extends Node
## GATE — A PLACED ASSET IN THE EDITOR (#120 L4 step 4, docs/node-tree-and-stacks.md
## §15.3). A collection "Kit" (a box with a mirror, a plain brush object, a scene object)
## and "Solo" (ONE plain brush) saved and placed. A placement is ONE thing, like a shape:
## E1 any piece / its object maps to the placement; picking the object selects IT
## E2 move (translate_selection): asset.xform; the selection follows the renamed pieces
## E3 rotate (rotate_brushes): the frame turns about the pivot
## E4 G through MoveOperator on a ONE-piece placement: asset.xform, and no brush.translate
## on a synthetic piece (the single-brush path)
## E5 delete the selection: asset.remove (one gesture; undo brings it back)
## E6 delete via its object: the placement goes, not the object alone
## E7 Shift+D: a new placement, same asset and frame, selected, in the same collections
## E8 its object cannot be moved alone (move_object refuses, nothing journalled)
## E9 its parts are read-only (guard_shape_part refuses)
## E10 Make Real: ordinary records, the SAME geometry (windings digest), in a collection
## "Kit"; the placement removed; a shape param edit then works; undo restores
## E11 the outliner: one "▣ Kit" row, no rows for its synthetic objects; the row selects it
## E12 a collection member "a:<dot>": linked from the selection; its row is under it
## E13 scale leaves a placement alone (rigid): its frame AND its pieces (a scale once
## wrote brush.scale on every synthetic piece while leaving the frame alone)
## E14 the Q menu on a placement: "Placed asset" (Make Real / Duplicate / Delete)
## E15 after all of it: no brush / face record names a placement's synthetic piece
## E16 a MIXED selection (placement + plain brush) moved and turned: each its own way
##
## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_edit.tscn
const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var lib: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "probe"
get_tree().root.get_node("/root/GameMode").set_mode("build")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
persist = map.get("_persist")
level = map.get("level")
lib = map.asset_library()
_run()
if checks < 25:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE asset_edit: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _box(at: Vector3, size: Vector3) -> String:
return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
"material_id": 2, "level_id": 0})
static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
return [-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 _frame(pd: String) -> Transform3D:
return (persist.placements() as Dictionary).get(pd, {}).get("frame", Transform3D())
func _live(pd: String) -> bool:
var p: Dictionary = (persist.placements() as Dictionary).get(pd, {})
return not p.is_empty() and not bool(p["removed"])
## The placement's synthetic object dot.
func _obj_of(pd: String) -> String:
for e: Dictionary in (map.get_node("SceneObjects").entries() as Array):
if persist.instance_of(String(e["dot"])) == pd:
return String(e["dot"])
return ""
func _digest(ids: Array) -> String:
var blobs: Array = []
for b: int in ids:
var bb := PackedByteArray()
for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4:
bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array())
bb.append(255)
blobs.append(bb)
blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode())
var h := HashingContext.new()
h.start(HashingContext.HASH_SHA256)
for bb2: PackedByteArray in blobs:
h.update(bb2)
return h.finish().hex_encode().substr(0, 16)
func _synthetic_translates() -> int:
var n := 0
for r: Dictionary in (persist.records(false) as Array):
if String(r["kind"]) == "brush.translate" and String(r.get("brush_key", "")).contains("/A") and not bool(r["undone"]):
n += 1
return n
func _run() -> void:
var O0 := Vector3(700.137, 0.31, 699.873)
persist.begin_gesture("room")
var a := _box(O0, Vector3(2, 2, 2))
persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
var grp := "probe:grp9"
persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, 5), O0 + Vector3(1, 1, 6)), "proj": [],
"material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
var sgrp := "probe:grp10"
persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, -20), O0 + Vector3(1, 1, -19)), "proj": [],
"material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": sgrp, "face_tex": []})
persist.end_gesture()
persist.refold()
var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
persist.begin_gesture("cols")
var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + grp, "obj:" + obj]})
var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""})
persist.record("col.link", {"target": solo, "members": ["g:" + sgrp]})
var shelf: String = persist.record("col.create", {"name": "Shelf", "parent": ""})
persist.end_gesture()
persist.refold()
lib.save_collection(kit, "Kit")
lib.save_collection(solo, "Solo")
var p1: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, 0)))
var ps: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, -40)))
var ids1: PackedInt32Array = map.asset_brush_ids(p1)
_ok("setup: two placements with pieces (Kit, Solo of ONE)", ids1.size() >= 3 and map.asset_brush_ids(ps).size() == 1,
[ids1.size(), map.asset_brush_ids(ps).size()])
# ---- E1 ----
var so := _obj_of(p1)
_ok("E1 every piece maps to the placement", Array(ids1).all(func(b: int) -> bool: return map.placement_of_brush(b) == p1))
map.select_object(so)
_ok("E1 picking its object selects the PLACEMENT", map.selected_asset() == p1 and (map.selected_objects() as Array).is_empty()
and PackedInt32Array(map.get("_sel_brushes")) == ids1, [map.selected_asset(), map.selected_objects()])
# ---- E2 ----
var f0 := _frame(p1)
map.translate_selection(Vector3(5, 0, 0))
_ok("E2 move: asset.xform +5 x", _frame(p1).origin.is_equal_approx(f0.origin + Vector3(5, 0, 0)), _frame(p1).origin)
_ok("E2 ... the selection follows the renamed pieces", map.selected_asset() == p1
and PackedInt32Array(map.get("_sel_brushes")) == map.asset_brush_ids(p1) and map.asset_brush_ids(p1).size() == ids1.size())
# ---- E3 ----
var piv: Vector3 = _frame(p1).origin
map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, piv)
_ok("E3 rotate: the frame turned 90° about y", _frame(p1).basis.is_equal_approx(Basis(Vector3.UP, PI / 2))
and _frame(p1).origin.is_equal_approx(piv), _frame(p1))
# ---- E4 ----
map.select_asset(ps)
var fs0 := _frame(ps)
var stack: Node = map.call("_ensure_ops")
var mv: Object = map.get("_move")
var started: bool = stack.activate(mv)
mv.set_tool_setting("distance", 3)
mv.update_preview()
mv.set_tool_setting("move", true)
var step: float = float(map.grid_units()) * float(map.WORLD_SCALE)
_ok("E4 G (MoveOperator) on a ONE-piece placement: asset.xform", started
and _frame(ps).origin.distance_to(fs0.origin) > step * 2.5, [_frame(ps).origin - fs0.origin, step])
_ok("E4 ... and no brush.translate on a synthetic piece", _synthetic_translates() == 0, _synthetic_translates())
# ---- E13 ----
map.select_asset(p1)
var f13 := _frame(p1)
var g13 := _digest(Array(map.asset_brush_ids(p1)))
map.scale_brushes(Array(map.get("_sel_brushes")), Vector3(2, 2, 2), f13.origin)
persist.refold()
_ok("E13 scale leaves a placement alone: frame AND pieces", _frame(p1) == f13
and _digest(Array(map.asset_brush_ids(p1))) == g13, [g13, _digest(Array(map.asset_brush_ids(p1)))])
# ---- E14 ----
var qs: Array = map.q_sections()
_ok("E14 Q menu on a placement: Placed asset", not qs.is_empty() and String(qs[0][0]) == "Placed asset", qs.map(func(x: Array) -> String: return String(x[0])))
# ---- E8 / E9 ----
var tr0 := (persist.records(false) as Array).size()
_ok("E8 its object cannot be moved alone", not map.move_object(_obj_of(p1), Transform3D.IDENTITY)
and (persist.records(false) as Array).size() == tr0)
_ok("E9 its parts are read-only (the guard refuses)", not map.guard_shape_part("move", Callable(), map.asset_brush_ids(p1)[0]))
map.call("_q_prompt_cancel")
# ---- E11 ----
var ol: Node = map.get("_outliner")
var rows: Array = (ol.get("model") as OutlinerModel).rows()
var arow: Dictionary = {}
var obj_rows := 0
for r: Dictionary in rows:
if String(r["id"]) == "asset:" + p1:
arow = r
if String(r.get("object_dot", "")) != "" and persist.instance_of(String(r["object_dot"])) != "":
obj_rows += 1
_ok("E11 one '▣ Kit' row covering its pieces", String(arow.get("name", "")) == "▣ Kit"
and (arow.get("brushes", PackedInt32Array()) as PackedInt32Array).size() == map.asset_brush_ids(p1).size(), arow.get("name", ""))
_ok("E11 no rows for its synthetic objects", obj_rows == 0, obj_rows)
map.clear_selection()
ol.call("_select_in_viewport_rows", arow)
_ok("E11 the row selects the placement", map.selected_asset() == p1)
# ---- E12 ----
persist.begin_gesture("link")
var ms: Array = CollectionOps.selection_members(map)
persist.record("col.link", {"target": shelf, "members": ms})
persist.end_gesture()
persist.refold()
_ok("E12 the selection's member is the placement 'a:<dot>'", ms == ["a:" + p1], ms)
var under := false
for r2: Dictionary in (ol.get("model") as OutlinerModel).rows():
if String(r2["id"]) == "col:%s/a:%s" % [shelf, p1] and String(r2["parent"]) == "col:" + shelf:
under = true
_ok("E12 its row is listed under the collection", under)
# ---- E7 ----
map.select_asset(p1)
var n_before := (persist.placements() as Dictionary).size()
map.duplicate_selection(false)
stack.cancel()
var p2: String = map.selected_asset()
_ok("E7 Shift+D: a new placement, same asset and frame, selected", p2 != "" and p2 != p1
and (persist.placements() as Dictionary).size() == n_before + 1
and String(persist.placements()[p2]["asset"]) == String(persist.placements()[p1]["asset"])
and _frame(p2).is_equal_approx(_frame(p1)), [p2])
_ok("E7 ... in the same collections", CollectionFold.collections_of(persist.collection_tree(), "a:" + p2).has(shelf))
# ---- E5 ----
map.select_asset(p2)
map.delete_selection()
_ok("E5 delete: the placement goes (asset.remove)", not _live(p2) and map.asset_brush_ids(p2).is_empty())
persist.undo_last()
persist.refold()
_ok("E5 ... undo brings it back", _live(p2) and not map.asset_brush_ids(p2).is_empty())
# ---- E6 ----
map.delete_objects([_obj_of(p2)])
_ok("E6 delete via its object: the placement goes, not the object alone", not _live(p2))
persist.undo_last()
persist.refold()
# ---- E10 ----
var inst_ids: Array = Array(map.asset_brush_ids(p1))
var before := _digest(inst_ids)
var n_inst := inst_ids.size()
map.select_asset(p1)
var made: int = map.make_real_asset(p1)
var cols: Dictionary = persist.collection_tree()["cols"]
var real_col := ""
for c: String in cols:
if String(cols[c]["name"]) == "Kit" and c != kit:
real_col = c
var real_ids := PackedInt32Array()
for m: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
if m.begins_with("g:"):
real_ids.append_array(level.get_group_brush_ids(m.substr(2)) as PackedInt32Array)
_ok("E10 Make Real: the placement is gone", made > 0 and not _live(p1), made)
_ok("E10 ... a new collection 'Kit' under Shelf holds the real contents", real_col != ""
and String(cols[real_col]["parent"]) == shelf and real_ids.size() == n_inst, [real_col, real_ids.size(), n_inst])
_ok("E10 ... the SAME geometry (windings digest)", _digest(Array(real_ids)) == before, [_digest(Array(real_ids)), before])
var real_shape := ""
for m2: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
if m2.begins_with("g:") and (persist.live_shapes() as Dictionary).has(m2.substr(2)):
real_shape = m2.substr(2)
_ok("E10 ... its box is an ordinary shape (live, in no placement)", real_shape != ""
and persist.instance_of(real_shape) == "" and (persist.live_shapes() as Dictionary).has(real_shape), real_shape)
persist.undo_last()
persist.undo_last()
persist.refold()
_ok("E10 undo restores the placement", _live(p1) and map.asset_brush_ids(p1).size() == n_inst)
# ---- E16: a MIXED selection (a placement's pieces + a plain brush) ----
var plain: int = (level.get_group_brush_ids(grp) as PackedInt32Array)[0]
var pb0: Vector3 = level.get_brush(plain)["aabb_min"]
var f16 := _frame(p1)
var mix: PackedInt32Array = map.asset_brush_ids(p1)
mix.append(plain)
map.select_brushes(mix)
map.translate_selection(Vector3(0, 0, 3))
map.select_brushes(PackedInt32Array(Array(map.asset_brush_ids(p1)) + [level.brush_by_key(String(level.get_brush_key(plain)))]))
map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, _frame(p1).origin)
persist.refold()
_ok("E16 mixed move: the placement by asset.xform, the plain brush by brush.translate",
_frame(p1).origin.is_equal_approx(f16.origin + Vector3(0, 0, 3))
and not (level.get_brush(level.brush_by_key(String(level.get_brush_key(plain))))["aabb_min"] as Vector3).is_equal_approx(pb0),
[_frame(p1).origin - f16.origin])
# ---- E15: after all of it, NO brush / face record names a placement's synthetic piece ----
var bad: Array = []
for r: Dictionary in (persist.records(false) as Array):
var k := String(r["kind"])
if (k.begins_with("brush.") or k.begins_with("face.")) and String(r.get("brush_key", "")).contains("/A"):
bad.append("%s %s" % [k, r["brush_key"]])
_ok("E15 no brush / face record names a placement's synthetic piece", bad.is_empty(), bad)