## Ths class is designed to create a mesh that
## will display a path between pairs of VisualizationSpots.
## 
## To use a VisualizedPath it must be populated with an array
## of spots which define the local object space positions and
## normals for the path at that point.
##
## The spots array can be defined programmatically such as by 
## user action (such as clicking) or defined in the editor using
## the VizPath gizmo.
##
## Not all paths are valid.  Putting the spots too close together 
## or with normals that do not leave enough room for bends to be
## created with the defined path characteristics (width, inner radius,
## etc.), will result in the path not being displayed and the "error"
## attribute being set to the underlying problem.
##

@tool
@icon("res://addons/vizpath/images/path.png")
extends Node3D
class_name VisualizedPath

signal changed_layout

## The spots array defines where the path will go between.
## See [VisualizationSpot]
@export var spots : Array[VisualizationSpot] :
	set(p_spots):
		spots = p_spots
		for spot in spots:
			if not spot.changed.is_connected(_rebuild):
				spot.changed.connect(_rebuild)
		_rebuild()

## The path_width defines the width of the path, which indirectly
## determines how tight the path can turn at a midpoint spot.
@export_range(0.005, 100, 0.001) var path_width := 0.1 :
	set(w):
		path_width = w
		_rebuild()

## The inner_curve_radius defines how tight a turn can be made
## at a midpoint spot within the path.
@export_range(0.005, 100, 0.001) var inner_curve_radius := 0.1 :
	set(r):
		inner_curve_radius = r
		_rebuild()

## The num_curve_segs defines how many points will be defined in the
## arc that are used to create a turn.  More segments will
## make a smoother UV mapping and therefore the display of associated material
## more accurate.
@export_range(4, 128) var num_curve_segs := 32 :
	set(r):
		num_curve_segs = r
		_rebuild()

## The bend_segs defines the segments in a bend of the path when transitioning
## between two spots with normals not in the same plane.
@export_range(4, 128) var bend_segs := 6 :
	set(r):
		bend_segs = r
		_rebuild()

## The bend_lip defines the distance that a segment extends from the intersection
## of the planes defined by the beginning and ending normals when those normals 
## are not in the same plane.  Making this a smaller value will make the 
## segment flatter for more of its length.
@export_range(0.005, 100, 0.001) var bend_lip := 0.1 :
	set(r):
		bend_lip = r
		_rebuild()

## The bend_sharpness defines how sharp the bend in a path.  Making this
## smaller will result in a tighter bend.
@export var bend_sharpness := 1.0 :
	set(r):
		bend_sharpness = r
		_rebuild()

## The path_mat is the material that will be applied to the underlying mesh
## that displays the path.  The initial spot in the path will have texture coordinates
## that start with U equal to 0.0 and ending at the value calculated as the actual length of the path
## in local space.  The V will range between 0.0 at the left side of the path, and 1.0 at the 
## right side of the path.
@export var path_mat : Material :
	set(m):
		path_mat = m
		_rebuild()

## The path_head is a resource (optional) that defines the end of the path by 
## providing an "apply" method.  The provided [VizHead] resource is an example
## that draws an arrow head on the end.
@export var path_head : VizHead :
	set(m):
		path_head = m
		path_head.changed.connect(_rebuild)
		_rebuild()

## The path_tail is a resource (optional) that defines the start of the path by 
## providing an "apply" method.  The provided [VizTail] resource is an example
## that draws a rounded cap on the end.
@export var path_tail : VizTail :
	set(m):
		path_tail = m
		path_tail.changed.connect(_rebuild)
		_rebuild()

@export var suppress_warnings := false :
	set(s):
		suppress_warnings = s
		_rebuild()

var _mesh_instance : MeshInstance3D
var _errors : Array[String]

func get_triangle_mesh() -> TriangleMesh:
	if _mesh_instance.mesh != null:
		return _mesh_instance.mesh.generate_triangle_mesh()
	return null

func _ready():
	_mesh_instance = MeshInstance3D.new()
	add_child(_mesh_instance)
	_rebuild()

func _get_configuration_warnings():
	if not suppress_warnings:
		return PackedStringArray(_errors)
	return PackedStringArray()

func get_errors() -> Array[String]:
	return _errors

func _rebuild():
	_errors = []
	if _mesh_instance == null:
		return
	if spots.size() < 2:
		_errors.append("the spots array must have at least 2 entries")
	_mesh_instance.mesh = null
	var u := 0.0
	var segments : Array[VizSegment] = []
	for idx in range(1, spots.size()):
		segments.push_back(VizSegment.new(spots[idx-1], spots[idx], path_width, bend_lip))
	for idx in range(1, segments.size()):
		if segments[idx-1].is_invalid():
			_errors.push_back(segments[idx-1].get_error())
			break
		var midpoint := VizMid.new(segments[idx-1], segments[idx], path_width, inner_curve_radius)
		if midpoint.is_invalid():
			_errors.push_back(midpoint.get_error())
			break
		# Creating midpoint may invalidate prior segment
		if segments[idx-1].is_invalid():
			_errors.push_back(segments[idx-1].get_error())
			break
		if idx == 1:
			u = _add_tail(segments[idx-1], u)
		u = segments[idx-1].update_mesh(_mesh_instance, u, bend_segs, bend_sharpness, path_mat)
		u = midpoint.update_mesh(_mesh_instance, u, num_curve_segs, path_mat)
	if _errors.size() == 0:
		if segments[segments.size()-1].is_invalid():
			_errors.push_back(segments[segments.size()-1].get_error())
		else:
			if segments.size() == 1:
				u = _add_tail(segments[0], u)
			_add_head(segments[segments.size()-1], u)
	update_configuration_warnings()
	changed_layout.emit()

func _add_head(head : VizSegment, u : float):
	if path_head != null:
		var end := head.get_end().point
		var binormal := head.get_end_binormal()
		var left := end - binormal * path_width / 2.0
		var right := end + binormal * path_width / 2.0
		var normal := head.get_end().normal
		var direction := head.get_end_ray()
		var offset := path_head.get_offset(left, right, normal, direction)
		head.adjust_end(offset)
		u = head.update_mesh(_mesh_instance, u, bend_segs, bend_sharpness, path_mat)
		path_head.apply(_mesh_instance, u, left - direction * offset, right - direction * offset, normal, direction, path_mat)
	else:
		head.update_mesh(_mesh_instance, u, bend_segs, bend_sharpness, path_mat)

func _add_tail(tail : VizSegment, u : float) -> float:
	if path_tail != null:
		var begin := tail.get_begin().point
		var binormal := tail.get_begin_binormal()
		var left := begin - binormal * path_width / 2.0
		var right := begin + binormal * path_width / 2.0
		var normal := tail.get_begin().normal
		var direction := tail.get_begin_ray()
		var offset := path_tail.get_offset(left, right, normal, direction)
		tail.adjust_begin(offset)
		path_tail.apply(_mesh_instance, u, left - direction * offset, right - direction * offset, normal, direction, path_mat)
		return u + offset
	return u
