extends RefCounted
## THE ASSET DOCUMENT (#120 L4, docs/node-tree-and-stacks.md §15.1): a collection's
## contents as ONE immutable blob, named by its hash. Written from the room's folded state,
## read back without trusting it.
##
## {v, name, pivot: Vector3, bounds: [min, max], (world, as captured)
## records: [{kind, dot: "L:<n>", …payload}], ordinary payloads, LOCAL dots
## assets: [oid…]} blobs it needs: models (nested later)
##
## AS CAPTURED, IN WORLD SPACE, plus the PIVOT (the contents' base centre). A placement
## folds to frame x translate(-pivot) x captured (§15.2). Not shifted here: a brush's
## texture moves with it only through the store's texture lock (brush.translate / rotate
## with tex_lock — dc_face_tex_lock_*), so the expansion lets the store do it rather than
## a second copy of that math in GDScript. Cost: the same contents saved somewhere else
## hash differently.
##
## WHAT IS IN IT — the collection's subtree (members_of, recursive):
## "g:<shape dot>" the shape's RESOLVED state (shape.create), its modifiers
## (mod.create + on), and every curve it or a modifier names
## "g:<group>" a brush object: each live brush as brush.create (its CURRENT planes,
## so cuts already made on it are baked in)
## "obj:<dot>" object.create {type, xform (scene space), props, parent}; a parent
## outside the asset -> top level, keeping its scene-space xform
## "c:<dot>" curve.create (§16.2), flagged `member` when it is linked into the
## ROOT collection; its points count towards the bounds
## sub-collections col.create {name, parent, members}
## live cuts an op whose TARGET is a shape in the asset comes along, narrowed to
## its targets inside, and its CUTTER comes too even from outside (Box
## Cutter keeps cutters in their own "Cutters" collection). Targets that
## are brush objects are dropped from it: their planes are already cut.
## Left out, and REPORTED: an op with no shape target inside; a shape inside that only cuts
## outside (it would fold as a solid); a linked duplicate's root outside (the link is
## dropped — its resolved data is written whole).
##
## CANONICAL BYTES: var_to_bytes of the doc with every Dictionary's keys sorted. Binary
## Variant keeps ints as ints and floats exact (JSON does neither), and bytes_to_var
## refuses Objects by default — a blob comes from another peer.
const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
const ModFold := preload("res://scenes/maps/mod_fold.gd")
const OpFold := preload("res://scenes/maps/op_fold.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
const V := 1
const EXT := "hasset"
## Record kinds a blob may hold (decode refuses anything else).
const KINDS := ["curve.create", "shape.create", "mod.create", "op.create", "brush.create",
"object.create", "col.create"]
## Write the subtree of collection `col` from the room `persist` folds (LevelPersistence,
## with its level and objects). {doc, report: [String]} — doc {} when there is nothing.
static func write(persist: Node, col: String, name: String = "") -> Dictionary:
var report: Array = []
var t: Dictionary = persist.collection_tree()
if not (t["cols"] as Dictionary).has(col):
return {"doc": {}, "report": ["no such collection"]}
var level: Node = persist.get("level")
var objects: Node = persist.get("objects")
var recs: Array = persist.records(false)
var curves: Dictionary = persist.curve_states()
var shape_recs: Array = recs.filter(func(r: Dictionary) -> bool: return ShapeFold.handles(String(r.get("kind", ""))))
var states: Dictionary = ShapeFold.resolve_states(shape_recs, curves)
var live_shape := func(d: String) -> bool:
return states.has(d) and not bool(states[d]["detached"]) and not bool(states[d].get("removed", false))
# ---- what the collection holds ----
var shapes := {} # shape dot -> true
var groups := {} # brush-object group -> true
var objs := {} # object dot -> true
var member_curves := {} # curve dot -> true (§16.2: "c:<dot>" members)
for m: String in CollectionFold.members_of(t, col, true):
if m.begins_with("g:"):
var g := m.substr(2)
if live_shape.call(g):
shapes[g] = true
elif level != null and not (level.get_group_brush_ids(g) as PackedInt32Array).is_empty():
groups[g] = true
elif m.begins_with("obj:") and objects != null and objects.node_for(m.substr(4)) != null:
objs[m.substr(4)] = true
elif m.begins_with("c:") and curves.has(m.substr(2)):
member_curves[m.substr(2)] = true
elif m.begins_with("a:"):
report.append("placed asset %s: assets inside assets come later — left out" % m.substr(2))
# ---- live cuts: an op comes with a shape target inside, and brings its cutter ----
var ops: Array = []
var cutter_of_kept := {}
var cutter_any := {}
for s: Dictionary in OpFold.existing_ops(recs):
cutter_any[String(s["cutter"])] = true
var inside: Array = []
var baked := 0
for g2: String in (s["targets"] as PackedStringArray):
if shapes.has(g2):
inside.append(g2)
elif groups.has(g2):
baked += 1
if baked > 0:
report.append("cut %s: its brush-object targets are baked (their planes are already cut)" % s["dot"])
if inside.is_empty():
if baked == 0 and shapes.has(String(s["cutter"])):
report.append("cut %s: it cuts only outside the collection — left out" % s["dot"])
continue
if not live_shape.call(String(s["cutter"])):
report.append("cut %s: its cutter is gone — left out" % s["dot"])
continue
var s2 := s.duplicate()
s2["targets"] = inside
ops.append(s2)
cutter_of_kept[String(s["cutter"])] = true
if not shapes.has(String(s["cutter"])):
shapes[String(s["cutter"])] = true
report.append("cutter %s: brought in from outside the collection" % s["cutter"])
for d: String in shapes.keys():
if cutter_any.has(d) and not cutter_of_kept.has(d):
shapes.erase(d)
report.append("shape %s: it only cuts outside the collection — left out" % d)
# ---- curves the shapes and their modifiers name ----
var mods: Array = []
for m2: Dictionary in ModFold.resolve_states(recs).values():
if not bool(m2["removed"]) and shapes.has(String(m2["target"])):
mods.append(m2)
mods.sort_custom(_before)
var used_curves := {}
for d2: String in shapes:
var sp: Dictionary = states[d2]["params"]
for ck: String in ["curve", "profile_curve"]:
if curves.has(String(sp.get(ck, ""))):
used_curves[String(sp[ck])] = true
for m3: Dictionary in mods:
if curves.has(String((m3["params"] as Dictionary).get("curve", ""))):
used_curves[String(m3["params"]["curve"])] = true
for cm: String in member_curves:
used_curves[cm] = true
# A curve linked into the ROOT collection is flagged `member`: Make Real links it into
# the new top collection (a curve a shape only names is not linked anywhere).
var root_members: Array = CollectionFold.members_of(t, col, false)
# ---- LOCAL DOTS, in a fixed order: curves, shapes, modifiers, cuts, brush objects
# (their brushes), objects, collections — each by its room order ----
var local := {} # room dot / group -> "L:<n>"
var n := [0]
var mint := func(k: String) -> String:
n[0] += 1
local[k] = "L:%d" % n[0]
return local[k]
var out: Array = []
var cdots: Array = used_curves.keys()
cdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(curves[a], curves[b]))
for cd: String in cdots:
var cs: Dictionary = curves[cd]
var crec := {"kind": "curve.create", "dot": mint.call(cd),
"points": LevelPersistence.CurveFold.to_wire(cs["points"]), "closed": bool(cs["closed"]),
"name": String(cs.get("name", ""))}
if root_members.has("c:" + cd):
crec["member"] = true
out.append(crec)
var sdots: Array = shapes.keys()
sdots.sort_custom(func(a: String, b: String) -> bool: return _dot_before(states[a], states[b]))
for sd: String in sdots:
mint.call(sd)
for sd2: String in sdots:
var s3: Dictionary = states[sd2]
var ps: Dictionary = (s3["params"] as Dictionary).duplicate(true)
for ck2: String in ["curve", "profile_curve"]:
if ps.has(ck2):
ps[ck2] = String(local.get(String(ps[ck2]), ""))
var sz: Vector3 = s3["size"]
var rec := {"kind": "shape.create", "dot": local[sd2], "type": String(s3["type"]),
"gv": int(s3["gv"]), "params": ps, "frame": SceneObjects.xform_to(s3["frame"]),
"size": [sz.x, sz.y, sz.z], "material_id": int(s3["material_id"]),
"level_id": int(s3["level_id"]), "tex": (s3.get("tex", {}) as Dictionary).duplicate(true),
"input": (s3.get("input", {}) as Dictionary).duplicate(true)}
var root := String(s3.get("link", ""))
if root != "":
if local.has(root):
rec["link"] = local[root]
else:
report.append("shape %s: its linked-duplicate root is outside — written unlinked" % sd2)
out.append(rec)
for m4: Dictionary in mods:
var mp: Dictionary = (m4["params"] as Dictionary).duplicate(true)
if mp.has("curve"):
mp["curve"] = String(local.get(String(mp["curve"]), ""))
out.append({"kind": "mod.create", "dot": mint.call(String(m4["dot"])),
"target": local[String(m4["target"])], "mod": String(m4["kind"]), "params": mp,
"on": bool(m4["on"])})
for s4: Dictionary in ops:
var tg: Array = []
for g3: String in s4["targets"]:
tg.append(local[g3])
out.append({"kind": "op.create", "dot": mint.call(String(s4["dot"])), "op": String(s4["kind"]),
"cutter": local[String(s4["cutter"])], "targets": tg, "depth": float(s4["depth"]),
"on": bool(s4["on"])})
var gk: Array = groups.keys()
gk.sort()
var brush_ids := PackedInt32Array()
for g4: String in gk:
var lg: String = mint.call("g:" + g4)
var ids: Array = Array(level.get_group_brush_ids(g4) as PackedInt32Array)
ids.sort_custom(func(a: int, b: int) -> bool: return String(level.get_brush_key(a)) < String(level.get_brush_key(b)))
for b: int in ids:
brush_ids.append(b)
var p: Dictionary = persist.call("_brush_payload", level.get_brush(b))
p["group"] = lg
p["kind"] = "brush.create"
p["dot"] = mint.call("b:" + String(level.get_brush_key(b)))
out.append(p)
var odots: Array = []
if objects != null:
for e: Dictionary in (objects.entries() as Array): # fold order
if objs.has(String(e["dot"])):
odots.append(String(e["dot"]))
for od: String in odots:
mint.call("obj:" + od)
var model_oids := {}
for od2: String in odots:
var node := objects.node_for(od2) as Node3D
var pd := String(objects.parent_of(od2))
var props := {}
var base: Dictionary = (objects.get("_base") as Dictionary).get(od2, {})
for k: String in (base.get("props", {}) as Dictionary):
props[k] = base["props"][k]
var set_here: Dictionary = (objects.get("_props") as Dictionary).get(od2, {})
for k2: String in set_here:
props[k2] = set_here[k2]
if String(props.get("asset", "")) != "":
model_oids[String(props["asset"])] = true
var rec2 := {"kind": "object.create", "dot": local["obj:" + od2],
"type": String(node.get_meta("object_type", "")), "props": props.duplicate(true)}
if pd != "" and local.has("obj:" + pd):
rec2["parent"] = local["obj:" + pd]
rec2["xform"] = SceneObjects.xform_to(node.transform)
else:
if pd != "":
report.append("object %s: its parent is outside — placed at the top level" % od2)
rec2["xform"] = SceneObjects.xform_to(objects.scene_xform(node))
out.append(rec2)
# Sub-collections (the root itself is the asset): parents first, members remapped.
var cols: Dictionary = t["cols"]
var corder: Array = []
var stack: Array = [col]
while not stack.is_empty():
var c: String = stack.pop_front()
if c != col:
corder.append(c)
var kids: Array = (cols[c]["children"] as Array).duplicate()
kids.sort()
stack.append_array(kids)
for c2: String in corder:
mint.call("col:" + c2)
for c3: String in corder:
var ms: Array = []
for m5: String in cols[c3]["members"]:
if m5.begins_with("c:"):
if local.has(m5.substr(2)):
ms.append("c:" + String(local[m5.substr(2)])) # curves are keyed bare
continue
var key := m5.substr(2) if m5.begins_with("g:") else m5
if m5.begins_with("g:") and groups.has(key):
key = "g:" + key
if local.has(key):
ms.append(("g:" if m5.begins_with("g:") else "obj:") + String(local[key]))
var par := String(cols[c3]["parent"])
out.append({"kind": "col.create", "dot": local["col:" + c3], "name": String(cols[c3]["name"]),
"parent": String(local.get("col:" + par, "")), "members": ms})
# Members of the ROOT collection are the asset's top level: no record needed.
if out.is_empty():
return {"doc": {}, "report": report + ["the collection holds nothing an asset can carry"]}
# ---- bounds and pivot: every brush the contents fold to, every object's origin ----
var lo := Vector3.INF
var hi := -Vector3.INF
var any := false
for sd3: String in sdots:
brush_ids.append_array(level.get_group_brush_ids(sd3) as PackedInt32Array)
var bb: Dictionary = level.get_brushes_bounds(brush_ids)
if int(bb["count"]) > 0:
lo = bb["aabb_min"]
hi = bb["aabb_max"]
any = true
for od3: String in odots:
var o: Vector3 = objects.scene_xform(objects.node_for(od3) as Node3D).origin
lo = lo.min(o)
hi = hi.max(o)
any = true
# A MEMBER curve's points (a curve a shape names is inside that shape's brushes).
for cm2: String in member_curves:
for q: Dictionary in (curves[cm2]["points"] as Array):
lo = lo.min(q["p"] as Vector3)
hi = hi.max(q["p"] as Vector3)
any = true
if not any:
lo = Vector3.ZERO
hi = Vector3.ZERO
var deps: Array = model_oids.keys()
deps.sort()
var doc := {"v": V, "name": name if name != "" else String(cols[col]["name"]),
"pivot": Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5),
"bounds": [lo, hi], "records": out, "assets": deps}
return {"doc": doc, "report": report}
static func _before(a: Dictionary, b: Dictionary) -> bool:
return _dot_before(a, b)
## Room order: (seq, author) — the fold's order.
static func _dot_before(a: Dictionary, b: Dictionary) -> bool:
if int(a.get("seq", 0)) != int(b.get("seq", 0)):
return int(a.get("seq", 0)) < int(b.get("seq", 0))
return String(a.get("author", a.get("dot", ""))) < String(b.get("author", b.get("dot", "")))
# ---- bytes -------------------------------------------------------------------
## The blob: var_to_bytes of `doc` with every Dictionary's keys sorted.
static func encode(doc: Dictionary) -> PackedByteArray:
return var_to_bytes(_canon(doc))
static func _canon(v: Variant) -> Variant:
if v is Dictionary:
var keys: Array = (v as Dictionary).keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return str(a) < str(b))
var out := {}
for k: Variant in keys:
out[k] = _canon((v as Dictionary)[k])
return out
if v is Array:
var out2: Array = []
for x: Variant in (v as Array):
out2.append(_canon(x))
return out2
return v
## The blob's name = the CONTENT OVERLAY's hash of its bytes (hyper-overlay v2's streaming
## chunk-tree hash, cdc::StreamingHasher — NOT a plain SHA-256): a placement names its blob
## by this, and a peer fetches it with ContentOverlay.request(hash), so it must be the
## overlay's own name. The overlay hashes files, so the bytes go through a scratch file.
## "" when the overlay is not loaded: no name rather than one nobody can fetch.
static func hash_of(bytes: PackedByteArray) -> String:
var co: Node = (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ContentOverlay")
if co == null:
return ""
var tmp := "user://content/.asset_hash.tmp"
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://content"))
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
return ""
f.store_buffer(bytes)
f.close()
var h := String(co.content_hash_of_file(tmp))
DirAccess.remove_absolute(ProjectSettings.globalize_path(tmp))
return h
## Read a blob — FROM ANOTHER PEER, so nothing is trusted: no Objects (bytes_to_var's
## default), the shape checked, every record's kind one of KINDS and every dot local.
## {} when any of it fails.
static func decode(bytes: PackedByteArray) -> Dictionary:
if bytes.is_empty():
return {}
var v: Variant = bytes_to_var(bytes)
if not (v is Dictionary):
return {}
var d: Dictionary = v
if int(d.get("v", 0)) != V or not (d.get("records") is Array) or not (d.get("pivot") is Vector3) \
or not (d.get("bounds") is Array) or (d["bounds"] as Array).size() != 2 \
or not (d.get("assets", []) is Array) or not (d.get("name", "") is String):
return {}
for r: Variant in (d["records"] as Array):
if not (r is Dictionary) or not KINDS.has(String((r as Dictionary).get("kind", ""))) \
or not String((r as Dictionary).get("dot", "")).begins_with("L:"):
return {}
return d