OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / shapes / param / shape_fold.gd

307 lines · gd ref:
extends RefCounted
## PARAMETRIC SHAPES IN THE FOLD (docs/parametric-shapes-design.md §2): shape.* journal
## records -> the brush.create records DCLevel folds. Pure and deterministic.
##
##   shape.create  {type, gv, params, frame, size, material_id, level_id}  name = its dot
##   shape.set     {target, params}     last write wins PER PARAMETER, by (seq, author);
##                 {type, gv, input}    ASSIGN A GENERATOR (#120 F2): the type switches
##                                      (params accumulate per key, so switching back
##                                      restores them); `input` = the generator it had
##                                      ({type, params}), emitted as the new one's PASSAGE
##                                      ("the cube stays as the passage shape", #109) —
##                                      {} clears it (the generator removed)
##   shape.xform   {target, frame}      the latest wins
##   shape.size    {target, size}       the latest wins
##   shape.detach  {target}             "Make Real": the shape stops generating
##
## THE PIECES' NAMES. DCLevel names a created brush by its record's dot,
## author + ":" + seq (dc_level_journal.h Record::dot). A piece is emitted with
## author "<shape author>/<i>@<ver>" and the SHAPE's seq, so its name is
## "<author>/<i>@<ver>:<seq>" — the same on every peer, sorting at the shape's
## create position in the fold's (seq, author) order. No C++ change, and no fake
## seqs to collide with the Lamport counter. group = the shape's dot, so the
## outliner and group selection see one object.
##
## ⛔ <ver> IS LOAD-BEARING. DCLevel's BrushCreate is idempotent BY NAME: a name it
## already holds is RESTORED with its as-created planes, and the record's planes are
## ignored. Named by index alone, a shape edited from 4 to 7 steps brought index 4
## back as the old CLEARANCE box, and a moved shape restored every piece where it
## used to be (gate_shape_journal, 2026-10-05). <ver> = the highest seq among the
## live records that make up the shape's state, so any edit gives fresh names, and
## an undo returns to the earlier version's names, which restore with exactly the
## planes that state had.

const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")

## shape.remove: DELETE the shape (it generates nothing; undo brings it back). Not the
## same as shape.detach, which hands its pieces over as plain brushes (Make Real).
const KINDS := ["shape.create", "shape.set", "shape.xform", "shape.size", "shape.detach", "shape.remove"]

## MEMOS: A SHAPE THAT DID NOT CHANGE IS NOT REGENERATED. Every full fold re-derived every
## shape — a cable re-sampled its curve twice (bounds, pieces) — so with a bundle in the
## room an edit ANYWHERE paid for all of it: 40 cables, expand 98 ms of which
## resolve_states 60 (2026-10-20, ashatron paused on every box cut). Keyed by dot; a hit
## needs the stored inputs to be == the current ones (a deep compare, so no hash can
## collide), and the pieces depend on nothing else. `memo = false` turns both off
## (gate_shape_fold_memo compares the two).
static var memo := true
static var _bounds_memo := {}    # shape dot -> [[params, curve state], frame, size]
static var _pieces_memo := {}    # shape dot -> [[state, is_cutter, curve, profile curve], records]
const MEMO_MAX := 4096           # shapes ever seen this session; cleared past it


static func handles(kind: String) -> bool:
	return KINDS.has(kind)


static func _before(a: Dictionary, b: Dictionary) -> bool:
	if int(a["seq"]) != int(b["seq"]):
		return int(a["seq"]) < int(b["seq"])
	return String(a["author"]) < String(b["author"])


