OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / mod_fold.gd

367 lines · gd ref:
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)