OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / asset_fold.gd

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