OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / scene_objects.gd

656 lines · gd ref:
extends Node3D
class_name SceneObjects
## AUTHORED SCENE OBJECTS — hyper-games #28, the durable half.
##
## Brushes have been journalled since the level-mesh pivot; scene objects never
## were. Every light, teleporter and door in a map comes from the .MAP IMPORT, so
## the editor could move the world but not populate it — there was no way to say
## "put a light here" and have it still be there tomorrow, let alone on someone
## else's machine.
##
## ---------------------------------------------------------------------------
## WHAT GODOT DOES, AND THE ONE LINE THAT DOES NOT TRANSLATE
##
## SceneTreeDock::_do_create (scene_tree_dock.cpp:3048) is four lines:
##
##     String new_name = p_parent->validate_child_name(child);
##     undo_redo->add_do_method(p_parent, "add_child", child, true);
##     undo_redo->add_do_method(child, "set_owner", edited_scene);
##     undo_redo->add_undo_method(p_parent, "remove_child", child);
##
## `set_owner` IS the persistence. Godot saves by walking the live tree into a
## PackedScene, and a node with no owner is invisible to that walk — which is why
## a node added at runtime vanishes and one added in the editor does not.
##
## We cannot do that. There is no scene file to write: the durable state is an
## append-only journal replayed on every peer (LevelPersistence -> GameState ->
## the room's StateBase). So the analogue of `set_owner` is THE CREATE IS
## JOURNALLED, and the analogue of PackedScene is this fold. Nothing here saves;
## it rebuilds.
##
## Which makes it the same shape as brushes, deliberately:
##
##   IDENTITY   the create's CRDT dot names the object — exactly DCBrush::key,
##              and exactly what #28 §4 says objects were missing. An id or a
##              node path would name something different on another peer.
##   UNDO       the record's `undone` flag, not an inverse op. Same as the brush
##              journal: there is no durable delete path, so nothing is erased.
##   REPLAY     is NOT once-only. state_changed fires on bulk load AND on every
##              live update, including your own writes coming back confirmed, so
##              a create that was not idempotent would grow an object per round
##              trip. Keyed by dot, applying twice is a no-op.
##
## ---------------------------------------------------------------------------
## SCOPE: object.create only, on purpose.
##
## Undo already works without object.remove, because undo is the journal flag.
## object.transform and object.set_prop are the obvious next two and are NOT here
## — they are part of the record-kind set #22 §5 item 5 wants decided together,
## and this one goes first to set the pattern rather than to pre-empt them.
## Consequently a created object is placed where you put it and does not move.

const KIND_CREATE := "object.create"
const KIND_TRANSFORM := "object.transform"
const KIND_SET_PROP := "object.set_prop"
## DELETE AS A RECORD (like brush.remove): a live remove takes its target out of the
## fold; undoing the remove brings the object back with everything it had. Nothing is
## erased — the journal has no durable delete, and does not need one.
const KIND_REMOVE := "object.remove"
## REPARENTING (demo sprint §3, user 2026-10-04): {target, parent ("" = top level), xform}.
## `xform` is the child's transform IN THE NEW PARENT'S SPACE, computed by the writer so
## nothing jumps. After it, the child's object.transform records are parent-local too, so
## the fold stays "the last record wins" and an undo falls back to an earlier parent AND
## the transform written in that parent's space. A cycle, a missing parent or a BOT as a
## child (its brain moves its body in world space) is refused. Removing a parent removes
## its children (Godot's rule); one undo brings them all back.
const KIND_PARENT := "object.parent"
## Prefabs that may not be a child (they may still be a parent).
const NEVER_CHILD := ["bot"]

