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