OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / asset_doc.gd

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