## dot -> the node built from that record. The registry is what makes replay
## idempotent and what a future object.transform would resolve against.
var _by_dot: Dictionary = {}
var _last: Dictionary = {}
## dot -> {prop id: sanitized wire value} from the LAST fold — only what records
## set, not the defaults. Rebuilt every fold, like _by_dot's membership.
var _props: Dictionary = {}
## dot -> parent dot ("" = top level) from the LAST fold.
var _parent_of: Dictionary = {}
## BASE OBJECTS (2026-10-05): a level map's own teleporters and spawn points, ADOPTED
## rather than created — dot "scene:<Name>" -> {type, xform, props, node}. They exist
## without an object.create, are reset to their AUTHORED transform and props on every
## fold (the brush store's base layer, for objects), and object.transform / set_prop /
## remove / parent target them like any object. A removed one is DETACHED, never freed,
## so undoing the remove puts the SAME node back.
var _base: Dictionary = {}


## Adopt a scene node as a BASE object. It moves under this node keeping its place; its
## current transform and its catalogue props, read FROM THE NODE, become its base — so a
## teleporter keeps the label its .tscn gave it, not the catalogue's default.
## Call before the first fold (map_dc_level._adopt_scene_objects).
func add_base(dot: String, type_id: String, node: Node3D) -> void:
	var g := node.global_transform
	if node.get_parent() != self:
		if node.get_parent() != null:
			node.get_parent().remove_child(node)
		add_child(node)
	node.global_transform = g
	node.set_meta("object_dot", dot)
	node.set_meta("object_type", type_id)
	var bp := {}
	for s: Dictionary in PrefabCatalogue.props_of(type_id, {}):
		var v: Variant = read_prop(node, s)
		if v != null:
			bp[String(s["id"])] = v
	_base[dot] = {"type": type_id, "xform": node.transform, "props": bp, "node": node}
	_by_dot[dot] = node


func is_base(dot: String) -> bool:
	return _base.has(dot)


## Is this a record we own? LevelPersistence asks, so brush records and object
## records can share one journal without either consumer seeing the other's and
## reporting it as a disagreement.
static func handles(kind: String) -> bool:
	return kind.begins_with("object.")


## Every live object, as {dot, type, node} — what the outliner lists. Store order
## is insertion order, which is fold order, which is (seq, author): stable, and
## the same on every peer.
func entries() -> Array:
	var out: Array = []
	for dot: String in _by_dot:
		var n: Node = _by_dot[dot]
		if n == null or not is_instance_valid(n):
			continue
		out.append({"dot": dot, "type": String(n.get_meta("object_type", "")), "node": n,
			"parent": String(_parent_of.get(dot, ""))})
	return out


## The parent's dot, or "" for a top-level object.
func parent_of(dot: String) -> String:
	return String(_parent_of.get(dot, ""))


## A node's transform in SCENE space (relative to this SceneObjects node) — what the
## tools work in. For a top-level object it IS its transform.
func scene_xform(node: Node3D) -> Transform3D:
	var t := node.transform
	var p := node.get_parent()
	while p != null and p != self and p is Node3D:
		t = (p as Node3D).transform * t
		p = p.get_parent()
	return t


## The transform a child of `parent_dot` needs for scene-space `t` ("" = top level).
func local_for(parent_dot: String, t: Transform3D) -> Transform3D:
	var pn := node_for(parent_dot) as Node3D if parent_dot != "" else null
	return t if pn == null else scene_xform(pn).affine_inverse() * t


## True if `anc` is `dot` or one of its ancestors (by the last fold).
func is_ancestor(anc: String, dot: String) -> bool:
	var d := dot
	var guard := 0
	while d != "" and guard < 4096:
		if d == anc:
			return true
		d = String(_parent_of.get(d, ""))
		guard += 1
	return false