## Every shape's final state: {dot: {dot, author, seq, type, gv, params, frame, size,
## material_id, level_id, detached}}. Undone records are ignored.
##
## A CABLE (#120 L3, §11.2) reads its curve from `curves` (CurveFold.live): its frame and
## size are the curve's bounds plus the profile (what the gizmo and the selection centre
## read), and its <ver> takes the curve's, so a curve edit renames its pieces.
static func resolve_states(records: Array, curves: Dictionary = {}) -> Dictionary:
	var live: Array = []
	for r: Dictionary in records:
		if handles(String(r.get("kind", ""))) and not bool(r.get("undone", false)):
			live.append(r)
	live.sort_custom(_before)
	var states := {}
	for r: Dictionary in live:
		var kind := String(r["kind"])
		if kind == "shape.create":
			var dot := "%s:%d" % [String(r["author"]), int(r["seq"])]
			states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]),
				"type": String(r.get("type", "")), "gv": int(r.get("gv", 1)),
				"params": (r.get("params", {}) as Dictionary).duplicate(true),
				"frame": r.get("frame", Transform3D.IDENTITY), "size": r.get("size", Vector3.ONE),
				"material_id": int(r.get("material_id", 0)), "level_id": int(r.get("level_id", 0)),
				"tex": (r.get("tex", {}) as Dictionary).duplicate() if r.get("tex") is Dictionary else {},
				"input": (r.get("input", {}) as Dictionary).duplicate(true) if r.get("input") is Dictionary else {},
				"detached": false, "ver": int(r["seq"]), "link": String(r.get("link", ""))}
			continue
		var t := String(r.get("target", ""))
		if not states.has(t):
			continue                      # targets a shape that does not exist (yet)
		var s: Dictionary = states[t]
		s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
		# A LINKED DUPLICATE's data edits land on its root: they are shared (see _link_data).
		var root := String(s.get("link", ""))
		if kind == "shape.set" and root != "" and states.has(root):
			s = states[root]
			s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
		match kind:
			"shape.set":
				var ps: Dictionary = r.get("params", {})
				for k: String in ps:
					s["params"][k] = ps[k]
				if String(r.get("type", "")) != "":
					s["type"] = String(r["type"])
					s["gv"] = int(r.get("gv", 1))
				if r.get("input") is Dictionary:
					s["input"] = (r["input"] as Dictionary).duplicate(true)
				# The shape's texture (§13.3) — not a param (resolve() would drop it); last wins.
				if r.get("tex") is Dictionary:
					s["tex"] = (r["tex"] as Dictionary).duplicate()
			"shape.xform":
				s["frame"] = r.get("frame", s["frame"])
			"shape.size":
				s["size"] = r.get("size", s["size"])
			"shape.detach":
				s["detached"] = true
			"shape.remove":
				s["removed"] = true
	for d: String in states:
		_link_data(states, states[d])
	for d: String in states:
		if String(states[d]["type"]) == "cable":
			_cable_bounds(states[d], curves)
	return states


## A LINKED DUPLICATE (Alt+D, Blender's linked data): `link` names the root shape whose
## DATA it shares — type, gv, params, texture, input. Its frame, size (Blender's object
## scale), modifiers and cuts are its own. A shape.set on either lands on the root (above),
## so it changes every user; the link's <ver> takes the root's, so its pieces are renamed
## too. With the root gone (its create undone) the link keeps the data it was created
## with: a link record carries a full snapshot.
static func _link_data(states: Dictionary, s: Dictionary) -> void:
	var root := String(s.get("link", ""))
	if root == "" or not states.has(root):
		return
	var r: Dictionary = states[root]
	for k: String in ["type", "gv"]:
		s[k] = r[k]
	for k2: String in ["params", "tex", "input"]:
		s[k2] = (r[k2] as Dictionary).duplicate(true)
	s["ver"] = maxi(int(s["ver"]), int(r["ver"]))


