extends RefCounted
## PLACED ASSETS IN THE FOLD (#120 L4 §15.2): asset.* journal records -> the SYNTHETIC
## curve / shape / mod / op / brush / object records their blobs hold, so ShapeFold,
## ModFold, OpFold, CurveFold, DCLevel and SceneObjects see ordinary things. Pure and
## deterministic, like the other folds; runs FIRST in LevelPersistence.refold.
##
## asset.place {asset: oid, name, frame} the placement's dot is its identity
## asset.xform {target, frame} the latest wins
## asset.set {target, asset: oid} another version of the asset (latest wins)
## asset.remove {target} gone (undo brings it back)
##
## THE TRANSFORM: M = frame x translate(-pivot). A placement at its own pivot is M = identity
## exactly, and folds to the very planes the collection had (the gate's oracle). (Applying
## translate(-pivot) and the frame as two steps is exact there too, for any coordinates
## within a factor of 2 of the pivot — x - p is then exact (Sterbenz) — so the gate cannot
## and need not tell the two apart.) Shapes' frames, curve points, a radial modifier's world pivot and top-level
## objects take M directly; brush objects are created as captured and then turned and moved
## by brush.rotate / brush.translate records with tex_lock, so their textures follow through
## the store's own texture lock (asset_doc.gd header). No scale (§15.2).
##
## THE NAMES: local dot "L:<n>" -> "<placement author>/A<nnnn>:<placement seq>", zero-padded
## so the (seq, author) order inside a placement is the blob's order (modifier stacks and
## cuts keep their order). A shape's <ver> must move with the placement (BrushCreate
## restores by name): when a later asset.xform / asset.set is live, each shape also gets a
## same-frame shape.xform at that seq, which is how ShapeFold bumps <ver> for any shape.
##
## A BLOB NOT HERE YET folds to ONE wire box of its bounds (cutter: drawn, clips nothing,
## not in the shell), named "<placement author>/A0000:<seq>" — never an empty hole that
## reads as a delete. `missing` lists the oids to fetch.
const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
const KINDS := ["asset.place", "asset.xform", "asset.set", "asset.remove"]
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 placement's final state: {dot: {dot, author, seq, asset, name, frame, ver, removed}}.
## `ver` = the highest seq among its live xform / set records (its create's seq without).
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 == "asset.place":
var dot := "%s:%d" % [String(r["author"]), int(r["seq"])]
states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]),
"asset": String(r.get("asset", "")), "name": String(r.get("name", "")),
"frame": r.get("frame", Transform3D.IDENTITY), "ver": int(r["seq"]), "removed": false}
continue
var t := String(r.get("target", ""))
if not states.has(t):
continue
var s: Dictionary = states[t]
match kind:
"asset.xform":
s["frame"] = r.get("frame", s["frame"])
s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
"asset.set":
if String(r.get("asset", "")) != "":
s["asset"] = String(r["asset"])
s["ver"] = maxi(int(s["ver"]), int(r["seq"]))
"asset.remove":
s["removed"] = true
return states
## The synthetic records for every live placement. `docs` = oid -> decoded AssetDoc;
## `parse` = func(author: String, seq: int, payload: Dictionary) -> Dictionary, the journal's
## own record parser (LevelPersistence._parse_as), so a synthetic record is exactly what the
## same payload journalled would be.
## {records: [parsed records, each with inst = the placement dot], missing: [oid…],
## inst: {synthetic dot / group -> placement dot}}
static func expand(records: Array, docs: Dictionary, parse: Callable) -> Dictionary:
var out: Array = []
var missing := {}
var inst := {}
var states := resolve_states(records)
var order: Array = states.values()
order.sort_custom(_before)
for p: Dictionary in order:
if bool(p["removed"]):
continue
var pdot := String(p["dot"])
var doc: Dictionary = docs.get(String(p["asset"]), {})
if doc.is_empty():
missing[String(p["asset"])] = true
var box := _missing_box(p, parse)
if not box.is_empty():
out.append(box)
inst[pdot] = pdot # the box's group is the placement's dot
continue
_expand_one(p, doc, parse, out, inst)
var ms: Array = missing.keys()
ms.sort()
return {"records": out, "missing": ms, "inst": inst}
## M = frame x translate(-pivot), composed so that frame = translate(pivot) gives IDENTITY.
static func placement_xform(frame: Transform3D, pivot: Vector3) -> Transform3D:
return Transform3D(frame.basis, frame.origin - frame.basis * pivot)
static func _expand_one(p: Dictionary, doc: Dictionary, parse: Callable, out: Array, inst: Dictionary) -> void:
var pa := String(p["author"])
var ps := int(p["seq"])
var pdot := String(p["dot"])
var names := local_names(doc)
var dot_of := func(local: String) -> String:
return "%s/A%04d:%d" % [pa, int(names[local]), ps] if names.has(local) else ""
var moved: bool = int(p["ver"]) > ps
for e: Dictionary in payloads(p, doc, dot_of):
var local := String(e["local"])
var au := "%s/A%04d%s" % [pa, int(names[local]), String(e["suffix"])]
var pl: Dictionary = e["payload"]
var rec: Dictionary = parse.call(au, ps, pl)
if rec.is_empty():
continue
rec["inst"] = pdot
out.append(rec)
match String(pl["kind"]):
"shape.create":
inst[dot_of.call(local)] = pdot
if moved:
# Same frame, later seq: ShapeFold's <ver> rule renames the pieces.
var xr: Dictionary = parse.call(au, int(p["ver"]), {"kind": "shape.xform",
"target": dot_of.call(local), "frame": pl["frame"]})
if not xr.is_empty():
xr["inst"] = pdot
out.append(xr)
"brush.create":
inst[String(pl["group"])] = pdot
"object.create":
inst[dot_of.call(local)] = pdot
## Local name -> its number: every record's dot, and every brush object's local GROUP (a
## name its brush.create records share, not a record of its own — it needs a name too: ""
## would merge it with every ungrouped brush in the level).
static func local_names(doc: Dictionary) -> Dictionary:
var names := {}
for r: Dictionary in (doc["records"] as Array):
names[String(r["dot"])] = int(String(r["dot"]).substr(2))
var lg := String(r.get("group", "")) if String(r["kind"]) == "brush.create" else ""
if lg.begins_with("L:") and not names.has(lg):
names[lg] = int(lg.substr(2))
return names
## A placement's records as journal PAYLOADS, M applied and every local reference resolved
## by `dot_of` (local name -> the dot it becomes): [{local, suffix, payload}], in the blob's
## order. `suffix` "" = the local's own record; "r" / "t" a brush object's turn / move,
## "s" a modifier's or cut's off switch, "p" / "q" an object's parent / props — follow-ups
## that must sort after it. SHARED by the fold (synthetic dots) and Make Real (fresh dots),
## so the two cannot drift.
static func payloads(p: Dictionary, doc: Dictionary, dot_of: Callable) -> Array:
var out: Array = []
var m := placement_xform(p["frame"], doc["pivot"])
var add := func(local: String, suffix: String, payload: Dictionary) -> void:
out.append({"local": local, "suffix": suffix, "payload": payload})
for r: Dictionary in (doc["records"] as Array):
var local := String(r["dot"])
var kind := String(r["kind"])
var pl: Dictionary = r.duplicate(true)
pl.erase("dot")
match kind:
"curve.create":
pl.erase("member") # the blob's own flag, for Make Real (§16.2)
var pts: Array = []
for q: Dictionary in (pl["points"] as Array):
var q2 := q.duplicate(true)
q2["p"] = _arr(m * _v3(q.get("p", [0, 0, 0])))
for hk: String in ["h_in", "h_out", "n"]:
if q.has(hk):
q2[hk] = _arr(m.basis * _v3(q[hk]))
pts.append(q2)
pl["points"] = pts
add.call(local, "", pl)
"shape.create":
pl["frame"] = SceneObjects.xform_to(m * SceneObjects.xform_from(pl["frame"]))
var sp: Dictionary = pl["params"]
for ck: String in ["curve", "profile_curve"]:
if sp.has(ck):
sp[ck] = dot_of.call(String(sp[ck]))
if String(pl.get("link", "")) != "":
pl["link"] = dot_of.call(String(pl["link"]))
else:
pl.erase("link")
add.call(local, "", pl)
"mod.create":
var mp: Dictionary = pl["params"]
if mp.has("curve"):
mp["curve"] = dot_of.call(String(mp["curve"]))
if mp.has("pivot"):
mp["pivot"] = _arr(m * _v3(mp["pivot"]))
pl["target"] = dot_of.call(String(pl["target"]))
var on := bool(pl.get("on", true))
pl.erase("on")
add.call(local, "", pl)
if not on:
add.call(local, "s", {"kind": "mod.set", "target": dot_of.call(local), "on": false})
"op.create":
pl["cutter"] = dot_of.call(String(pl["cutter"]))
var tg: Array = []
for t: Variant in (pl["targets"] as Array):
tg.append(dot_of.call(String(t)))
pl["targets"] = tg
var on2 := bool(pl.get("on", true))
pl.erase("on")
add.call(local, "", pl)
if not on2:
add.call(local, "s", {"kind": "op.set", "target": dot_of.call(local), "on": false})
"brush.create":
pl["group"] = dot_of.call(String(pl["group"]))
add.call(local, "", pl)
var key: String = dot_of.call(local)
# Turned about the world origin by M's basis, then moved by M's origin: the
# same map as M, through the store's texture lock. Identity adds nothing.
var q3 := m.basis.get_rotation_quaternion()
var ang := rad_to_deg(q3.get_angle())
if absf(ang) > 1.0e-9:
add.call(local, "r", {"kind": "brush.rotate", "brush_key": key, "axis": _arr(q3.get_axis()),
"angle": ang, "pivot": [0, 0, 0], "tex_lock": true})
if m.origin != Vector3.ZERO:
add.call(local, "t", {"kind": "brush.translate", "brush_key": key, "delta": _arr(m.origin),
"tex_lock": true})
"object.create":
var par := String(pl.get("parent", ""))
var x := SceneObjects.xform_from(pl["xform"])
var props: Dictionary = pl.get("props", {})
add.call(local, "", {"kind": "object.create", "type": pl["type"],
"xform": SceneObjects.xform_to(x if par != "" else m * x)})
if par != "":
add.call(local, "p", {"kind": "object.parent", "target": dot_of.call(local),
"parent": dot_of.call(par), "xform": SceneObjects.xform_to(x)})
if not props.is_empty():
add.call(local, "q", {"kind": "object.set_prop", "target": dot_of.call(local),
"props": props.duplicate(true)})
_:
pass # col.create: organisation, not geometry
return out
## The wire box of a placement whose blob is not here: bounds unknown too, so a 1 m cube at
## the frame (the catalogue's bounds arrive with the blob). Cutter: drawn, clips nothing.
static func _missing_box(p: Dictionary, parse: Callable) -> Dictionary:
var f: Transform3D = p["frame"]
var local := PackedFloat32Array([-1, 0, 0, 0.5, 1, 0, 0, 0.5, 0, -1, 0, 0.0, 0, 1, 0, 1.0,
0, 0, -1, 0.5, 0, 0, 1, 0.5])
var planes := ShapeFold.transform_planes(local, f)
var pa: Array = []
for v: float in planes:
pa.append(v)
var rec: Dictionary = parse.call("%s/A0000" % String(p["author"]), int(p["seq"]), {"kind": "brush.create",
"planes": pa, "proj": [], "material_id": 0, "level_id": 0, "sky": false, "clearance": false,
"group": String(p["dot"]), "face_tex": []})
if not rec.is_empty():
rec["cutter"] = true
rec["inst"] = String(p["dot"])
return rec
static func _v3(a: Variant) -> Vector3:
if a is Vector3:
return a
if a is Array and (a as Array).size() == 3:
return Vector3(float(a[0]), float(a[1]), float(a[2]))
return Vector3.ZERO
static func _arr(v: Vector3) -> Array:
return [v.x, v.y, v.z]