extends SceneTree
## OPERATOR GROUPS — one arch is one thing, hyper-games #28.
##
## "$HYPER_TPS_GODOT" --headless --script scenes/test/brush_group_gate.gd
##
## An arch is eight voussoirs and a clearance volume. Until the group key existed
## nothing said they belonged together, so moving the ring left the clearance
## behind — and because the clearance is what the terrain shell is cut from, the
## hillside kept a passage where the arch no longer was. That is the failure this
## exists to stop, and it is invisible from inside the level: the arch looks fine,
## the hole is somewhere else.
##
## TWO TIERS, deliberately (the user's call, 2026-08-29): the GROUP is durable —
## it replicates and it survives a reload, because an arch has to be one thing on
## every peer. HIDING stays view-local. So this gate asserts the group is in the
## journal and says nothing about eyes.
##
## THE ROW IS DERIVED, NOT STORED. The outliner's "passage" row is every brush
## carrying the key, computed each build. There is no membership list, so there is
## nothing to fall out of step with the geometry — which is the failure mode of
## keeping the two beside each other.
var _failures: Array[String] = []
var _frame := 0
func _check(label: String, ok: bool, detail: String) -> void:
if not ok:
_failures.append("%s (%s)" % [label, detail])
print(" %-52s %s %s" % [label, "OK " if ok else "FAIL", detail])
static func _box(lo: Vector3, hi: Vector3) -> PackedFloat32Array:
return PackedFloat32Array([
-1, 0, 0, -lo.x, 1, 0, 0, hi.x,
0, -1, 0, -lo.y, 0, 1, 0, hi.y,
0, 0, -1, -lo.z, 0, 0, 1, hi.z,
])
func _process(_delta: float) -> bool:
_frame += 1
if _frame < 3:
return false
print("brush group gate")
var lvl := ClassDB.instantiate("DCLevel") as Node3D
root.add_child(lvl)
# A "passage": two solids and a clearance volume, one invocation.
var g := "peerA:12"
var a: int = lvl.add_brush(_box(Vector3(0, 0, 0), Vector3(2, 2, 2)), 0,
PackedInt32Array(), 0, false, "peerA:12", false, g)
var b: int = lvl.add_brush(_box(Vector3(2, 0, 0), Vector3(4, 2, 2)), 0,
PackedInt32Array(), 0, false, "peerA:13", false, g)
var c: int = lvl.add_brush(_box(Vector3(0, 2, 0), Vector3(4, 4, 2)), 0,
PackedInt32Array(), 0, false, "peerA:14", true, g)
# And an ungrouped wall, so "everything is in a group" cannot pass by accident.
var loose: int = lvl.add_brush(_box(Vector3(9, 0, 0), Vector3(11, 2, 2)), 0,
PackedInt32Array(), 0, false, "peerA:15")
lvl.rebuild()
_check("every piece carries the key", lvl.get_brush_group(a) == g
and lvl.get_brush_group(b) == g and lvl.get_brush_group(c) == g, g)
_check("an ungrouped brush carries none", lvl.get_brush_group(loose) == "",
"'%s'" % lvl.get_brush_group(loose))
var members: PackedInt32Array = lvl.get_group_brush_ids(g)
_check("the group resolves to its three brushes", members.size() == 3,
"%d" % members.size())
_check("including the clearance volume", members.has(c), "#%d" % c)
_check("and not the loose wall", not members.has(loose), "#%d excluded" % loose)
_check("get_group_keys finds exactly one group",
(lvl.get_group_keys() as PackedStringArray).size() == 1,
"%s" % str(lvl.get_group_keys()))
# "" is the ABSENCE of a group, not a group — otherwise every imported brush
# in START.MAP would resolve as one enormous 1164-member row.
_check("\"\" resolves to nothing, not to every ungrouped brush",
(lvl.get_group_brush_ids("") as PackedInt32Array).is_empty(),
"%d" % (lvl.get_group_brush_ids("") as PackedInt32Array).size())
_check("get_brush reports the group too",
String((lvl.get_brush(a) as Dictionary).get("group", "")) == g, g)
# ---- IT REPLICATES. The durable half of the two-tier decision. ---------
var peer := ClassDB.instantiate("DCLevel") as Node3D
root.add_child(peer)
var st: Dictionary = peer.apply_journal([
{"author": "peerA", "seq": 12, "kind": "brush.create", "undone": false,
"planes": _to_list(_box(Vector3(0, 0, 0), Vector3(2, 2, 2))), "proj": [],
"material_id": 0, "level_id": 0, "sky": false, "clearance": false,
"group": g},
{"author": "peerA", "seq": 14, "kind": "brush.create", "undone": false,
"planes": _to_list(_box(Vector3(0, 2, 0), Vector3(4, 4, 2))), "proj": [],
"material_id": 0, "level_id": 0, "sky": false, "clearance": true,
"group": g},
])
_check("a replaying peer applies both", int(st.get("applied", 0)) == 2,
"applied %d" % int(st.get("applied", 0)))
_check("and rebuilds them AS A GROUP",
(peer.get_group_brush_ids(g) as PackedInt32Array).size() == 2,
"%d member(s)" % (peer.get_group_brush_ids(g) as PackedInt32Array).size())
# THE POINT: without the group in the record a peer would hold nine unrelated
# brushes and could take the arch apart in ways its author could not.
_check("the clearance came back as clearance too",
(peer.get_clearance_brush_ids() as PackedInt32Array).size() == 1,
"%d" % (peer.get_clearance_brush_ids() as PackedInt32Array).size())
# ---- moving the group moves the cut with it ---------------------------
# The failure in one line: translate the ring, not the clearance, and the
# terrain keeps a hole where the arch is not.
var before: Vector3 = (lvl.get_brush(c) as Dictionary)["center"]
for m: int in members:
lvl.translate_brush(m, Vector3(5, 0, 0))
lvl.rebuild()
var after: Vector3 = (lvl.get_brush(c) as Dictionary)["center"]
_check("translating the group carries the clearance",
after.is_equal_approx(before + Vector3(5, 0, 0)),
"%s -> %s" % [str(before), str(after)])
# ---- SCALING THE GROUP TAKES THE CUT WITH IT --------------------------
# The reported ask: "if the length changes it should be able to change with
# it". A clearance volume is IN the group, so scaling the group by one factor
# about one pivot scales the cut identically — the ring cannot outgrow the
# bore that serves it. That is why this is a group op and not a per-brush one.
var pivot := Vector3(2, 2, 1)
var ring_before: Vector3 = (lvl.get_brush(a) as Dictionary)["aabb_max"] as Vector3 \
- ((lvl.get_brush(a) as Dictionary)["aabb_min"] as Vector3)
var cut_before: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
var sf := Vector3(2.0, 1.0, 1.0)
var ok_all := true
for m: int in lvl.get_group_brush_ids(g):
if not lvl.scale_brush(m, sf, pivot):
ok_all = false
lvl.rebuild()
_check("every member scales", ok_all, "x2 on X")
var ring_after: Vector3 = (lvl.get_brush(a) as Dictionary)["aabb_max"] as Vector3 \
- ((lvl.get_brush(a) as Dictionary)["aabb_min"] as Vector3)
var cut_after: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
_check("the ring doubled on X", absf(ring_after.x - ring_before.x * 2.0) < 1e-3,
"%.3f -> %.3f" % [ring_before.x, ring_after.x])
_check("AND SO DID THE CUT", absf(cut_after.x - cut_before.x * 2.0) < 1e-3,
"%.3f -> %.3f" % [cut_before.x, cut_after.x])
_check("the untouched axes are untouched",
absf(cut_after.y - cut_before.y) < 1e-3 and absf(cut_after.z - cut_before.z) < 1e-3,
"y %.3f z %.3f" % [cut_after.y, cut_after.z])
# EXACTLY REVERSIBLE, which is what "planes, not vertices" buys: scaling back
# by the inverse about the same pivot restores the original to the digit.
for m: int in lvl.get_group_brush_ids(g):
lvl.scale_brush(m, Vector3(0.5, 1, 1), pivot)
lvl.rebuild()
var cut_back: Vector3 = (lvl.get_brush(c) as Dictionary)["aabb_max"] as Vector3 \
- ((lvl.get_brush(c) as Dictionary)["aabb_min"] as Vector3)
_check("scaling back restores it exactly", cut_back.is_equal_approx(cut_before),
"%s vs %s" % [str(cut_back), str(cut_before)])
# A ZERO FACTOR IS REFUSED, not clamped: it collapses the solid to a plane and
# no later scale brings it back.
_check("a zero factor is refused",
not lvl.scale_brush(a, Vector3(0, 1, 1), pivot), "nothing changed")
# AND IT REPLICATES: brush.scale carries the pivot, because "the group's
# centre" is a property of the selection and a peer scaling about its own
# idea of it would land the pieces apart.
var sc: Dictionary = peer.apply_journal([
{"author": "peerA", "seq": 12, "kind": "brush.create", "undone": false,
"planes": _to_list(_box(Vector3(0, 0, 0), Vector3(2, 2, 2))), "proj": [],
"material_id": 0, "level_id": 0, "sky": false, "clearance": false,
"group": g},
{"author": "peerA", "seq": 20, "kind": "brush.scale", "undone": false,
"brush_key": "peerA:12", "scale": Vector3(2, 1, 1), "pivot": pivot},
])
_check("a peer replays brush.scale", int(sc.get("applied", 0)) == 2,
"applied %d skipped %d" % [int(sc.get("applied", 0)), int(sc.get("skipped", 0))])
var pb: Dictionary = peer.get_brush(peer.brush_by_key("peerA:12"))
_check("and lands the same size the author had",
absf(((pb["aabb_max"] as Vector3) - (pb["aabb_min"] as Vector3)).x - 4.0) < 1e-3,
"%.3f wide" % ((pb["aabb_max"] as Vector3) - (pb["aabb_min"] as Vector3)).x)
print("\nRESULT: %s" % ("PASS" if _failures.is_empty() else "FAIL"))
for f: String in _failures:
print(" - %s" % f)
quit(0 if _failures.is_empty() else 1)
return true
static func _to_list(p: PackedFloat32Array) -> Array:
var out: Array = []
for f: float in p:
out.append(f)
return out