OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 5e3ce7e

#120 L4 §15 step 4: a placed asset in the editor — one thing, like a shape
Selection (_sel_asset: a piece click or its object selects the placement), G / R → asset.xform (MoveOperator's one-brush path never takes a placement), scale leaves it alone, X / Delete → asset.remove, Shift+D / Alt+D → a new placement in the same collections, its object not movable alone, parts read-only (guard → Make Real), texture refused, Q menu "Placed asset". Make Real journals AssetFold.payloads under fresh dots in a new collection named after the asset, then asset.remove (one gesture). _split_shapes's third bucket keeps placement pieces out of every plain-brush path. Outliner: "▣ name" rows, collections hold "a:<dot>". gate_asset_edit 29/29; 6 mutants killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5e3ce7e5bcd8d66f58cf48b613a5ef7172a8fd90
by plankatron <fleeky@gmail.com> · 2026-10-19T22:34:47.000Z

Changed files (9) +715 -9

docs/node-tree-and-stacks.md +18 -3
……@@ -1328,9 +1328,24 @@ Save as asset is menu-only for now).
13281328 on disk after the fold; empty collection refused; the menu entry. 4 mutants killed
13291329 (the fetch one after the gate flushed AssetSync's deferred content_ready signals,
13301330 which had restored the blob by another path). **DONE 2026-10-19.**
13314. **Editor**: outliner rows, selection mapping, G / R / X / Shift+D / Alt+D, Make Real.
1332 `gate_asset_edit`: each, plus Make Real = the same digest, then ordinary editable
1333 records (a shape param edit works on one).
13314. **Editor** (map: `_sel_asset`, `placement_of_brush`, `asset_brush_ids`, `select_asset`,
1332 `duplicate_asset`, `make_real_asset`; `_split_shapes` gains a third bucket, placements,
1333 which never reach a plain-brush path). A click on a piece (brush mode) or on its object
1334 selects the placement; G / R → asset.xform (MoveOperator's one-brush path never takes a
1335 placement); scale leaves it alone (rigid); X / Delete / deleting its object →
1336 asset.remove; Shift+D / Alt+D → a new asset.place in the same collections; its object
1337 cannot be moved alone; its parts are read-only (the guard offers Make Real); texture
1338 apply refuses; the Q menu says "Placed asset". **Make Real** journals the placement's
1339 records (AssetFold.payloads, shared with the fold) under fresh dots — shapes stay
1340 parametric, cuts live — in a NEW collection named after the asset (sub-collections
1341 beneath it, under the placement's first collection), then asset.remove, one gesture.
1342 Outliner: one "▣ name" row (no rows for its synthetic objects); collections hold
1343 placements as `a:<dot>`; a placement inside a collection being saved is reported left
1344 out (assets inside assets come later). `gate_asset_edit` (29): E1–E16 incl. Make Real =
1345 the instance's windings bit-identical, and "no brush / face record ever names a
1346 synthetic piece" (it caught scale writing brush.scale on every piece while leaving the
1347 frame alone, and the same hole in mixed move / rotate). 6 mutants killed. **DONE
1348 2026-10-19.**
133413495. **Browser**: the bottom panel, tiles, click + Shift+A, drag into the view (the drop
13351350 path called directly in the gate, as the outliner's drop_on is).
13361351 `gate_asset_browser`.
scenes/game/outliner_model.gd +39 -2
……@@ -118,8 +118,8 @@ func _one_tree(base: Array, cols: Array) -> Array:
118118 if String(r["parent"]) == "":
119119 r["parent"] = "node:." # top-level collections: under the Scene
120120 continue
121 var m := id.substr(slash + 1) # "obj:<dot>" or "g:<group>"
122 var home := m if m.begins_with("obj:") else "group:" + m.substr(2)
121 var m := id.substr(slash + 1) # "obj:<dot>", "g:<group>" or "a:<placement>"
122 var home := m if m.begins_with("obj:") else ("asset:" + m.substr(2) if m.begins_with("a:") else "group:" + m.substr(2))
123123 if not member_row.has(home):
124124 member_row[home] = id
125125 var out: Array = []
……@@ -225,7 +225,11 @@ func _add_object_rows(out: Array) -> void:
225225 (by_parent[pd] as Array).append(e)
226226 var order: Array = []
227227 _walk_objects(by_parent, "", order, 0)
228 var pp: Node = map.get("_persist") if map != null else null
228229 for e: Dictionary in order:
230 # A PLACED ASSET's objects are inside its row, read-only (#120 L4).
231 if pp != null and is_instance_valid(pp) and String(pp.instance_of(String(e["dot"]))) != "":
232 continue
229233 # A model reads by its NAME ("model · chair"): ten rows that all say "model"
230234 # cannot be told apart. Other prefabs keep their type.
231235 var label := String(e["type"])
……@@ -317,11 +321,25 @@ func _build_brush_rows(out: Array) -> void:
317321 var shapes: Dictionary = {}
318322 if p != null and is_instance_valid(p) and p.has_method("live_shapes"):
319323 shapes = p.live_shapes()
324 # PLACED ASSETS (#120 L4 §15.3): ONE row each, "▣ <name>", covering every piece of the
325 # placement (its synthetic groups are renamed by every move, so they get no rows).
326 if p != null and is_instance_valid(p) and p.has_method("placements"):
327 var pls: Array = (p.placements() as Dictionary).values()
328 pls.sort_custom(func(x: Dictionary, y: Dictionary) -> bool: return String(x["dot"]) < String(y["dot"]))
329 for pl: Dictionary in pls:
330 if bool(pl["removed"]):
331 continue
332 var aids: PackedInt32Array = map.asset_brush_ids(String(pl["dot"])) if map.has_method("asset_brush_ids") else PackedInt32Array()
333 for b2: int in aids:
334 claimed[b2] = true
335 out.append(_asset_row("asset:%s" % pl["dot"], parent, pl, aids))
320336 if level.has_method("get_group_keys"):
321337 for gk: String in (level.get_group_keys() as PackedStringArray):
322338 var members: PackedInt32Array = level.get_group_brush_ids(gk)
323339 if members.is_empty():
324340 continue
341 if p != null and is_instance_valid(p) and p.has_method("instance_of") and String(p.instance_of(gk)) != "":
342 continue # a placement's piece: in its row above
325343 for b: int in members:
326344 claimed[b] = true
327345 var row := {
……@@ -390,12 +408,25 @@ func _add_collection_rows(out: Array) -> void:
390408 if not seen.has(b):
391409 seen[b] = true
392410 all.append(b)
411 elif m.begins_with("a:") and map.has_method("asset_brush_ids"):
412 for b3: int in (map.asset_brush_ids(m.substr(2)) as PackedInt32Array):
413 if not seen.has(b3):
414 seen[b3] = true
415 all.append(b3)
393416 out.append({
394417 "kind": Kind.COLLECTION, "id": cid, "name": String(c["name"]),
395418 "parent": "col:" + String(c["parent"]) if String(c["parent"]) != "" else "",
396419 "brushes": all, "count": all.size(), "node_path": NodePath(), "col_dot": d,
397420 })
421 var pls2: Dictionary = p.placements() if p.has_method("placements") else {}
398422 for m: String in (c["members"] as Array):
423 if m.begins_with("a:"):
424 var pl2: Dictionary = pls2.get(m.substr(2), {})
425 if pl2.is_empty() or bool(pl2["removed"]):
426 continue
427 var aids2: PackedInt32Array = map.asset_brush_ids(m.substr(2)) if map.has_method("asset_brush_ids") else PackedInt32Array()
428 out.append(_asset_row("%s/%s" % [cid, m], cid, pl2, aids2))
429 continue
399430 if m.begins_with("obj:"):
400431 var od := m.substr(4)
401432 if not obj_by_dot.has(od):
……@@ -422,6 +453,12 @@ func _add_collection_rows(out: Array) -> void:
422453 out.append(row)
423454
424455
456## A placed asset's row (#120 L4): selecting it selects the placement (`asset_dot`).
457func _asset_row(id: String, parent: String, pl: Dictionary, ids: PackedInt32Array) -> Dictionary:
458 return {"kind": Kind.GROUP, "id": id, "name": "▣ " + String(pl["name"]), "parent": parent,
459 "brushes": ids, "count": ids.size(), "node_path": NodePath(), "asset_dot": String(pl["dot"])}
460
461
425462## A shape's type label ("Stairs"), from its resolved state.
426463func _shape_label(p: Node, dot: String) -> String:
427464 var tid := String((p.shape_state(dot) as Dictionary).get("type", ""))
scenes/game/outliner_panel.gd +25 -0
……@@ -86,6 +86,9 @@ var _pushed_sig := ""
8686func _map_sig() -> String:
8787 if map == null:
8888 return ""
89 var ad := String(map.get("_sel_asset")) if map.get("_sel_asset") is String else ""
90 if ad != "":
91 return "asset:" + ad
8992 var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else ""
9093 if sd != "":
9194 return "shape:" + sd
……@@ -182,6 +185,19 @@ func _on_map_selection() -> void:
182185## selects). Never the group row: that row covers more than was picked, and Delete on
183186## it would take the rest too. The active brush is selected last, so it is the cursor.
184187func _show_map_selection() -> void:
188 var ad := String(map.get("_sel_asset")) if map.get("_sel_asset") is String else ""
189 if ad != "":
190 # Its own row, or the collection row it is listed under (one tree, #124 B4).
191 var ait: TreeItem = _items.get("asset:" + ad, null)
192 if ait == null:
193 for id: Variant in _items:
194 if String(id).ends_with("/a:" + ad):
195 ait = _items[id]
196 break
197 if ait != null:
198 _reveal(ait)
199 ait.select(COL_NAME)
200 return
185201 var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else ""
186202 if sd != "":
187203 var sit: TreeItem = _items.get(_group_id(sd), null)
……@@ -934,6 +950,10 @@ func drag_data_for_selection() -> Dictionary:
934950 var row: Dictionary = r
935951 var od := String(row.get("object_dot", ""))
936952 var id := String(row["id"])
953 if String(row.get("asset_dot", "")) != "":
954 members.append("a:" + String(row["asset_dot"])) # a placed asset (#120 L4)
955 it = _tree.get_next_selected(it)
956 continue
937957 var g := String(row.get("shape_dot", ""))
938958 if g == "":
939959 # A member row is "col:<dot>/<member>"; a collection dot has no "/".
……@@ -1228,6 +1248,11 @@ func _select_in_viewport_rows(r: Dictionary) -> void:
12281248 return
12291249 # A ◇ SHAPE ROW selects the shape, so the Shape panel follows — selecting its
12301250 # pieces as brushes would leave _sel_shape empty and the panel blank.
1251 # A PLACED ASSET's row selects the placement (#120 L4).
1252 var adot := String(r.get("asset_dot", ""))
1253 if adot != "" and map.has_method("select_asset"):
1254 map.select_asset(adot)
1255 return
12311256 var sdot := String(r.get("shape_dot", ""))
12321257 if sdot != "" and map.has_method("select_shape"):
12331258 map.select_shape(sdot)
scenes/maps/asset_doc.gd +2 -0
……@@ -75,6 +75,8 @@ static func write(persist: Node, col: String, name: String = "") -> Dictionary:
7575 groups[g] = true
7676 elif m.begins_with("obj:") and objects != null and objects.node_for(m.substr(4)) != null:
7777 objs[m.substr(4)] = true
78 elif m.begins_with("a:"):
79 report.append("placed asset %s: assets inside assets come later — left out" % m.substr(2))
7880
7981 # ---- live cuts: an op comes with a shape target inside, and brings its cutter ----
8082 var ops: Array = []
scenes/maps/collection_ops.gd +6 -0
……@@ -34,6 +34,12 @@ static func selection_members(map: Node) -> Array:
3434 var persist: Node = map.get("_persist")
3535 for b: Variant in map.get("_sel_brushes"):
3636 var g := String(level.get_brush_group(int(b)))
37 # A PLACED ASSET's piece is a member as its PLACEMENT, "a:<dot>" (#120 L4): its
38 # synthetic groups are renamed by every move.
39 var pa := String(persist.instance_of(g)) if g != "" and persist != null else ""
40 if pa != "":
41 _add(out, seen, "a:" + pa)
42 continue
3743 if g == "":
3844 var key := String(level.get_brush_key(int(b)))
3945 if key == "":
scenes/maps/map_dc_level.gd +304 -3
……@@ -587,6 +587,10 @@ func q_sections() -> Array:
587587 return [cs[0]] if not cs.is_empty() else []
588588 if _sel_brushes.is_empty():
589589 return [["Add", [["builder.create_box"]]], cursor_section()]
590 if _sel_asset != "":
591 # A PLACED ASSET is one thing (#120 L4): its pieces are read-only.
592 return [["Placed asset", [["builder.make_real"], ["builder.duplicate"], ["builder.delete"]]],
593 ["Selection", [["builder.hide"], ["builder.hide_unselected"]]]]
590594 var things := {}
591595 for b: int in _sel_brushes:
592596 var g := shape_of_brush(b)
……@@ -924,6 +928,10 @@ func individual_units(ids: Array) -> Array:
924928 var pieces: PackedInt32Array = level.get_group_brush_ids(d)
925929 var bs := _unit_bounds(pieces)
926930 out.append({"key": "", "shape": d, "ids": pieces, "centre": bs.get_center(), "size": bs.size})
931 for pd: String in (sp[2] as Dictionary): # a placed asset is one unit (#120 L4)
932 var aps := asset_brush_ids(pd)
933 var ab := _unit_bounds(aps)
934 out.append({"key": "", "shape": "", "asset": pd, "ids": aps, "centre": ab.get_center(), "size": ab.size})
927935 for b: int in (sp[1] as Array):
928936 var one := PackedInt32Array([b])
929937 var bb := _unit_bounds(one)
……@@ -934,6 +942,8 @@ func individual_units(ids: Array) -> Array:
934942
935943## A unit's CURRENT brush ids (re-resolved by its durable name).
936944func unit_ids(u: Dictionary) -> PackedInt32Array:
945 if String(u.get("asset", "")) != "":
946 return asset_brush_ids(String(u["asset"]))
937947 if String(u["shape"]) != "":
938948 return level.get_group_brush_ids(String(u["shape"]))
939949 var b: int = level.brush_by_key(String(u["key"]))
……@@ -1193,6 +1203,8 @@ func asset_collection() -> String:
11931203 # The selection's members, READ ONLY (CollectionOps.selection_members regroups loose
11941204 # brushes — a write — and asking which collection must not write).
11951205 var ms: Array = []
1206 if _sel_asset != "":
1207 ms.append("a:" + _sel_asset)
11961208 if _sel_shape != "":
11971209 ms.append("g:" + _sel_shape)
11981210 for d: Variant in selected_objects():
……@@ -1299,6 +1311,9 @@ func _selection_member_names() -> Array:
12991311 var out: Array = []
13001312 for d: String in selected_objects():
13011313 out.append("obj:" + d)
1314 if _sel_asset != "":
1315 out.append("a:" + _sel_asset) # a placed asset (#120 L4)
1316 return out
13021317 if _sel_shape != "":
13031318 out.append("g:" + _sel_shape)
13041319 for b: int in _sel_brushes:
……@@ -1616,6 +1631,9 @@ func box_select(r: Rect2, add: bool) -> int:
16161631
16171632
16181633func _q_make_real() -> void:
1634 if _sel_asset != "":
1635 make_real_asset(_sel_asset) # a placed asset (#120 L4 §15.3)
1636 return
16191637 var s := stack_subject()
16201638 if s != "":
16211639 make_real(s)
……@@ -1670,6 +1688,17 @@ var _pending_part_edit := Callable()
16701688## prompt is open, and Make Real will run `resume` on the same part of the plain brush.
16711689func guard_shape_part(what: String, resume: Callable, brush: int = -2) -> bool:
16721690 var b := _sel_brush if brush == -2 else brush
1691 # A PLACED ASSET's part (#120 L4): read-only too. Make Real first (no "then go on":
1692 # its parts come back under new names in a new collection), or Cancel.
1693 var pa := placement_of_brush(b) if b >= 0 else ""
1694 if pa != "":
1695 if _q_menu != null:
1696 var pat := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
1697 _q_menu.open_sections([["This is a placed asset: its parts are read-only",
1698 [["builder.make_real"], ["builder.prompt_cancel"]]]], pat)
1699 _sel_asset = pa
1700 print("[MapDCLevel] %s: %s is a placed asset — Make Real it first, or Cancel" % [what, pa])
1701 return false
16731702 var sd := shape_of_brush(b) if b >= 0 else ""
16741703 if sd == "":
16751704 return true
……@@ -1897,6 +1926,9 @@ func apply_texture_to_selection() -> int:
18971926 return 0
18981927 # A PARAMETRIC SHAPE is textured as a whole (§13.3): one shape.set {tex}. A face.tex
18991928 # on a piece would be lost — every regeneration names the pieces afresh.
1929 if _sel_asset != "" or placement_of_brush(_sel_brush) != "":
1930 print("[texture] a placed asset's faces are read-only (#120 L4) — Make Real it to texture them")
1931 return 0
19001932 if _sel_shape != "":
19011933 return set_shape_tex(_sel_shape, current_face_tex())
19021934 var tex_id: String = current_face_tex()["tex"]
……@@ -3297,6 +3329,9 @@ func move_object(dot: String, xform: Transform3D) -> bool:
32973329 if dot == "":
32983330 return false
32993331 _ensure_persistence()
3332 if String(_persist.instance_of(dot)) != "":
3333 print("[MapDCLevel] %s is part of a placed asset — move the placement (or Make Real it)" % dot)
3334 return false
33003335 var op: String = _persist.reserve_dot()
33013336 if not _persist.record_at(op, "object.transform", {
33023337 "target": dot, "xform": SceneObjects.xform_to(xform)}):
……@@ -3481,22 +3516,31 @@ func delete_brushes(ids: Array) -> int:
34813516 var shape_targets := {}
34823517 var n := 0
34833518 _persist.begin_gesture("Delete")
3519 var asset_targets := {}
34843520 for id: Variant in ids:
34853521 var g: String = String(level.get_brush_group(int(id)))
34863522 if shapes.has(g):
34873523 shape_targets[g] = true
34883524 continue
3525 # A PLACED ASSET's piece deletes the placement (#120 L4): its pieces are read-only.
3526 var pa: String = String(_persist.instance_of(g)) if g != "" else ""
3527 if pa != "":
3528 asset_targets[pa] = true
3529 continue
34893530 var bkey: String = String(level.get_brush_key(int(id)))
34903531 if bkey != "" and _persist.record("brush.remove", {"brush_key": bkey}) != "":
34913532 n += 1
34923533 for g: String in shape_targets:
34933534 if _persist.remove_shape(g) != "":
34943535 n += 1
3536 for pa2: String in asset_targets:
3537 if _persist.record("asset.remove", {"target": pa2}) != "":
3538 n += 1
34953539 _persist.end_gesture()
34963540 if n > 0:
34973541 _clear_face_selection()
34983542 _persist.refold()
3499 print("[MapDCLevel] deleted %d brush(es) / shape(s) — Ctrl+Z to bring them back" % n)
3543 print("[MapDCLevel] deleted %d brush(es) / shape(s) / placed asset(s) — Ctrl+Z to bring them back" % n)
35003544 return n
35013545
35023546
……@@ -3508,6 +3552,25 @@ func delete_brushes(ids: Array) -> int:
35083552## Hierarchy) skips the move-up: the children go too. One gesture either way.
35093553func delete_objects(dots: Array, hierarchy: bool = false) -> int:
35103554 _ensure_persistence()
3555 # A PLACED ASSET's object deletes the placement (#120 L4), never the object alone.
3556 var assets := {}
3557 var own: Array = []
3558 for d0: Variant in dots:
3559 var pa := String(_persist.instance_of(String(d0)))
3560 if pa != "":
3561 assets[pa] = true
3562 else:
3563 own.append(d0)
3564 if not assets.is_empty():
3565 _persist.begin_gesture("Delete")
3566 for pa3: String in assets:
3567 _persist.record("asset.remove", {"target": pa3})
3568 if not own.is_empty():
3569 delete_objects(own, hierarchy)
3570 _persist.end_gesture()
3571 _clear_face_selection()
3572 _persist.refold()
3573 return assets.size() + own.size()
35113574 var n := 0
35123575 _persist.begin_gesture("Delete %d object%s" % [dots.size(), "" if dots.size() == 1 else "s"])
35133576 if not hierarchy and _objects != null and is_instance_valid(_objects):
……@@ -3550,6 +3613,8 @@ func move_objects(moves: Dictionary, label: String = "") -> int:
35503613 var n := 0
35513614 _persist.begin_gesture(label)
35523615 for d: String in moves:
3616 if String(_persist.instance_of(d)) != "":
3617 continue # a placed asset's object (#120 L4): read-only
35533618 var local: Transform3D = _objects.local_for(_objects.parent_of(d), moves[d] as Transform3D) \
35543619 if _objects != null and is_instance_valid(_objects) else moves[d] as Transform3D
35553620 if _persist.record_at(_persist.reserve_dot(), "object.transform",
……@@ -3676,6 +3741,10 @@ func _emit_selection() -> void:
36763741## replacing it — the brush rule. The ACTIVE object (_sel_object, what the Object panel
36773742## and the Outliner's fields edit) is the last one added; G / R / Delete act on all.
36783743func select_object(dot: String, add: bool = false) -> void:
3744 # A PLACED ASSET's object selects the placement (#120 L4): it is not editable alone.
3745 if dot != "" and _persist != null and String(_persist.instance_of(dot)) != "":
3746 select_asset(String(_persist.instance_of(dot)))
3747 return
36793748 if add and dot != "":
36803749 var at := _sel_objects.find(dot)
36813750 if at >= 0:
……@@ -3692,6 +3761,21 @@ func select_object(dot: String, add: bool = false) -> void:
36923761## Replace the object selection with `dots` (the Outliner's multi-row pick); `active`
36933762## becomes the active one when it is among them.
36943763func select_objects(dots: Array, active: String = "") -> void:
3764 # A placed asset's objects are not selectable alone (#120 L4): dropped, and when they
3765 # are all there is, their placement is selected instead.
3766 var placed := ""
3767 if _persist != null:
3768 var own: Array = []
3769 for d0: Variant in dots:
3770 var pa := String(_persist.instance_of(String(d0)))
3771 if pa == "":
3772 own.append(d0)
3773 elif placed == "":
3774 placed = pa
3775 if own.is_empty() and placed != "":
3776 select_asset(placed)
3777 return
3778 dots = own
36953779 _sel_objects.clear()
36963780 for d: Variant in dots:
36973781 if String(d) != "" and not _sel_objects.has(String(d)):
……@@ -3708,6 +3792,7 @@ func selected_objects() -> Array:
37083792
37093793func _select_objects_common() -> void:
37103794 _sel_shape = ""
3795 _sel_asset = ""
37113796 _sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
37123797 _announce_selection()
37133798 _sel_brushes.clear()
……@@ -5479,6 +5564,18 @@ func clear_selection() -> void:
54795564
54805565## After a fold: the selected shape's pieces have new ids (or it is gone).
54815566func _reselect_shape() -> void:
5567 if _sel_asset != "":
5568 # A placed asset (#120 L4): its pieces are renamed by a move, or it is gone.
5569 var pd := _sel_asset
5570 var p: Dictionary = (_persist.placements() as Dictionary).get(pd, {})
5571 if p.is_empty() or bool(p["removed"]):
5572 _clear_face_selection()
5573 return
5574 var aids := asset_brush_ids(pd)
5575 if aids != PackedInt32Array(_sel_brushes):
5576 select_brushes(aids)
5577 _sel_asset = pd
5578 return
54825579 if _sel_shape == "":
54835580 return
54845581 var dot := _sel_shape
……@@ -5492,17 +5589,169 @@ func _reselect_shape() -> void:
54925589 _sel_shape = dot
54935590
54945591
5495## The live shapes among `ids`, and the ids that are plain brushes: [{dot: true}, [ids]].
5592## The live shapes among `ids`, the ids that are plain brushes, and the PLACED ASSETS
5593## (#120 L4) whose pieces are among them: [{dot: true}, [ids], {placement dot: true}]. A
5594## placement's pieces are never plain: every edit goes to its record (asset.*).
54965595func _split_shapes(ids: Array) -> Array:
54975596 var shapes := {}
54985597 var rest: Array = []
5598 var assets := {}
54995599 for b: Variant in ids:
5600 var pa := placement_of_brush(int(b))
5601 if pa != "":
5602 assets[pa] = true
5603 continue
55005604 var d := shape_of_brush(int(b))
55015605 if d != "":
55025606 shapes[d] = true
55035607 else:
55045608 rest.append(int(b))
5505 return [shapes, rest]
5609 return [shapes, rest, assets]
5610
5611
5612# ---- PLACED ASSETS in the editor (#120 L4 §15.3) ----------------------------------
5613#
5614# A placement is ONE thing, like a shape: clicking any of its pieces (or its objects)
5615# selects it; G / R write asset.xform, X asset.remove, Shift+D / Alt+D a new asset.place;
5616# its parts are read-only (Make Real hands them over). _sel_asset is HELD like _sel_shape:
5617# a move renames its pieces, so _reselect_shape re-takes them after the fold.
5618
5619var _sel_asset := ""
5620
5621
5622## The placement a brush is a piece of, or "".
5623func placement_of_brush(id: int) -> String:
5624 if id < 0 or level == null:
5625 return ""
5626 _ensure_persistence()
5627 var g := String(level.get_brush_group(id))
5628 return String(_persist.instance_of(g)) if g != "" else ""
5629
5630
5631## A placement's live brush ids.
5632func asset_brush_ids(pdot: String) -> PackedInt32Array:
5633 var out := PackedInt32Array()
5634 if level == null or pdot == "":
5635 return out
5636 _ensure_persistence()
5637 for b: int in (level.get_live_brush_ids() as PackedInt32Array):
5638 var g := String(level.get_brush_group(b))
5639 if g != "" and String(_persist.instance_of(g)) == pdot:
5640 out.append(b)
5641 return out
5642
5643
5644func selected_asset() -> String:
5645 return _sel_asset
5646
5647
5648func select_asset(pdot: String) -> void:
5649 select_brushes(asset_brush_ids(pdot))
5650 _sel_asset = pdot
5651 print("[MapDCLevel] selected placed asset %s (%d pieces)" % [pdot, _sel_brushes.size()])
5652
5653
5654## Write a placement's new frame (asset.xform). True if recorded.
5655func _record_asset_frame(pdot: String, frame: Transform3D) -> bool:
5656 return _persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)}) != ""
5657
5658
5659func _asset_frame(pdot: String) -> Transform3D:
5660 return (_persist.placements() as Dictionary).get(pdot, {}).get("frame", Transform3D.IDENTITY)
5661
5662
5663## Shift+D / Alt+D on a placement: a new asset.place of the same asset at the same frame,
5664## linked into the same collections (every placement is already linked data). Returns its dot.
5665func duplicate_asset(pdot: String) -> String:
5666 var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
5667 if p.is_empty():
5668 return ""
5669 var d: String = _persist.record("asset.place", {"asset": p["asset"], "name": p["name"],
5670 "frame": SceneObjects.xform_to(p["frame"])})
5671 if d != "":
5672 var t: Dictionary = _persist.collection_tree()
5673 for c: String in CollectionFold.collections_of(t, "a:" + pdot):
5674 _persist.record("col.link", {"target": c, "members": ["a:" + d]})
5675 return d
5676
5677
5678## MAKE REAL on a placement (§15.3): its records — exactly what the fold expands it to
5679## (AssetFold.payloads, shared) — journalled as ordinary ones under FRESH dots, shapes still
5680## parametric and cuts still live, in a NEW collection named after the asset (its
5681## sub-collections beneath it, under the placement's first collection if it is in one); then
5682## asset.remove. ONE gesture: Ctrl+Z brings the placement back. Returns how many records.
5683func make_real_asset(pdot: String) -> int:
5684 _ensure_persistence()
5685 var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
5686 var docs: Dictionary = _persist.get("_asset_docs")
5687 if p.is_empty() or bool(p["removed"]) or not docs.has(String(p["asset"])):
5688 print("[MapDCLevel] make real: %s is not a placement whose asset is here" % pdot)
5689 return 0
5690 var doc: Dictionary = docs[String(p["asset"])]
5691 var names := LevelPersistence.AssetFold.local_names(doc)
5692 var fresh := {}
5693 var locals: Array = names.keys()
5694 locals.sort_custom(func(a: String, b: String) -> bool: return int(names[a]) < int(names[b]))
5695 _persist.begin_gesture("Make Real")
5696 for l: String in locals:
5697 fresh[l] = _persist.reserve_dot()
5698 var dot_of := func(l: String) -> String: return String(fresh.get(l, ""))
5699 var n := 0
5700 for e: Dictionary in LevelPersistence.AssetFold.payloads(p, doc, dot_of):
5701 var pl: Dictionary = (e["payload"] as Dictionary).duplicate(true)
5702 var kind := String(pl["kind"])
5703 pl.erase("kind")
5704 var at: String = fresh[String(e["local"])] if String(e["suffix"]) == "" else _persist.reserve_dot()
5705 if _persist.record_at(at, kind, pl):
5706 n += 1
5707 # Organisation: one collection named after the asset holds the top level; the blob's
5708 # sub-collections are made beneath it with their members remapped.
5709 var t: Dictionary = _persist.collection_tree()
5710 var home := CollectionFold.collections_of(t, "a:" + pdot)
5711 var top: String = _persist.record("col.create", {"name": String(p["name"]),
5712 "parent": String(home[0]) if not home.is_empty() else ""})
5713 var in_sub := {}
5714 var sub_dot := {}
5715 for r: Dictionary in (doc["records"] as Array):
5716 if String(r["kind"]) == "col.create":
5717 sub_dot[String(r["dot"])] = _persist.record("col.create", {"name": String(r["name"]),
5718 "parent": sub_dot.get(String(r["parent"]), top)})
5719 for r2: Dictionary in (doc["records"] as Array):
5720 if String(r2["kind"]) != "col.create":
5721 continue
5722 var ms: Array = []
5723 for m: String in (r2["members"] as Array):
5724 var l2 := m.substr(m.find(":") + 1)
5725 in_sub[l2] = true
5726 if fresh.has(l2):
5727 ms.append(m.substr(0, m.find(":") + 1) + String(fresh[l2]))
5728 if not ms.is_empty():
5729 _persist.record("col.link", {"target": sub_dot[String(r2["dot"])], "members": ms})
5730 var top_ms: Array = []
5731 for r3: Dictionary in (doc["records"] as Array):
5732 var l3 := String(r3["dot"])
5733 if in_sub.has(l3):
5734 continue
5735 match String(r3["kind"]):
5736 "shape.create":
5737 top_ms.append("g:" + String(fresh[l3]))
5738 "object.create":
5739 if String(r3.get("parent", "")) == "":
5740 top_ms.append("obj:" + String(fresh[l3]))
5741 "brush.create":
5742 var lg := String(r3["group"])
5743 if not in_sub.has(lg) and not top_ms.has("g:" + String(fresh[lg])):
5744 top_ms.append("g:" + String(fresh[lg]))
5745 if not top_ms.is_empty():
5746 _persist.record("col.link", {"target": top, "members": top_ms})
5747 _persist.record("asset.remove", {"target": pdot})
5748 _persist.end_gesture()
5749 if _sel_asset == pdot:
5750 _sel_asset = ""
5751 _persist.refold()
5752 _clear_face_selection()
5753 print("[MapDCLevel] made placed asset %s real: %d record(s), collection \"%s\"" % [pdot, n, p["name"]])
5754 return n
55065755
55075756
55085757## Write a shape's new frame (and size). Refolds; returns true if recorded.
……@@ -6166,6 +6415,7 @@ func make_real(dot: String) -> int:
61666415
61676416func select_brushes(ids: PackedInt32Array) -> void:
61686417 _sel_shape = ""
6418 _sel_asset = ""
61696419 _sel_object = "" # one selection at a time; see _sel_object
61706420 _sel_objects.clear()
61716421 _announce_selection()
……@@ -6217,6 +6467,18 @@ func rotate_brushes(ids: Array, axis: Vector3, deg: float, pivot: Vector3) -> in
62176467 _ensure_persistence()
62186468 # A SHAPE turns its FRAME about the pivot (shape.xform); its pieces regenerate.
62196469 var sp: Array = _split_shapes(ids)
6470 # A PLACED ASSET turns its frame the same way (asset.xform, #120 L4).
6471 if not (sp[2] as Dictionary).is_empty():
6472 var ra := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
6473 var about_a := Transform3D(Basis.IDENTITY, pivot) * ra * Transform3D(Basis.IDENTITY, -pivot)
6474 var na := 0
6475 for pd: String in (sp[2] as Dictionary):
6476 if _record_asset_frame(pd, about_a * _asset_frame(pd)):
6477 na += 1
6478 if (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
6479 return _shapes_transformed(na)
6480 _shapes_transformed(na)
6481 ids = sp[1] # plain brushes only below: never a placement's piece
62206482 if not (sp[0] as Dictionary).is_empty():
62216483 var r := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
62226484 var about := Transform3D(Basis.IDENTITY, pivot) * r * Transform3D(Basis.IDENTITY, -pivot)
……@@ -6256,6 +6518,15 @@ func translate_selection(delta: Vector3) -> int:
62566518 # A SHAPE moves its FRAME (shape.xform); its pieces regenerate where it now is.
62576519 var ids: Array = _sel_brushes.duplicate()
62586520 var sp: Array = _split_shapes(ids)
6521 # A PLACED ASSET moves its frame too (asset.xform, #120 L4).
6522 var na := 0
6523 for pd: String in (sp[2] as Dictionary):
6524 var fa := _asset_frame(pd)
6525 if _record_asset_frame(pd, Transform3D(fa.basis, fa.origin + delta)):
6526 na += 1
6527 if na > 0 and (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
6528 return _shapes_transformed(na)
6529 ids = sp[1] # plain brushes only below: never a placement's piece
62596530 if not (sp[0] as Dictionary).is_empty():
62606531 var ns := 0
62616532 for d: String in (sp[0] as Dictionary):
……@@ -6314,6 +6585,8 @@ func scale_shapes_local(ids: Array, f: Vector3, pivot: Vector3) -> int:
63146585 return 0
63156586 _ensure_persistence()
63166587 var sp: Array = _split_shapes(ids)
6588 if not (sp[2] as Dictionary).is_empty():
6589 print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
63176590 var ns := 0
63186591 for d: String in (sp[0] as Dictionary):
63196592 var st: Dictionary = _persist.shape_state(d)
……@@ -6351,6 +6624,9 @@ func scale_brushes(ids: Array, factor: Vector3, pivot: Vector3) -> int:
63516624 # extent scales by the factor of the world axis it lies on. A turned frame cannot
63526625 # take a non-uniform world scale as a size; it is refused, not approximated.
63536626 var sp: Array = _split_shapes(ids)
6627 if not (sp[2] as Dictionary).is_empty():
6628 print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
6629 ids = sp[1] # plain brushes only below: never a placement's piece
63546630 if not (sp[0] as Dictionary).is_empty():
63556631 var ns := 0
63566632 for d: String in (sp[0] as Dictionary):
……@@ -6463,6 +6739,7 @@ func _selection_centre() -> Vector3:
64636739
64646740func _clear_face_selection() -> void:
64656741 _sel_shape = ""
6742 _sel_asset = ""
64666743 _sel_object = ""
64676744 _sel_objects.clear()
64686745 _announce_selection()
……@@ -6485,6 +6762,7 @@ func _note_selected(brush: int, add: bool) -> void:
64856762 # object. It did not — select an object in the Outliner, click a wall, press G,
64866763 # and the OBJECT moved, because MoveOperator checks _sel_object first.
64876764 _sel_shape = ""
6765 _sel_asset = ""
64886766 _sel_object = ""
64896767 _sel_objects.clear()
64906768 _announce_selection()
……@@ -6805,6 +7083,16 @@ func _pick_element(add: bool = false, at: Variant = null) -> void:
68057083 # every new box is a shape, so element selection looked broken). The pieces are still
68067084 # read-only (docs/parametric-shapes-design.md §7): an EDIT of one asks first
68077085 # (guard_shape_part: Make Real, then go on / Cancel).
7086 # A PLACED ASSET's piece (#120 L4): brush mode selects the PLACEMENT, as for a shape.
7087 var pa := placement_of_brush(brush)
7088 if pa != "" and _sel_mode == "brush":
7089 select_asset(pa)
7090 _sel_point = hit["position"]
7091 _sel_kind = "brush"
7092 _update_face_highlight()
7093 _update_element_highlight()
7094 _update_op_hud()
7095 return
68087096 var sd := shape_of_brush(brush)
68097097 if sd != "" and _sel_mode == "brush":
68107098 select_shape(sd)
……@@ -7941,6 +8229,19 @@ func duplicate_selection(linked: bool = false) -> bool:
79418229 return false
79428230 select_objects(made, made[-1])
79438231 print("[duplicate] %d object(s)" % made.size())
8232 elif _sel_asset != "":
8233 # A PLACED ASSET (#120 L4): a new placement of the same asset, same collections —
8234 # Shift+D and Alt+D alike (every placement is already linked data).
8235 var srca := _sel_asset
8236 _persist.begin_gesture("Duplicate")
8237 var da := duplicate_asset(srca)
8238 _persist.end_gesture()
8239 if da == "":
8240 print("[duplicate] the journal refused the placement copy")
8241 return false
8242 _persist.refold()
8243 select_asset(da)
8244 print("[duplicate] placed asset %s -> %s" % [srca, da])
79448245 elif _sel_shape != "":
79458246 var src := _sel_shape
79468247 _persist.begin_gesture("Duplicate")
scenes/maps/operators/move_operator.gd +5 -1
……@@ -700,7 +700,11 @@ func _confirm_main(d: Vector3) -> bool:
700700 # while the ring walked off — the terrain then kept a passage where the arch no
701701 # longer was. Every selected brush translates, one rebuild at the end.
702702 var multi: Array = _map_array("_sel_brushes")
703 if _object == "" and multi.size() > 1 and map.has_method("translate_selection"):
703 # A PLACED ASSET always goes through translate_selection (asset.xform, #120 L4), even
704 # as ONE piece: the single-brush path below would journal brush.translate on a
705 # synthetic piece, which the next fold of the placement discards.
706 var placed := String(map.get("_sel_asset")) if "_sel_asset" in map else ""
707 if _object == "" and (multi.size() > 1 or placed != "") and map.has_method("translate_selection"):
704708 # The map owns the journal write. The organizer's numeric fields move a
705709 # group through the same call, and two writers of one journal path is how
706710 # they come to disagree about what a record means.
scenes/test/gate_asset_edit.gd +310 -0
……@@ -0,0 +1,310 @@
1extends Node
2## GATE — A PLACED ASSET IN THE EDITOR (#120 L4 step 4, docs/node-tree-and-stacks.md
3## §15.3). A collection "Kit" (a box with a mirror, a plain brush object, a scene object)
4## and "Solo" (ONE plain brush) saved and placed. A placement is ONE thing, like a shape:
5## E1 any piece / its object maps to the placement; picking the object selects IT
6## E2 move (translate_selection): asset.xform; the selection follows the renamed pieces
7## E3 rotate (rotate_brushes): the frame turns about the pivot
8## E4 G through MoveOperator on a ONE-piece placement: asset.xform, and no brush.translate
9## on a synthetic piece (the single-brush path)
10## E5 delete the selection: asset.remove (one gesture; undo brings it back)
11## E6 delete via its object: the placement goes, not the object alone
12## E7 Shift+D: a new placement, same asset and frame, selected, in the same collections
13## E8 its object cannot be moved alone (move_object refuses, nothing journalled)
14## E9 its parts are read-only (guard_shape_part refuses)
15## E10 Make Real: ordinary records, the SAME geometry (windings digest), in a collection
16## "Kit"; the placement removed; a shape param edit then works; undo restores
17## E11 the outliner: one "▣ Kit" row, no rows for its synthetic objects; the row selects it
18## E12 a collection member "a:<dot>": linked from the selection; its row is under it
19## E13 scale leaves a placement alone (rigid): its frame AND its pieces (a scale once
20## wrote brush.scale on every synthetic piece while leaving the frame alone)
21## E14 the Q menu on a placement: "Placed asset" (Make Real / Duplicate / Delete)
22## E15 after all of it: no brush / face record names a placement's synthetic piece
23## E16 a MIXED selection (placement + plain brush) moved and turned: each its own way
24##
25## 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
26
27const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
28const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
29
30var fails := 0
31var checks := 0
32var map: Node3D
33var persist: Node
34var level: Node
35var lib: Node
36
37
38func _ok(what: String, cond: bool, note: Variant = "") -> void:
39 checks += 1
40 if not cond:
41 fails += 1
42 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
43
44
45func _ready() -> void:
46 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
47 gs.route("world:level:*", 1, "peer")
48 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
49 if keet != null:
50 keet.current_member_id = "probe"
51 get_tree().root.get_node("/root/GameMode").set_mode("build")
52 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
53 add_child(map)
54 for i in 6:
55 await get_tree().process_frame
56 persist = map.get("_persist")
57 level = map.get("level")
58 lib = map.asset_library()
59 _run()
60 if checks < 25:
61 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
62 fails += 1
63 print("\nGATE asset_edit: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
64 get_tree().quit(0 if fails == 0 else 1)
65
66
67func _box(at: Vector3, size: Vector3) -> String:
68 return persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
69 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z],
70 "material_id": 2, "level_id": 0})
71
72
73static func _box_planes(lo: Vector3, hi: Vector3) -> Array:
74 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]
75
76
77func _frame(pd: String) -> Transform3D:
78 return (persist.placements() as Dictionary).get(pd, {}).get("frame", Transform3D())
79
80
81func _live(pd: String) -> bool:
82 var p: Dictionary = (persist.placements() as Dictionary).get(pd, {})
83 return not p.is_empty() and not bool(p["removed"])
84
85
86## The placement's synthetic object dot.
87func _obj_of(pd: String) -> String:
88 for e: Dictionary in (map.get_node("SceneObjects").entries() as Array):
89 if persist.instance_of(String(e["dot"])) == pd:
90 return String(e["dot"])
91 return ""
92
93
94func _digest(ids: Array) -> String:
95 var blobs: Array = []
96 for b: int in ids:
97 var bb := PackedByteArray()
98 for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4:
99 bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array())
100 bb.append(255)
101 blobs.append(bb)
102 blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode())
103 var h := HashingContext.new()
104 h.start(HashingContext.HASH_SHA256)
105 for bb2: PackedByteArray in blobs:
106 h.update(bb2)
107 return h.finish().hex_encode().substr(0, 16)
108
109
110func _synthetic_translates() -> int:
111 var n := 0
112 for r: Dictionary in (persist.records(false) as Array):
113 if String(r["kind"]) == "brush.translate" and String(r.get("brush_key", "")).contains("/A") and not bool(r["undone"]):
114 n += 1
115 return n
116
117
118func _run() -> void:
119 var O0 := Vector3(700.137, 0.31, 699.873)
120 persist.begin_gesture("room")
121 var a := _box(O0, Vector3(2, 2, 2))
122 persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}})
123 var grp := "probe:grp9"
124 persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, 5), O0 + Vector3(1, 1, 6)), "proj": [],
125 "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []})
126 var sgrp := "probe:grp10"
127 persist.record("brush.create", {"planes": _box_planes(O0 + Vector3(0, 0, -20), O0 + Vector3(1, 1, -19)), "proj": [],
128 "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": sgrp, "face_tex": []})
129 persist.end_gesture()
130 persist.refold()
131 var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3)))
132 persist.begin_gesture("cols")
133 var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""})
134 persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + grp, "obj:" + obj]})
135 var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""})
136 persist.record("col.link", {"target": solo, "members": ["g:" + sgrp]})
137 var shelf: String = persist.record("col.create", {"name": "Shelf", "parent": ""})
138 persist.end_gesture()
139 persist.refold()
140 lib.save_collection(kit, "Kit")
141 lib.save_collection(solo, "Solo")
142 var p1: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, 0)))
143 var ps: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(40, 0, -40)))
144 var ids1: PackedInt32Array = map.asset_brush_ids(p1)
145 _ok("setup: two placements with pieces (Kit, Solo of ONE)", ids1.size() >= 3 and map.asset_brush_ids(ps).size() == 1,
146 [ids1.size(), map.asset_brush_ids(ps).size()])
147
148 # ---- E1 ----
149 var so := _obj_of(p1)
150 _ok("E1 every piece maps to the placement", Array(ids1).all(func(b: int) -> bool: return map.placement_of_brush(b) == p1))
151 map.select_object(so)
152 _ok("E1 picking its object selects the PLACEMENT", map.selected_asset() == p1 and (map.selected_objects() as Array).is_empty()
153 and PackedInt32Array(map.get("_sel_brushes")) == ids1, [map.selected_asset(), map.selected_objects()])
154
155 # ---- E2 ----
156 var f0 := _frame(p1)
157 map.translate_selection(Vector3(5, 0, 0))
158 _ok("E2 move: asset.xform +5 x", _frame(p1).origin.is_equal_approx(f0.origin + Vector3(5, 0, 0)), _frame(p1).origin)
159 _ok("E2 ... the selection follows the renamed pieces", map.selected_asset() == p1
160 and PackedInt32Array(map.get("_sel_brushes")) == map.asset_brush_ids(p1) and map.asset_brush_ids(p1).size() == ids1.size())
161
162 # ---- E3 ----
163 var piv: Vector3 = _frame(p1).origin
164 map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, piv)
165 _ok("E3 rotate: the frame turned 90° about y", _frame(p1).basis.is_equal_approx(Basis(Vector3.UP, PI / 2))
166 and _frame(p1).origin.is_equal_approx(piv), _frame(p1))
167
168 # ---- E4 ----
169 map.select_asset(ps)
170 var fs0 := _frame(ps)
171 var stack: Node = map.call("_ensure_ops")
172 var mv: Object = map.get("_move")
173 var started: bool = stack.activate(mv)
174 mv.set_tool_setting("distance", 3)
175 mv.update_preview()
176 mv.set_tool_setting("move", true)
177 var step: float = float(map.grid_units()) * float(map.WORLD_SCALE)
178 _ok("E4 G (MoveOperator) on a ONE-piece placement: asset.xform", started
179 and _frame(ps).origin.distance_to(fs0.origin) > step * 2.5, [_frame(ps).origin - fs0.origin, step])
180 _ok("E4 ... and no brush.translate on a synthetic piece", _synthetic_translates() == 0, _synthetic_translates())
181
182 # ---- E13 ----
183 map.select_asset(p1)
184 var f13 := _frame(p1)
185 var g13 := _digest(Array(map.asset_brush_ids(p1)))
186 map.scale_brushes(Array(map.get("_sel_brushes")), Vector3(2, 2, 2), f13.origin)
187 persist.refold()
188 _ok("E13 scale leaves a placement alone: frame AND pieces", _frame(p1) == f13
189 and _digest(Array(map.asset_brush_ids(p1))) == g13, [g13, _digest(Array(map.asset_brush_ids(p1)))])
190
191 # ---- E14 ----
192 var qs: Array = map.q_sections()
193 _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])))
194
195 # ---- E8 / E9 ----
196 var tr0 := (persist.records(false) as Array).size()
197 _ok("E8 its object cannot be moved alone", not map.move_object(_obj_of(p1), Transform3D.IDENTITY)
198 and (persist.records(false) as Array).size() == tr0)
199 _ok("E9 its parts are read-only (the guard refuses)", not map.guard_shape_part("move", Callable(), map.asset_brush_ids(p1)[0]))
200 map.call("_q_prompt_cancel")
201
202 # ---- E11 ----
203 var ol: Node = map.get("_outliner")
204 var rows: Array = (ol.get("model") as OutlinerModel).rows()
205 var arow: Dictionary = {}
206 var obj_rows := 0
207 for r: Dictionary in rows:
208 if String(r["id"]) == "asset:" + p1:
209 arow = r
210 if String(r.get("object_dot", "")) != "" and persist.instance_of(String(r["object_dot"])) != "":
211 obj_rows += 1
212 _ok("E11 one '▣ Kit' row covering its pieces", String(arow.get("name", "")) == "▣ Kit"
213 and (arow.get("brushes", PackedInt32Array()) as PackedInt32Array).size() == map.asset_brush_ids(p1).size(), arow.get("name", ""))
214 _ok("E11 no rows for its synthetic objects", obj_rows == 0, obj_rows)
215 map.clear_selection()
216 ol.call("_select_in_viewport_rows", arow)
217 _ok("E11 the row selects the placement", map.selected_asset() == p1)
218
219 # ---- E12 ----
220 persist.begin_gesture("link")
221 var ms: Array = CollectionOps.selection_members(map)
222 persist.record("col.link", {"target": shelf, "members": ms})
223 persist.end_gesture()
224 persist.refold()
225 _ok("E12 the selection's member is the placement 'a:<dot>'", ms == ["a:" + p1], ms)
226 var under := false
227 for r2: Dictionary in (ol.get("model") as OutlinerModel).rows():
228 if String(r2["id"]) == "col:%s/a:%s" % [shelf, p1] and String(r2["parent"]) == "col:" + shelf:
229 under = true
230 _ok("E12 its row is listed under the collection", under)
231
232 # ---- E7 ----
233 map.select_asset(p1)
234 var n_before := (persist.placements() as Dictionary).size()
235 map.duplicate_selection(false)
236 stack.cancel()
237 var p2: String = map.selected_asset()
238 _ok("E7 Shift+D: a new placement, same asset and frame, selected", p2 != "" and p2 != p1
239 and (persist.placements() as Dictionary).size() == n_before + 1
240 and String(persist.placements()[p2]["asset"]) == String(persist.placements()[p1]["asset"])
241 and _frame(p2).is_equal_approx(_frame(p1)), [p2])
242 _ok("E7 ... in the same collections", CollectionFold.collections_of(persist.collection_tree(), "a:" + p2).has(shelf))
243
244 # ---- E5 ----
245 map.select_asset(p2)
246 map.delete_selection()
247 _ok("E5 delete: the placement goes (asset.remove)", not _live(p2) and map.asset_brush_ids(p2).is_empty())
248 persist.undo_last()
249 persist.refold()
250 _ok("E5 ... undo brings it back", _live(p2) and not map.asset_brush_ids(p2).is_empty())
251
252 # ---- E6 ----
253 map.delete_objects([_obj_of(p2)])
254 _ok("E6 delete via its object: the placement goes, not the object alone", not _live(p2))
255 persist.undo_last()
256 persist.refold()
257
258 # ---- E10 ----
259 var inst_ids: Array = Array(map.asset_brush_ids(p1))
260 var before := _digest(inst_ids)
261 var n_inst := inst_ids.size()
262 map.select_asset(p1)
263 var made: int = map.make_real_asset(p1)
264 var cols: Dictionary = persist.collection_tree()["cols"]
265 var real_col := ""
266 for c: String in cols:
267 if String(cols[c]["name"]) == "Kit" and c != kit:
268 real_col = c
269 var real_ids := PackedInt32Array()
270 for m: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
271 if m.begins_with("g:"):
272 real_ids.append_array(level.get_group_brush_ids(m.substr(2)) as PackedInt32Array)
273 _ok("E10 Make Real: the placement is gone", made > 0 and not _live(p1), made)
274 _ok("E10 ... a new collection 'Kit' under Shelf holds the real contents", real_col != ""
275 and String(cols[real_col]["parent"]) == shelf and real_ids.size() == n_inst, [real_col, real_ids.size(), n_inst])
276 _ok("E10 ... the SAME geometry (windings digest)", _digest(Array(real_ids)) == before, [_digest(Array(real_ids)), before])
277 var real_shape := ""
278 for m2: String in CollectionFold.members_of(persist.collection_tree(), real_col, true):
279 if m2.begins_with("g:") and (persist.live_shapes() as Dictionary).has(m2.substr(2)):
280 real_shape = m2.substr(2)
281 _ok("E10 ... its box is an ordinary shape (live, in no placement)", real_shape != ""
282 and persist.instance_of(real_shape) == "" and (persist.live_shapes() as Dictionary).has(real_shape), real_shape)
283 persist.undo_last()
284 persist.undo_last()
285 persist.refold()
286 _ok("E10 undo restores the placement", _live(p1) and map.asset_brush_ids(p1).size() == n_inst)
287
288 # ---- E16: a MIXED selection (a placement's pieces + a plain brush) ----
289 var plain: int = (level.get_group_brush_ids(grp) as PackedInt32Array)[0]
290 var pb0: Vector3 = level.get_brush(plain)["aabb_min"]
291 var f16 := _frame(p1)
292 var mix: PackedInt32Array = map.asset_brush_ids(p1)
293 mix.append(plain)
294 map.select_brushes(mix)
295 map.translate_selection(Vector3(0, 0, 3))
296 map.select_brushes(PackedInt32Array(Array(map.asset_brush_ids(p1)) + [level.brush_by_key(String(level.get_brush_key(plain)))]))
297 map.rotate_brushes(Array(map.get("_sel_brushes")), Vector3.UP, 90.0, _frame(p1).origin)
298 persist.refold()
299 _ok("E16 mixed move: the placement by asset.xform, the plain brush by brush.translate",
300 _frame(p1).origin.is_equal_approx(f16.origin + Vector3(0, 0, 3))
301 and not (level.get_brush(level.brush_by_key(String(level.get_brush_key(plain))))["aabb_min"] as Vector3).is_equal_approx(pb0),
302 [_frame(p1).origin - f16.origin])
303
304 # ---- E15: after all of it, NO brush / face record names a placement's synthetic piece ----
305 var bad: Array = []
306 for r: Dictionary in (persist.records(false) as Array):
307 var k := String(r["kind"])
308 if (k.begins_with("brush.") or k.begins_with("face.")) and String(r.get("brush_key", "")).contains("/A"):
309 bad.append("%s %s" % [k, r["brush_key"]])
310 _ok("E15 no brush / face record names a placement's synthetic piece", bad.is_empty(), bad)
scenes/test/gate_asset_edit.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_asset_edit.gd" id="1"]
4
5[node name="GateAssetEdit" type="Node"]
6script = ExtResource("1")