extends RefCounted
## MODIFIERS IN THE FOLD (#120 F2, docs/node-tree-and-stacks.md §3): mod.* journal records
## -> COPIES of a shape's generated pieces, as more brush.create records. Pure and
## deterministic, like ShapeFold and OpFold, and between them in the fold:
##
## generator (ShapeFold) -> copies (here) -> cuts (OpFold, brush.cut) -> chamfer
##
## mod.create {target: <shape dot>, mod: mirror|array|radial|curve, params}
## mod.set {target: <mod dot>, params?, on?} the latest wins, per field
## mod.remove {target} the modifier stops (undo brings it back)
##
## A SHAPE'S STACK. A modifier targets a parametric shape: its pieces are known here, before
## the C++ store, so a copy is a plane transform of a convex piece — still convex. (A plain
## brush's current planes exist only after the C++ fold; Make parametric turns any brush
## into a shape first, as a Blender modifier needs an object.) The copy modifiers of one
## shape apply in creation order, each to everything above it (Blender's stack).
##
## mirror {axis: x|y|z, offset} reflect across the shape frame's plane `axis` =
## offset (metres; 0 = the frame's low face, so the
## copy sits alongside)
## array {count, offset: [x, y, z]} count - 1 more copies, each shifted by `offset`
## times the shape's size, in its frame
## grid {count: [nx, ny, nz], a box grid (#120 L1): a copy in every cell of an
## offset: [x, y, z]} nx x ny x nz block, cell = offset times the size,
## in the frame (offset 1 = side by side)
## radial {count, axis, pivot: [x,y,z]} count - 1 more copies turned 360/count degrees
## about the frame's `axis` through the world `pivot`
## curve {curve: <curve dot>, count} FOLLOW-CURVE (F5): the object goes ONTO the curve
## — itself and count - 1 copies, evenly spaced by
## length (both ends on an open curve). Each maps the
## shape's centre to its sample with the frame's x
## along the tangent, y along the curve normal, scaled
## by the curve's radius there: Blender's Array (fit
## curve) + Curve, but RIGID, so every piece stays
## convex. count 0 = fit: copies one size.x apart.
## The curve gone: the object is back where it was
## modelled.
## chamfer {width, segments, angle} NOT a copy: a brush.chamfer record folded AFTER the
## cuts (DCLevel BrushChamfer), on the shape's group —
## its copies and cut pieces too, never their seams
##
## THE COPIES' NAMES: "<piece author>~<mod ver>.<k>" at the shape's seq, group = the shape's
## dot (so selection, collections and cuts see one object). ⛔ <mod ver> = the highest seq
## among the modifier's live records: DCLevel's BrushCreate restores a name it already holds
## with its AS-CREATED planes, so an edit must rename the copies (ShapeFold's <ver> rule).
## The piece author already carries the shape's <ver>. A curve modifier's <ver> also takes its
## curve's (CurveFold "ver"): an edited curve must rename the copies the same way, and the
## original, which it MOVES, is renamed "<piece author>~<ver>.0".
##
## A CUT ON A SHAPE IS COPIED WITH IT (§3.3): copy_cutters() gives each live op's cutter the
## same transforms as its target's copies, so a mirrored shape is cut symmetrically.
const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
const KINDS := ["mod.create", "mod.set", "mod.remove"]
## dice is not a copy, but it rides the same per-shape list (expand runs it first).
const COPY_KINDS := ["mirror", "array", "grid", "radial", "curve", "dice"]
## A follow-curve's copies at most (count, or the fit).
const CURVE_MAX := 256
## Chamfers fold after every cut: brush.cut sits at OP_SEQ_BASE + seq (OpFold), so this is
## above any of those and still exact in a double (2^52 + 2^51 + seq < 2^53).
const CHAMFER_SEQ_BASE := (1 << 52) + (1 << 51)
const AXES := {"x": 0, "y": 1, "z": 2}
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 modifier's final state: {dot: {dot, author, seq, ver, target, kind, params, on,
## removed}}. Undone records are ignored.
static func resolve_states(records: Array) -> 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 == "mod.create":
var dot := "%s:%d" % [String(r["author"]), int(r["seq"])]
states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]),
"ver": int(r["seq"]), "target": String(r.get("target", "")),
"kind": String(r.get("mod", "")),
"params": (r.get("params", {}) as Dictionary).duplicate(true),
"on": true, "removed": false}
continue
var t := String(r.get("target", ""))
if not states.has(t):
continue
var s: Dictionary = states[t]
s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
match kind:
"mod.set":
var ps: Dictionary = r.get("params", {})
for k: String in ps:
s["params"][k] = ps[k]
if r.has("on"):
s["on"] = bool(r["on"])
"mod.remove":
s["removed"] = true
return states
## The modifiers that still exist (on or off) on `shape`, in stack (creation) order.
static func stack_of(records: Array, shape: String) -> Array:
var out: Array = []
for s: Dictionary in resolve_states(records).values():
if String(s["target"]) == shape and not bool(s["removed"]):
out.append(s)
out.sort_custom(_before)
return out
## Live copy modifiers grouped by target: {shape dot: [state, …]} in stack order.
static func live_by_target(records: Array) -> Dictionary:
var out := {}
var all: Array = resolve_states(records).values()
all.sort_custom(_before)
for s: Dictionary in all:
if bool(s["removed"]) or not bool(s["on"]) or not COPY_KINDS.has(String(s["kind"])):
continue
var t := String(s["target"])
if not out.has(t):
out[t] = []
(out[t] as Array).append(s)
return out
## The world-space transforms one modifier makes: the original's first (the identity, but
## for a curve modifier, which moves it), then one per copy. `curves` = CurveFold.live.
static func transforms_of(m: Dictionary, frame: Transform3D, size: Vector3, curves: Dictionary = {}) -> Array[Transform3D]:
var out: Array[Transform3D] = [Transform3D.IDENTITY]
var p: Dictionary = m["params"]
var b := frame.basis.orthonormalized()
match String(m["kind"]):
"mirror":
var ai := int(AXES.get(String(p.get("axis", "x")), 0))
var n: Vector3 = b[ai]
var point: Vector3 = frame.origin + n * float(p.get("offset", 0.0))
# Reflection across the plane through `point` with normal n: x' = R x + 2 (n·point) n.
var r := Basis.IDENTITY
for c in 3:
var e := Basis.IDENTITY[c]
r[c] = e - 2.0 * n * n.dot(e)
out.append(Transform3D(r, 2.0 * n.dot(point) * n))
"array":
var off: Array = p.get("offset", [1.0, 0.0, 0.0])
var step: Vector3 = b * Vector3(float(off[0]) * size.x, float(off[1]) * size.y, float(off[2]) * size.z)
for k in range(1, maxi(1, int(p.get("count", 2)))):
out.append(Transform3D(Basis.IDENTITY, step * float(k)))
"grid":
# Every cell of an nx x ny x nz block but the original's (0, 0, 0).
var cn: Array = p.get("count", [2, 1, 2])
var gap: Array = p.get("offset", [1.0, 1.0, 1.0])
var cell: Vector3 = Vector3(float(gap[0]) * size.x, float(gap[1]) * size.y, float(gap[2]) * size.z)
for i in maxi(1, int(cn[0])):
for j in maxi(1, int(cn[1])):
for k in maxi(1, int(cn[2])):
if i + j + k > 0:
out.append(Transform3D(Basis.IDENTITY, b * (Vector3(i, j, k) * cell)))
"radial":
var n2: Vector3 = b[int(AXES.get(String(p.get("axis", "y")), 1))]
var pv: Array = p.get("pivot", [0.0, 0.0, 0.0])
var pivot := Vector3(float(pv[0]), float(pv[1]), float(pv[2]))
var count := maxi(1, int(p.get("count", 4)))
for k in range(1, count):
var rot := Basis(n2, TAU * float(k) / float(count))
out.append(Transform3D(rot, pivot - rot * pivot))
"curve":
var cv: Dictionary = curves.get(String(p.get("curve", "")), {})
if cv.is_empty() or (cv["points"] as Array).size() < 2:
return out # no curve: the object stays where it was modelled
var pts: Array = cv["points"]
var closed := bool(cv["closed"])
var count := int(p.get("count", 0))
if count <= 0:
var fit := roundi(CurveMath.length(pts, closed) / maxf(size.x, 0.01))
count = fit if closed else fit + 1
count = clampi(count, 2, CURVE_MAX)
# The shape's centre, in its (orthonormal) frame -> each sample's frame.
var to_local := Transform3D(b, frame * (size * 0.5)).affine_inverse()
out.clear()
for f: Dictionary in CurveMath.sample(pts, closed, count):
var r := float(f["radius"])
var at := Basis(f["t"] as Vector3 * r, f["n"] as Vector3 * r, f["b"] as Vector3 * r)
out.append(Transform3D(at, f["p"]) * to_local)
return out
## DICE (#120 L1 §12.4): every solid piece of `work` split into the cells of an even
## count[0] x count[1] x count[2] grid over the shape's frame — two planes per cell axis,
## so each cell stays convex. A piece's first cell keeps the record (renamed
## "<author>~d<ver>.0", its planes changed); the rest are new records "~d<ver>.<k>",
## appended to `out`. Clearance and cutter pieces are not diced. Returns the new work.
static func _dice(work: Array, out: Array, m: Dictionary, frame: Transform3D, size: Vector3) -> Array:
var cn: Array = (m["params"] as Dictionary).get("count", [2, 1, 2])
var b := frame.basis.orthonormalized()
var o := frame.origin
# Per axis: the cells' plane pairs, [] for a side that is the frame's own face.
var axes: Array = []
for a in 3:
var n := maxi(1, int(cn[a]))
var cells: Array = []
for i in n:
var pl := PackedFloat32Array()
var lo := b[a].dot(o) + size[a] * float(i) / float(n)
var hi := b[a].dot(o) + size[a] * float(i + 1) / float(n)
if i > 0:
pl.append_array(PackedFloat32Array([-b[a].x, -b[a].y, -b[a].z, -lo]))
if i < n - 1:
pl.append_array(PackedFloat32Array([b[a].x, b[a].y, b[a].z, hi]))
cells.append(pl)
axes.append(cells)
var ver := int(m["ver"])
var next: Array = []
for r: Dictionary in work:
if bool(r.get("clearance", false)) or bool(r.get("cutter", false)):
next.append(r)
continue
var kept: Array = []
for cx: PackedFloat32Array in axes[0]:
for cy: PackedFloat32Array in axes[1]:
for cz: PackedFloat32Array in axes[2]:
var pl: PackedFloat32Array = (r["planes"] as PackedFloat32Array) + cx + cy + cz
if _has_volume(pl):
kept.append(pl)
if kept.size() < 2:
next.append(r) # nothing to split (or no cell has volume)
continue
var author := String(r["author"])
r["author"] = "%s~d%d.0" % [author, ver]
r["planes"] = kept[0]
next.append(r)
for k in range(1, kept.size()):
var c := r.duplicate(true)
c["author"] = "%s~d%d.%d" % [author, ver, k]
c["planes"] = kept[k]
out.append(c)
next.append(c)
return next
## A plane set encloses a solid, not a sliver: its corners span more than a millimetre
## on every axis.
static func _has_volume(planes: PackedFloat32Array) -> bool:
var cs := FaceKit.corners(planes)
if cs.size() < 4:
return false
var lo := cs[0]
var hi := cs[0]
for v: Vector3 in cs:
lo = lo.min(v)
hi = hi.max(v)
var e := hi - lo
return e.x > 1.0e-3 and e.y > 1.0e-3 and e.z > 1.0e-3
## A modifier's <ver> for its copies' names: its own, or its curve's when that is later.
static func _ver_of(m: Dictionary, curves: Dictionary) -> int:
var v := int(m["ver"])
if String(m["kind"]) == "curve":
var cv: Dictionary = curves.get(String((m["params"] as Dictionary).get("curve", "")), {})
v = maxi(v, int(cv.get("ver", 0)))
return v
## `shaped` = ShapeFold.expand's records; `shape_states` = ShapeFold.resolve_states; `curves`
## = CurveFold.live. Returns the copies to append: each live copy modifier, in stack order,
## copies every piece of its shape made so far (the generator's and the earlier modifiers').
## Clearance comes along (a copied stair keeps its headroom); a cutter's pieces too (it cuts
## as a copy). A curve modifier also MOVES those pieces (in `shaped`, renamed).
static func expand(records: Array, shaped: Array, shape_states: Dictionary, curves: Dictionary = {}) -> Array:
var mods := live_by_target(records)
if mods.is_empty():
return []
var by_shape := {}
for r: Dictionary in shaped:
var g := String(r.get("group", ""))
if mods.has(g):
if not by_shape.has(g):
by_shape[g] = []
(by_shape[g] as Array).append(r)
var out: Array = []
for g: String in mods:
if not shape_states.has(g) or not by_shape.has(g):
continue
var st: Dictionary = shape_states[g]
var work: Array = by_shape[g]
# DICE FIRST (§12.4), whatever the record order: the copies then copy its cells.
for m: Dictionary in mods[g]:
if String(m["kind"]) == "dice":
work = _dice(work, out, m, st["frame"], st["size"])
for m: Dictionary in mods[g]:
if String(m["kind"]) == "dice":
continue
var xs := transforms_of(m, st["frame"], st["size"], curves)
var ver := _ver_of(m, curves)
var made: Array = []
for k in range(1, xs.size()):
for r: Dictionary in work:
var c := r.duplicate(true)
c["author"] = "%s~%d.%d" % [String(r["author"]), ver, k]
c["planes"] = ShapeFold.transform_planes(r["planes"], xs[k])
made.append(c)
if xs[0] != Transform3D.IDENTITY:
for r2: Dictionary in work:
r2["author"] = "%s~%d.0" % [String(r2["author"]), ver]
r2["planes"] = ShapeFold.transform_planes(r2["planes"], xs[0])
work = work + made
out.append_array(made)
return out
## The brush.chamfer records of every live chamfer modifier whose shape is generating
## (`live_shapes`), in stack order, after every cut.
static func chamfers(records: Array, live_shapes: Dictionary) -> Array:
var all: Array = resolve_states(records).values()
all.sort_custom(_before)
var out: Array = []
for m: Dictionary in all:
if bool(m["removed"]) or not bool(m["on"]) or String(m["kind"]) != "chamfer":
continue
if not live_shapes.has(String(m["target"])):
continue
var p: Dictionary = m["params"]
# The modifier's <ver> rides the author, so an edit gives the chamfer faces new keys.
out.append({"author": "%s~%d" % [String(m["author"]), int(m["ver"])],
"seq": CHAMFER_SEQ_BASE + int(m["seq"]), "kind": "brush.chamfer", "undone": false,
"targets": PackedStringArray([String(m["target"])]),
"width": float(p.get("width", 0.1)), "segments": int(p.get("segments", 1)),
"angle": float(p.get("angle", 30.0))})
return out
## Each brush.cut's cutters, copied by the transforms of its targets' modifiers (§3.3), so
## a cut follows the copies. `cuts` = OpFold.expand's records; modifies them in place. A
## curve modifier's moved original gets its cutters ADDED (not moved: the cut may have
## other targets that stay put).
static func copy_cutters(records: Array, cuts: Array, shape_states: Dictionary, curves: Dictionary = {}) -> void:
var mods := live_by_target(records)
if mods.is_empty():
return
for cut: Dictionary in cuts:
var extra: Array = []
for t: Variant in cut.get("targets", PackedStringArray()):
var g := String(t)
if not mods.has(g) or not shape_states.has(g):
continue
var st: Dictionary = shape_states[g]
var cs: Array = (cut["cutters"] as Array).duplicate()
for m: Dictionary in mods[g]:
var xs := transforms_of(m, st["frame"], st["size"], curves)
var more: Array = []
for k in range(0 if xs[0] != Transform3D.IDENTITY else 1, xs.size()):
for c: Variant in cs:
more.append(ShapeFold.transform_planes(c, xs[k]))
cs.append_array(more)
extra.append_array(more)
(cut["cutters"] as Array).append_array(extra)