## Rebuild from the journal. Same contract as DCLevel.apply_journal: hand it every
## live record, get back {applied, skipped, undone, records}.
##
## REBUILT, NOT PATCHED. A fold is the whole state, so an object whose record has
## gone (undone, or authored against another level) must disappear — and working
## out which ones those are by diffing is how the tree and the journal drift.
## Cheap because authored objects are tens, not thousands.
func apply_journal(records: Array) -> Dictionary:
	# rejected_props: property writes refused by the catalogue (not exposed by that
	# prefab, or the wrong shape). Counted apart from `skipped`, which is per RECORD:
	# a record with one bad prop and one good one still applies the good one.
	var stats := {"applied": 0, "skipped": 0, "undone": 0, "records": 0, "rejected_props": 0}
	var wanted: Dictionary = {}
	var ordered: Array = []
	# dot -> {prop: sanitized value}. Filled in (seq, author) order, so a later write
	# to the same prop simply overwrites — per-PROPERTY last-write-wins.
	var set_props: Dictionary = {}
	# Dots removed so far in (seq, author) order. A record after the remove that names
	# one has nothing to act on and is counted skipped, like a move of a missing object.
	var removed: Dictionary = {}
	# dot -> parent dot, in (seq, author) order: a create starts at the top level.
	var parents: Dictionary = {}
	# BASE OBJECTS are wanted without a create, back in the tree if a remove had
	# detached them, at their authored transform — the records below then move them.
	for bdot: String in _base:
		var b: Dictionary = _base[bdot]
		var bn := b["node"] as Node3D
		if bn == null or not is_instance_valid(bn):
			continue
		if not bn.is_inside_tree() and bn.get_parent() == null:
			add_child(bn)
		bn.transform = b["xform"]
		_by_dot[bdot] = bn
		wanted[bdot] = true
		parents[bdot] = ""
	for r: Dictionary in records:
		if not handles(String(r.get("kind", ""))):
			continue
		stats["records"] = int(stats["records"]) + 1
		if bool(r.get("undone", false)):
			stats["undone"] = int(stats["undone"]) + 1
			continue
		ordered.append(r)
	# (seq, author) — the same total order the brush fold uses. Arrival order in a
	# last-write-wins view means nothing.
	ordered.sort_custom(func(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", "")) < String(b.get("author", "")))

	for r: Dictionary in ordered:
		# The dot is (author, seq) — built here rather than carried, so it is the
		# same construction dc_journal::Record::dot() uses and the two cannot
		# drift into naming the same record differently.
		var dot := "%s:%d" % [String(r.get("author", "")), int(r.get("seq", -1))]
		if String(r.get("author", "")) == "":
			stats["skipped"] = int(stats["skipped"]) + 1
			continue
		match String(r.get("kind", "")):
			KIND_CREATE:
				wanted[dot] = true
				parents[dot] = ""
				var node: Node = _by_dot.get(dot, null)
				if node != null and is_instance_valid(node):
					# ALREADY BUILT — replay is not once-only, so the node stays and
					# only its transform is re-applied. That re-application is not
					# redundant: it is what puts an object BACK when the
					# object.transform that moved it has just been undone. Without
					# it the create is skipped, no transform record applies, and the
					# node silently keeps the position the undone record gave it.
					_place(node, r)
				else:
					node = _spawn(r, dot)
					if node == null:
						# NOT silent. A record naming a prefab this build does not
						# have is the version-skew case, and it is information: the
						# object is missing here and present elsewhere.
						stats["skipped"] = int(stats["skipped"]) + 1
						continue
					_by_dot[dot] = node
				stats["applied"] = int(stats["applied"]) + 1
			KIND_TRANSFORM:
				# ABSOLUTE, not a delta. The fold replays the whole journal every
				# time, so a relative move would compound on each re-fold — the
				# same reason DCLevel rewinds to base planes rather than
				# accumulating. Absolute means the LAST transform in (seq, author)
				# order simply wins, and undoing one falls back to the one before
				# it with no inverse to compute.
				var target := String(r.get("target", ""))
				var n: Node = _by_dot.get(target, null)
				if n == null or not is_instance_valid(n) or removed.has(target):
					# The target is gone or not yet built — an undone create, or a
					# prefab this build lacks. Counted, because a move with nothing
					# to move means the journal and this peer disagree.
					stats["skipped"] = int(stats["skipped"]) + 1
					continue
				_place(n, r)
				stats["applied"] = int(stats["applied"]) + 1
			KIND_SET_PROP:
				var tgt := String(r.get("target", ""))
				var on: Node = _by_dot.get(tgt, null)
				var raw: Variant = r.get("props", {})
				if on == null or not is_instance_valid(on) or not (raw is Dictionary) or removed.has(tgt):
					stats["skipped"] = int(stats["skipped"]) + 1
					continue
				var type_id := String(on.get_meta("object_type", ""))
				var took := 0
				# "brain" FIRST: a bot's other settings are specced BY its brain, so a
				# record that changes both must resolve the brain before them.
				var keys: Array = (raw as Dictionary).keys()
				if keys.has("brain"):
					keys.erase("brain")
					keys.push_front("brain")
				for k: Variant in keys:
					# THE ALLOW-LIST. The name and the value both came from a peer;
					# anything the catalogue does not list for THIS prefab is refused,
					# and a value of the wrong shape is refused rather than coerced.
					var spec: Dictionary = PrefabCatalogue.prop_spec(type_id, String(k), set_props.get(tgt, {}))
					var clean: Variant = PrefabCatalogue.sanitize(spec, raw[k]) if not spec.is_empty() else null
					if clean == null:
						stats["rejected_props"] = int(stats["rejected_props"]) + 1
						continue
					if not set_props.has(tgt):
						set_props[tgt] = {}
					(set_props[tgt] as Dictionary)[String(k)] = clean
					took += 1
				if took == 0:
					stats["skipped"] = int(stats["skipped"]) + 1
				else:
					stats["applied"] = int(stats["applied"]) + 1
			KIND_REMOVE:
				var rt := String(r.get("target", ""))
				if not wanted.has(rt) or removed.has(rt):
					stats["skipped"] = int(stats["skipped"]) + 1
					continue
				# ITS CHILDREN GO WITH IT (Godot's rule), by the parents as of this record.
				for d: String in wanted.keys():
					if _chain_has(parents, d, rt):
						removed[d] = true
						wanted.erase(d)   # freed by the "no longer wanted" sweep below
				stats["applied"] = int(stats["applied"]) + 1
			KIND_PARENT:
				var ct := String(r.get("target", ""))
				var np := String(r.get("parent", ""))
				var cn: Node = _by_dot.get(ct, null)
				var ok_parent := np == "" or (wanted.has(np) and not removed.has(np))
				if cn == null or not is_instance_valid(cn) or not wanted.has(ct) or not ok_parent \
						or (np != "" and NEVER_CHILD.has(String(cn.get_meta("object_type", "")))) \
						or (np != "" and _chain_has(parents, np, ct)):
					# Missing / removed either end, a bot as a child, or a CYCLE (the new
					# parent is the target or below it) — refused, the tree stays a tree.
					stats["skipped"] = int(stats["skipped"]) + 1
					continue
				parents[ct] = np
				_place(cn, r)             # parent-local, like every later transform
				stats["applied"] = int(stats["applied"]) + 1
			_:
				stats["skipped"] = int(stats["skipped"]) + 1

	# THE HIERARCHY, BEFORE the sweep: a wanted child of a node about to be freed (its
	# parent's create was undone) must be out from under it first. Only nodes whose parent
	# CHANGED move — a bot leaving the tree detaches its brain. Two passes (out to the top
	# level, then in) so a swap of who-parents-whom never asks Godot for a cycle.
	_parent_of = {}
	for dot: String in wanted:
		var pd := String(parents.get(dot, ""))
		_parent_of[dot] = pd if wanted.has(pd) else ""
	var moving: Array = []
	for dot: String in wanted:
		var n: Node = _by_dot.get(dot, null)
		if n == null or not is_instance_valid(n):
			continue
		var want_p: Node = self if String(_parent_of[dot]) == "" else _by_dot.get(_parent_of[dot], null)
		if want_p == null or not is_instance_valid(want_p):
			want_p = self
			_parent_of[dot] = ""
		if n.get_parent() != want_p:
			moving.append([n, want_p])
	for m: Array in moving:
		if (m[0] as Node).get_parent() != self:
			(m[0] as Node).reparent(self, false)
	for m: Array in moving:
		if m[1] != self:
			(m[0] as Node).reparent(m[1] as Node, false)

	# A REMOVED BASE object is DETACHED, not freed — FIRST, before anything is freed,
	# so a base node parented under a journal object that is going away is not freed
	# with it. Detached = out of the tree: a teleporter stops triggering, a spawn point
	# leaves the spawn_points group. The next fold without the remove re-attaches it.
	for dot: String in _by_dot.keys():
		if wanted.has(dot) or not _base.has(dot):
			continue
		var bn: Node = _by_dot[dot]
		if bn != null and is_instance_valid(bn) and bn.get_parent() != null:
			bn.get_parent().remove_child(bn)
		_by_dot.erase(dot)
	# Anything else no longer wanted was undone or belongs to another level.
	for dot: String in _by_dot.keys():
		if wanted.has(dot):
			continue
		var n: Node = _by_dot[dot]
		if n != null and is_instance_valid(n):
			n.queue_free()
		_by_dot.erase(dot)

	# PROPERTIES, last: every object gets its prefab DEFAULT for each property, then
	# whatever the journal set. Resetting on every fold is the props version of
	# _place re-applying the create's transform — it is what makes an undone
	# set_prop fall back to the write before it, or to the default, with no inverse.
	_props = set_props
	for dot: String in _by_dot:
		var n: Node = _by_dot[dot]
		if n == null or not is_instance_valid(n):
			continue
		var mine: Dictionary = set_props.get(dot, {})
		# A base object falls back to its AUTHORED value, not the catalogue default.
		var based: Dictionary = (_base.get(dot, {}) as Dictionary).get("props", {})
		for s: Dictionary in PrefabCatalogue.props_of(String(n.get_meta("object_type", "")), mine):
			var sid := String(s["id"])
			apply_prop(n, s, mine.get(sid, based.get(sid, s["default"])))
		# A bot gathers its settings and (re)starts once per fold, not once per prop.
		if n.has_method("props_applied"):
			n.props_applied()

	_last = stats
	return stats


## True if walking up from `dot` through `parents` reaches `anc` (dot itself counts).
static func _chain_has(parents: Dictionary, dot: String, anc: String) -> bool:
	var d := dot
	var guard := 0
	while d != "" and guard < 4096:
		if d == anc:
			return true
		d = String(parents.get(d, ""))
		guard += 1
	return false


func _spawn(r: Dictionary, dot: String) -> Node:
	var type_id := String(r.get("type", ""))
	var node: Node = PrefabCatalogue.instantiate(type_id)
	if node == null:
		return null
	# The name is cosmetic and MUST NOT be identity — Godot's validate_child_name
	# makes it unique per parent, which is exactly the kind of local, ordering
	# dependent value that names different things on different peers. The dot is
	# the identity; the name just has to be readable.
	node.name = "%s_%s" % [type_id.replace(".", "_"), dot.replace(":", "_")]
	node.set_meta("object_dot", dot)
	node.set_meta("object_type", type_id)
	add_child(node)
	_place(node, r)
	return node


## Put a node where a record says. Shared by create and transform so the two
## cannot disagree about what `xform` means.
static func _place(node: Node, r: Dictionary) -> void:
	if node is Node3D and r.has("xform"):
		(node as Node3D).transform = r["xform"] as Transform3D


## The dot that names this node, or "" — the durable handle anything outliving a
## single call must hold.
static func dot_of(node: Node) -> String:
	return String(node.get_meta("object_dot", "")) if node != null else ""


# ---- properties (object.set_prop) -------------------------------------------
#
# Values travel in WIRE FORM — float, bool, String, or a 3-float array for a colour
# or a vec3 — and only become engine types at the node. The catalogue's spec says
# which, so there is one conversion each way and the panel, the fold and the gate
# all compare the same shape.

## Materials per (base material, colour). Every block of one colour shares ONE
## material, so a tinted stack still batches — the same argument as the block's
## shared mesh. Static: it outlives any one fold or map.
static var _tint_cache: Dictionary = {}


## A node's CURRENT value for `spec`, in wire form.
static func read_prop(node: Node, spec: Dictionary) -> Variant:
	if String(spec.get("to", "")) == "bot":
		return node.get_bot_prop(String(spec["id"])) if node.has_method("get_bot_prop") else null
	if String(spec.get("to", "")) == "tint":
		var mi := node.get_node_or_null(NodePath(String(spec["mesh"]))) as MeshInstance3D
		var m := mi.material_override as StandardMaterial3D if mi != null else null
		if m == null:
			return null
		return [m.albedo_color.r, m.albedo_color.g, m.albedo_color.b]
	var v: Variant = node.get_indexed(NodePath(String(spec["path"])))
	if v is Color:
		return [(v as Color).r, (v as Color).g, (v as Color).b]
	if v is Vector3:
		return [(v as Vector3).x, (v as Vector3).y, (v as Vector3).z]
	return v


## Wire values equal, floats approximately (a colour read back from a Color is
## float32, the spec's is float64).
static func same_value(a: Variant, b: Variant) -> bool:
	if a is Array and b is Array:
		if (a as Array).size() != (b as Array).size():
			return false
		for i in (a as Array).size():
			if not same_value(a[i], b[i]):
				return false
		return true
	if (a is float or a is int) and (b is float or b is int):
		return is_equal_approx(float(a), float(b))
	return typeof(a) == typeof(b) and a == b


## Put a SANITIZED wire value on the node.
static func apply_prop(node: Node, spec: Dictionary, value: Variant) -> void:
	var kind := String(spec.get("kind", ""))
	if String(spec.get("to", "")) == "bot":
		if node.has_method("set_bot_prop"):
			node.set_bot_prop(String(spec["id"]), value)
		return
	if String(spec.get("to", "")) == "tint":
		var mi := node.get_node_or_null(NodePath(String(spec["mesh"]))) as MeshInstance3D
		if mi == null:
			return
		# The prefab's own material is remembered the first time, so going back to
		# the default restores THAT instance (the block's shared one) rather than a
		# look-alike copy.
		if not mi.has_meta("tint_base"):
			mi.set_meta("tint_base", mi.material_override)
		var base := mi.get_meta("tint_base") as StandardMaterial3D
		if base == null:
			return
		if same_value(value, spec["default"]):
			mi.material_override = base
			return
		var c := Color(float(value[0]), float(value[1]), float(value[2]))
		var key := "%d:%s" % [base.get_instance_id(), c.to_html(false)]
		var m: StandardMaterial3D = _tint_cache.get(key, null)
		if m == null:
			m = base.duplicate() as StandardMaterial3D
			m.albedo_color = c
			_tint_cache[key] = m
		mi.material_override = m
		return
	var v: Variant = value
	if kind == "color":
		v = Color(float(value[0]), float(value[1]), float(value[2]))
	elif kind == "vec3":
		v = Vector3(float(value[0]), float(value[1]), float(value[2]))
	node.set_indexed(NodePath(String(spec["path"])), v)


## The effective properties of an object, {id: wire value}: the prefab defaults
## overlaid with what the journal set. What the Object panel shows.
func props(dot: String) -> Dictionary:
	var n := node_for(dot)
	if n == null:
		return {}
	var out: Dictionary = {}
	var set_here: Dictionary = _props.get(dot, {})
	var based: Dictionary = (_base.get(dot, {}) as Dictionary).get("props", {})
	for s: Dictionary in PrefabCatalogue.props_of(String(n.get_meta("object_type", "")), set_here):
		out[String(s["id"])] = based.get(String(s["id"]), s["default"])
	for k: String in set_here:
		out[k] = set_here[k]
	return out


# ---- picking ---------------------------------------------------------------

## An object's WORLD bounds: every VisualInstance3D under it, merged. Lights and
## markers have no mesh worth the name (an OmniLight3D's AABB is its whole range), so
## a light — or anything with no visual — is a small box at its origin, the same
## stand-in MoveOperator outlines.
static func bounds_of(node: Node3D) -> AABB:
	if not (node is Light3D):
		var box := AABB()
		var any := false
		for vi in node.find_children("*", "VisualInstance3D", true, false) + ([node] if node is VisualInstance3D else []):
			if vi is Light3D or not (vi as Node3D).is_visible_in_tree():
				continue
			# A CHILD OBJECT is its own pick target: a parent's box that swallowed its
			# children would make every child unclickable (the nearer box wins).
			if _under_child_object(vi as Node, node):
				continue
			var a: AABB = (vi as Node3D).global_transform * (vi as VisualInstance3D).get_aabb()
			if a.size.length() < 1e-4:
				continue
			box = a if not any else box.merge(a)
			any = true
		if any:
			return box
	return AABB(node.global_position - Vector3.ONE * 0.35, Vector3.ONE * 0.7)


## True if `n` sits inside another object node below `root` (or is one).
static func _under_child_object(n: Node, root: Node) -> bool:
	var p := n
	while p != null and p != root:
		if p.has_meta("object_dot"):
			return true
		p = p.get_parent()
	return false


## The nearest object whose bounds the ray enters within `max_dist`:
## {dot, dist, point}, or {} when it misses them all. Tens of objects, so a linear
## scan; no physics, because lights and markers have no collider to hit.
func pick(from: Vector3, dir: Vector3, max_dist: float = 50.0, only_type: String = "") -> Dictionary:
	var best := {}
	var d := dir.normalized()
	for dot: String in _by_dot:
		var n := _by_dot[dot] as Node3D
		if n == null or not is_instance_valid(n) or not n.is_visible_in_tree():
			continue
		if only_type != "" and String(n.get_meta("object_type", "")) != only_type:
			continue
		var t := ray_aabb(from, d, bounds_of(n))
		if t >= 0.0 and t <= max_dist and (best.is_empty() or t < float(best["dist"])):
			best = {"dot": dot, "dist": t, "point": from + d * t}
	return best


## Slab test: distance along a unit ray to where it enters `box` (0 when it starts
## inside), or -1 when it misses.
static func ray_aabb(from: Vector3, d: Vector3, box: AABB) -> float:
	var tmin := -INF
	var tmax := INF
	for i in 3:
		if absf(d[i]) < 1e-9:
			if from[i] < box.position[i] or from[i] > box.end[i]:
				return -1.0
			continue
		var t1 := (box.position[i] - from[i]) / d[i]
		var t2 := (box.end[i] - from[i]) / d[i]
		tmin = maxf(tmin, minf(t1, t2))
		tmax = minf(tmax, maxf(t1, t2))
	if tmax < maxf(tmin, 0.0):
		return -1.0
	return maxf(tmin, 0.0)


func node_for(dot: String) -> Node:
	var n: Variant = _by_dot.get(dot, null)
	return n as Node if n != null and is_instance_valid(n) else null


func count() -> int:
	return _by_dot.size()


func last_stats() -> Dictionary:
	return _last


# ---- the durable transform encoding ----------------------------------------
#
# 12 FLOATS, NOT A Transform3D. Every other value in this journal is a plain
# number (`pos`, `half`, `n`), and an engine type in a durable record is one more
# thing that has to keep surviving var_to_str across builds. It also lives HERE
# rather than in LevelPersistence because this is what consumes it — and because
# LevelPersistence reaches for the GameState autoload, which makes it
# unreachable from a headless script gate. A codec nothing can test is a codec
# nobody checks.

## 12 floats -> Transform3D. Anything malformed becomes the IDENTITY rather than
## a partial basis: half a decoded transform puts an object somewhere nobody
## chose, which is worse than putting it at the origin where it is obvious.
static func xform_from(a: Array) -> Transform3D:
	if a.size() != 12:
		return Transform3D.IDENTITY
	return Transform3D(
		Basis(Vector3(float(a[0]), float(a[1]), float(a[2])),
			Vector3(float(a[3]), float(a[4]), float(a[5])),
			Vector3(float(a[6]), float(a[7]), float(a[8]))),
		Vector3(float(a[9]), float(a[10]), float(a[11])))


## Transform3D -> 12 floats, the shape record_at should be handed.
static func xform_to(t: Transform3D) -> Array:
	return [t.basis.x.x, t.basis.x.y, t.basis.x.z,
		t.basis.y.x, t.basis.y.y, t.basis.y.z,
		t.basis.z.x, t.basis.z.y, t.basis.z.z,
		t.origin.x, t.origin.y, t.origin.z]