## A cable's frame / size from its curve (axis-aligned, padded by the widest the profile
## gets: a connector's 1.6 x the radius x the largest point radius), and <ver> the curve's.
##
## A CUSTOM PROFILE (§14.2): <ver> takes the profile curve's too, so editing it renames the
## pieces. With the profile curve GONE the cable folds round, and `profile_gone` adds "r"
## to the names: <ver> falls back to an earlier version whose pieces were custom, and
## BrushCreate would restore those planes by name.
static func _cable_bounds(s: Dictionary, curves: Dictionary) -> void:
	var ps: Dictionary = s["params"]
	if String(ps.get("profile", "")) == "custom":
		var pcv: Dictionary = curves.get(String(ps.get("profile_curve", "")), {})
		if pcv.is_empty():
			s["profile_gone"] = true
		else:
			s["ver"] = maxi(int(s["ver"]), int(pcv.get("ver", 0)))
	var cv: Dictionary = curves.get(String(ps.get("curve", "")), {})
	if cv.is_empty() or (cv["points"] as Array).size() < 2:
		return
	s["ver"] = maxi(int(s["ver"]), int(cv.get("ver", 0)))
	# The frame / size below read the params and the curve, nothing else (MEMOS).
	var key := [ps, cv]
	var dot := String(s["dot"])
	if memo:
		var m: Variant = _bounds_memo.get(dot)
		if m is Array and (m as Array)[0] == key:
			s["frame"] = m[1]
			s["size"] = m[2]
			return
	var t: Object = ParamShapes.get_type("cable")
	var p: Dictionary = t.resolve(s["params"])
	var rmax := 0.0
	for q: Dictionary in cv["points"]:
		rmax = maxf(rmax, float(q.get("radius", 1.0)))
	var pad := Vector3.ONE * float(p["radius"]) * rmax * 1.6
	var fs := CurveMath.sample(cv["points"], bool(cv["closed"]), 64)
	var bb := AABB(fs[0]["p"], Vector3.ZERO)
	for f: Dictionary in fs:
		bb = bb.expand(f["p"])
	s["frame"] = Transform3D(Basis.IDENTITY, bb.position - pad)
	s["size"] = (bb.size + pad * 2.0).max(Vector3.ONE * 0.01)
	if memo:
		if _bounds_memo.size() > MEMO_MAX:
			_bounds_memo.clear()
		_bounds_memo[dot] = [key.duplicate(true), s["frame"], s["size"]]


## The dots of every shape that is generating right now (not detached, not removed).
static func live_dots(records: Array) -> Dictionary:
	var out := {}
	var states := resolve_states(records)
	for d: String in states:
		var s: Dictionary = states[d]
		if not bool(s["detached"]) and not bool(s.get("removed", false)):
			out[d] = true
	return out


