extends RefCounted
## Town perf (2026-10-02): a static town NPC figure as ONE vertex-coloured mesh instead of Chibi.humanoid()'s
## 16 primitive MeshInstance3Ds (9 with an ink-outline pass). Town NPCs never move their limbs (npc.gd only bobs
## the whole model), so the look is identical while the draw calls per NPC drop from ~34 (16 colour + 9 outline
## + 9 shadow) to ~7: one shadowed mesh (surface 0 = outlined parts, surface 1 = outlined hair with the sheen band)
## + one shadow-less mesh for the tiny face parts (eyes, highlights, blush), like Chibi.add() treats them.
## The geometry is baked ONCE from a reference Chibi.humanoid() built with sentinel colours (so any change to the
## humanoid recipe is picked up automatically); per NPC only the colour array is filled (one fill per part).
## Colour lives in the vertices (sRGB): toon.gdshader / outline.gdshader `use_vcolor`, materials shared by all.
## npc.gd calls humanoid(skin, hair, shirt, pants) in place of Chibi.humanoid().

const Chibi = preload("res://scripts/chibi.gd")
## sentinel colours of the reference figure -> slot 0..3 = skin, hair, shirt, pants
const SENT := [Color(0.101, 0.0, 0.0), Color(0.202, 0.0, 0.0), Color(0.303, 0.0, 0.0), Color(0.404, 0.0, 0.0)]

## perf A/B: `-- --nobake` keeps the old 16-part figures
static var enabled := not OS.get_cmdline_user_args().has("--nobake")
static var _tpl: Array = []  # [{verts, norms, idx, segs: [[slot or Color, count]], tiny, outline, spec}]
static var _mats := {}
static var _cache := {}


static func humanoid(skin: Color, hair: Color, shirt: Color, pants: Color) -> Node3D:
	if not enabled:
		return Chibi.humanoid(skin, hair, shirt, pants)
	if _tpl.is_empty():
		_tpl = _template()
		if _tpl.is_empty():
			enabled = false
			return Chibi.humanoid(skin, hair, shirt, pants)
	var key := "%s%s%s%s" % [skin.to_html(), hair.to_html(), shirt.to_html(), pants.to_html()]
	var meshes: Array = _cache.get(key, [])
	if meshes.is_empty():
		meshes = _meshes([skin, hair, shirt, pants])
		_cache[key] = meshes
	var root := Node3D.new()
	var mi := MeshInstance3D.new()
	mi.name = "Baked"
	mi.mesh = meshes[0]
	root.add_child(mi)
	if meshes[1] != null:
		var tiny := MeshInstance3D.new()
		tiny.name = "BakedTiny"
		tiny.mesh = meshes[1]
		tiny.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		root.add_child(tiny)
	return root


static func _meshes(cols4: Array) -> Array:
	var main := ArrayMesh.new()
	var tiny: ArrayMesh = null
	for g in _tpl:
		var cols := PackedColorArray()
		for sg in g["segs"]:
			var seg := PackedColorArray()
			seg.resize(int(sg[1]))
			seg.fill(cols4[sg[0]] if sg[0] is int else sg[0])
			cols.append_array(seg)
		var a := []
		a.resize(Mesh.ARRAY_MAX)
		a[Mesh.ARRAY_VERTEX] = g["verts"]
		a[Mesh.ARRAY_NORMAL] = g["norms"]
		a[Mesh.ARRAY_COLOR] = cols
		a[Mesh.ARRAY_INDEX] = g["idx"]
		var target := main
		if g["tiny"]:
			if tiny == null:
				tiny = ArrayMesh.new()
			target = tiny
		target.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, a)
		target.surface_set_material(target.get_surface_count() - 1, _mat(g["outline"], g["spec"]))
	return [main, tiny]


static func _rel_xf(n: Node3D, root: Node3D) -> Transform3D:
	var xf := Transform3D.IDENTITY
	var cur: Node = n
	while cur != null and cur != root:
		if cur is Node3D:
			xf = (cur as Node3D).transform * xf
		cur = cur.get_parent()
	return xf


## Bakes the reference figure into per-material-group geometry; [] when the recipe holds something unexpected.
static func _template() -> Array:
	var ref: Node3D = Chibi.humanoid(SENT[0], SENT[1], SENT[2], SENT[3])
	var groups := {}
	var order: Array = []
	var ok := true
	for node in ref.find_children("*", "MeshInstance3D", true, false):
		var m := node as MeshInstance3D
		var sm := m.material_override as ShaderMaterial
		if m.mesh == null or sm == null or m.mesh.get_surface_count() != 1:
			ok = false
			break
		var outline := sm.next_pass != null
		var tiny := m.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		var sp = sm.get_shader_parameter("spec_amount")
		var spec := float(sp) if sp != null else 0.0
		var gk := "%d%d%.2f" % [int(tiny), int(outline), spec]
		if not groups.has(gk):
			groups[gk] = {"verts": PackedVector3Array(), "norms": PackedVector3Array(), "idx": PackedInt32Array(),
				"segs": [], "tiny": tiny, "outline": outline, "spec": spec}
			order.append(gk)
		var g: Dictionary = groups[gk]
		var arr := m.mesh.surface_get_arrays(0)
		var xf := _rel_xf(m, ref)
		var v: PackedVector3Array = xf * (arr[Mesh.ARRAY_VERTEX] as PackedVector3Array)
		# the toon / outline shaders normalize the normal themselves -> no per-vertex normalize needed
		var nrm: PackedVector3Array = Transform3D(xf.basis.inverse().transposed(), Vector3.ZERO) * (arr[Mesh.ARRAY_NORMAL] as PackedVector3Array)
		var base: int = (g["verts"] as PackedVector3Array).size()
		var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX] if arr[Mesh.ARRAY_INDEX] != null else PackedInt32Array()
		if idx.is_empty():
			idx.resize(v.size())
			for i in v.size():
				idx[i] = i
		for i in idx.size():
			idx[i] += base
		# packed arrays: modify a local and store it back (never rely on in-place edits through the Dictionary)
		var gi: PackedInt32Array = g["idx"]
		gi.append_array(idx)
		g["idx"] = gi
		var gv: PackedVector3Array = g["verts"]
		gv.append_array(v)
		g["verts"] = gv
		var gn: PackedVector3Array = g["norms"]
		gn.append_array(nrm)
		g["norms"] = gn
		var c: Color = sm.get_shader_parameter("albedo")
		var slot = c
		for s in 4:
			if c.is_equal_approx(SENT[s]):
				slot = s
		(g["segs"] as Array).append([slot, v.size()])
	ref.free()
	if not ok:
		return []
	# shadowed groups first (main mesh), stable order
	var out: Array = []
	for gk in order:
		if not groups[gk]["tiny"]:
			out.append(groups[gk])
	for gk in order:
		if groups[gk]["tiny"]:
			out.append(groups[gk])
	return out


static func _mat(outline: bool, spec: float) -> ShaderMaterial:
	var k := "%d%.2f" % [int(outline), spec]
	if _mats.has(k):
		return _mats[k]
	var m := ShaderMaterial.new()
	m.shader = load("res://shaders/toon.gdshader")
	m.set_shader_parameter("albedo", Color(1, 1, 1))
	m.set_shader_parameter("use_vcolor", true)
	if spec > 0.0:
		m.set_shader_parameter("spec_amount", spec)
	if outline:
		var o := ShaderMaterial.new()
		o.shader = load("res://shaders/outline.gdshader")
		o.set_shader_parameter("thickness", 0.022)
		o.set_shader_parameter("use_vcolor", true)
		m.next_pass = o
	_mats[k] = m
	return m