## The synthetic brush.create records for every live, attached shape.
##
## `cutters` = the dots of shapes a live op cuts WITH (OpFold.cutter_dots). Their solid
## pieces come out as `cutter: true` (DCBrush::cutter — wire, clips nothing, not in the
## shell) and their clearance not at all: a cutter is a tool, and its headroom is no
## statement about the level's air. ⛔ The flag rides the NAME too ("…@<ver>c"):
## BrushCreate restores by name, so without it a shape that became a cutter would come
## back as its old solid brushes, and an undone op would leave it a cutter.
static func expand(records: Array, cutters: Dictionary = {}, curves: Dictionary = {}) -> Array:
	var states := resolve_states(records, curves)
	var order: Array = states.values()
	order.sort_custom(_before)
	var out: Array = []
	for s: Dictionary in order:
		if bool(s["detached"]) or bool(s.get("removed", false)):
			continue
		var t: Object = ParamShapes.get_type(String(s["type"]))
		if t == null or int(s["gv"]) > int(t.GV):
			# Unknown type, or one a newer build generated: skipping is the only safe
			# move, as with any record this build cannot read.
			push_warning("[ShapeFold] %s: cannot generate %s gv %d" % [s["dot"], s["type"], s["gv"]])
			continue
		var p: Dictionary = t.resolve(s["params"])
		var is_cutter: bool = cutters.has(String(s["dot"]))
		# MEMOS: the records below read the state, the cutter flag and the curves the
		# RESOLVED params name — the key is exactly those.
		var key := [s, is_cutter, curves.get(String(p.get("curve", "")), {}),
			curves.get(String(p.get("profile_curve", "")), {})]
		var dot := String(s["dot"])
		if memo:
			var m: Variant = _pieces_memo.get(dot)
			if m is Array and (m as Array)[0] == key:
				for r0: Dictionary in (m[1] as Array):
					out.append(r0.duplicate())   # shallow: nothing downstream may alter the memo's
				continue
		var recs: Array = []
		var size: Vector3 = s["size"]
		var frame: Transform3D = s["frame"]
		var pieces: Array = []
		# A cable's pieces are already in the WORLD (it reads a curve, not a box).
		var world: bool = t.has_method("world_pieces")
		if world:
			for pcw: Dictionary in t.world_pieces(curves.get(String(p.get("curve", "")), {}), p,
					curves.get(String(p.get("profile_curve", "")), {})):
				pieces.append([pcw, false])
		for pc: Dictionary in t.local_pieces(size, p):
			pieces.append([pc, false])
		for pc2: Dictionary in t.local_clearance(size, p):
			pieces.append([pc2, true])
		# THE INPUT AS PASSAGE: the solid this shape had before its generator was assigned.
		var inp: Dictionary = s.get("input", {})
		var ti: Object = ParamShapes.get_type(String(inp.get("type", ""))) if not inp.is_empty() else null
		if ti != null:
			for pc4: Dictionary in ti.local_pieces(size, ti.resolve(inp.get("params", {}))):
				pieces.append([pc4, true])
		var tex: Dictionary = s.get("tex", {})
		for i in pieces.size():
			var pc3: Dictionary = pieces[i][0]
			if is_cutter and bool(pieces[i][1]):
				continue
			# TEXTURED (§13.3): the shape's texture on every plane of a SOLID piece, as the
			# baked tool did at birth; clearance is air and gets none.
			var face_tex: Array = []
			if not bool(pieces[i][1]) and pc3.get("face_tex") is Array:
				face_tex = (pc3["face_tex"] as Array).duplicate(true)   # per plane (hull)
			elif not bool(pieces[i][1]) and not tex.is_empty():
				for _k in (pc3["planes"] as PackedFloat32Array).size() / 4:
					face_tex.append(tex.duplicate())
			recs.append({
				"author": "%s/%d@%d%s%s" % [s["author"], i, s["ver"], "c" if is_cutter else "",
					"r" if bool(s.get("profile_gone", false)) else ""],
				"seq": s["seq"], "kind": "brush.create", "cutter": is_cutter,
				"undone": false, "planes": pc3["planes"] if world else transform_planes(pc3["planes"], frame),
				"proj": PackedInt32Array(), "material_id": s["material_id"],
				"level_id": s["level_id"], "sky": false, "clearance": bool(pieces[i][1]),
				"group": s["dot"], "face_tex": face_tex,
			})
		if memo:
			if _pieces_memo.size() > MEMO_MAX:
				_pieces_memo.clear()
			_pieces_memo[dot] = [key.duplicate(true), recs]
		for r1: Dictionary in recs:
			out.append(r1.duplicate() if memo else r1)
	return out


## Local planes (n·x <= d) -> world planes under `frame` (x' = B x + t):
## n' = normalize(B^-T n), d' = (d + (B^-T n)·t) / |B^-T n|. General in B, so a
## frame that ever carries scale still produces exact planes.
static func transform_planes(planes: PackedFloat32Array, frame: Transform3D) -> PackedFloat32Array:
	var m := frame.basis.inverse().transposed()
	var out := PackedFloat32Array()
	out.resize(planes.size())
	for k in range(0, planes.size(), 4):
		var mn: Vector3 = m * Vector3(planes[k], planes[k + 1], planes[k + 2])
		var l := mn.length()
		if l < 1.0e-12:
			l = 1.0
		var n2 := mn / l
		out[k] = n2.x
		out[k + 1] = n2.y
		out[k + 2] = n2.z
		out[k + 3] = (planes[k + 3] + mn.dot(frame.origin)) / l
	return out