| … | … | @@ -1328,9 +1328,24 @@ Save as asset is menu-only for now). |
| 1328 | 1328 | on disk after the fold; empty collection refused; the menu entry. 4 mutants killed |
| 1329 | 1329 | (the fetch one after the gate flushed AssetSync's deferred content_ready signals, |
| 1330 | 1330 | which had restored the blob by another path). **DONE 2026-10-19.** |
| 1331 | 4. **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). | |
| 1331 | 4. **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.** | |
| 1334 | 1349 | 5. **Browser**: the bottom panel, tiles, click + Shift+A, drag into the view (the drop |
| 1335 | 1350 | path called directly in the gate, as the outliner's drop_on is). |
| 1336 | 1351 | `gate_asset_browser`. |
| … | … | @@ -118,8 +118,8 @@ func _one_tree(base: Array, cols: Array) -> Array: |
| 118 | 118 | if String(r["parent"]) == "": |
| 119 | 119 | r["parent"] = "node:." # top-level collections: under the Scene |
| 120 | 120 | 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)) | |
| 123 | 123 | if not member_row.has(home): |
| 124 | 124 | member_row[home] = id |
| 125 | 125 | var out: Array = [] |
| … | … | @@ -225,7 +225,11 @@ func _add_object_rows(out: Array) -> void: |
| 225 | 225 | (by_parent[pd] as Array).append(e) |
| 226 | 226 | var order: Array = [] |
| 227 | 227 | _walk_objects(by_parent, "", order, 0) |
| 228 | var pp: Node = map.get("_persist") if map != null else null | |
| 228 | 229 | 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 | |
| 229 | 233 | # A model reads by its NAME ("model · chair"): ten rows that all say "model" |
| 230 | 234 | # cannot be told apart. Other prefabs keep their type. |
| 231 | 235 | var label := String(e["type"]) |
| … | … | @@ -317,11 +321,25 @@ func _build_brush_rows(out: Array) -> void: |
| 317 | 321 | var shapes: Dictionary = {} |
| 318 | 322 | if p != null and is_instance_valid(p) and p.has_method("live_shapes"): |
| 319 | 323 | 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)) | |
| 320 | 336 | if level.has_method("get_group_keys"): |
| 321 | 337 | for gk: String in (level.get_group_keys() as PackedStringArray): |
| 322 | 338 | var members: PackedInt32Array = level.get_group_brush_ids(gk) |
| 323 | 339 | if members.is_empty(): |
| 324 | 340 | 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 | |
| 325 | 343 | for b: int in members: |
| 326 | 344 | claimed[b] = true |
| 327 | 345 | var row := { |
| … | … | @@ -390,12 +408,25 @@ func _add_collection_rows(out: Array) -> void: |
| 390 | 408 | if not seen.has(b): |
| 391 | 409 | seen[b] = true |
| 392 | 410 | 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) | |
| 393 | 416 | out.append({ |
| 394 | 417 | "kind": Kind.COLLECTION, "id": cid, "name": String(c["name"]), |
| 395 | 418 | "parent": "col:" + String(c["parent"]) if String(c["parent"]) != "" else "", |
| 396 | 419 | "brushes": all, "count": all.size(), "node_path": NodePath(), "col_dot": d, |
| 397 | 420 | }) |
| 421 | var pls2: Dictionary = p.placements() if p.has_method("placements") else {} | |
| 398 | 422 | 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 | |
| 399 | 430 | if m.begins_with("obj:"): |
| 400 | 431 | var od := m.substr(4) |
| 401 | 432 | if not obj_by_dot.has(od): |
| … | … | @@ -422,6 +453,12 @@ func _add_collection_rows(out: Array) -> void: |
| 422 | 453 | out.append(row) |
| 423 | 454 | |
| 424 | 455 | |
| 456 | ## A placed asset's row (#120 L4): selecting it selects the placement (`asset_dot`). | |
| 457 | func _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 | ||
| 425 | 462 | ## A shape's type label ("Stairs"), from its resolved state. |
| 426 | 463 | func _shape_label(p: Node, dot: String) -> String: |
| 427 | 464 | var tid := String((p.shape_state(dot) as Dictionary).get("type", "")) |
| … | … | @@ -86,6 +86,9 @@ var _pushed_sig := "" |
| 86 | 86 | func _map_sig() -> String: |
| 87 | 87 | if map == null: |
| 88 | 88 | return "" |
| 89 | var ad := String(map.get("_sel_asset")) if map.get("_sel_asset") is String else "" | |
| 90 | if ad != "": | |
| 91 | return "asset:" + ad | |
| 89 | 92 | var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else "" |
| 90 | 93 | if sd != "": |
| 91 | 94 | return "shape:" + sd |
| … | … | @@ -182,6 +185,19 @@ func _on_map_selection() -> void: |
| 182 | 185 | ## selects). Never the group row: that row covers more than was picked, and Delete on |
| 183 | 186 | ## it would take the rest too. The active brush is selected last, so it is the cursor. |
| 184 | 187 | func _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 | |
| 185 | 201 | var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else "" |
| 186 | 202 | if sd != "": |
| 187 | 203 | var sit: TreeItem = _items.get(_group_id(sd), null) |
| … | … | @@ -934,6 +950,10 @@ func drag_data_for_selection() -> Dictionary: |
| 934 | 950 | var row: Dictionary = r |
| 935 | 951 | var od := String(row.get("object_dot", "")) |
| 936 | 952 | 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 | |
| 937 | 957 | var g := String(row.get("shape_dot", "")) |
| 938 | 958 | if g == "": |
| 939 | 959 | # A member row is "col:<dot>/<member>"; a collection dot has no "/". |
| … | … | @@ -1228,6 +1248,11 @@ func _select_in_viewport_rows(r: Dictionary) -> void: |
| 1228 | 1248 | return |
| 1229 | 1249 | # A ◇ SHAPE ROW selects the shape, so the Shape panel follows — selecting its |
| 1230 | 1250 | # 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 | |
| 1231 | 1256 | var sdot := String(r.get("shape_dot", "")) |
| 1232 | 1257 | if sdot != "" and map.has_method("select_shape"): |
| 1233 | 1258 | map.select_shape(sdot) |
| … | … | @@ -75,6 +75,8 @@ static func write(persist: Node, col: String, name: String = "") -> Dictionary: |
| 75 | 75 | groups[g] = true |
| 76 | 76 | elif m.begins_with("obj:") and objects != null and objects.node_for(m.substr(4)) != null: |
| 77 | 77 | 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)) | |
| 78 | 80 | |
| 79 | 81 | # ---- live cuts: an op comes with a shape target inside, and brings its cutter ---- |
| 80 | 82 | var ops: Array = [] |
| … | … | @@ -34,6 +34,12 @@ static func selection_members(map: Node) -> Array: |
| 34 | 34 | var persist: Node = map.get("_persist") |
| 35 | 35 | for b: Variant in map.get("_sel_brushes"): |
| 36 | 36 | 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 | |
| 37 | 43 | if g == "": |
| 38 | 44 | var key := String(level.get_brush_key(int(b))) |
| 39 | 45 | if key == "": |
| … | … | @@ -587,6 +587,10 @@ func q_sections() -> Array: |
| 587 | 587 | return [cs[0]] if not cs.is_empty() else [] |
| 588 | 588 | if _sel_brushes.is_empty(): |
| 589 | 589 | 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"]]]] | |
| 590 | 594 | var things := {} |
| 591 | 595 | for b: int in _sel_brushes: |
| 592 | 596 | var g := shape_of_brush(b) |
| … | … | @@ -924,6 +928,10 @@ func individual_units(ids: Array) -> Array: |
| 924 | 928 | var pieces: PackedInt32Array = level.get_group_brush_ids(d) |
| 925 | 929 | var bs := _unit_bounds(pieces) |
| 926 | 930 | 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}) | |
| 927 | 935 | for b: int in (sp[1] as Array): |
| 928 | 936 | var one := PackedInt32Array([b]) |
| 929 | 937 | var bb := _unit_bounds(one) |
| … | … | @@ -934,6 +942,8 @@ func individual_units(ids: Array) -> Array: |
| 934 | 942 | |
| 935 | 943 | ## A unit's CURRENT brush ids (re-resolved by its durable name). |
| 936 | 944 | func unit_ids(u: Dictionary) -> PackedInt32Array: |
| 945 | if String(u.get("asset", "")) != "": | |
| 946 | return asset_brush_ids(String(u["asset"])) | |
| 937 | 947 | if String(u["shape"]) != "": |
| 938 | 948 | return level.get_group_brush_ids(String(u["shape"])) |
| 939 | 949 | var b: int = level.brush_by_key(String(u["key"])) |
| … | … | @@ -1193,6 +1203,8 @@ func asset_collection() -> String: |
| 1193 | 1203 | # The selection's members, READ ONLY (CollectionOps.selection_members regroups loose |
| 1194 | 1204 | # brushes — a write — and asking which collection must not write). |
| 1195 | 1205 | var ms: Array = [] |
| 1206 | if _sel_asset != "": | |
| 1207 | ms.append("a:" + _sel_asset) | |
| 1196 | 1208 | if _sel_shape != "": |
| 1197 | 1209 | ms.append("g:" + _sel_shape) |
| 1198 | 1210 | for d: Variant in selected_objects(): |
| … | … | @@ -1299,6 +1311,9 @@ func _selection_member_names() -> Array: |
| 1299 | 1311 | var out: Array = [] |
| 1300 | 1312 | for d: String in selected_objects(): |
| 1301 | 1313 | out.append("obj:" + d) |
| 1314 | if _sel_asset != "": | |
| 1315 | out.append("a:" + _sel_asset) # a placed asset (#120 L4) | |
| 1316 | return out | |
| 1302 | 1317 | if _sel_shape != "": |
| 1303 | 1318 | out.append("g:" + _sel_shape) |
| 1304 | 1319 | for b: int in _sel_brushes: |
| … | … | @@ -1616,6 +1631,9 @@ func box_select(r: Rect2, add: bool) -> int: |
| 1616 | 1631 | |
| 1617 | 1632 | |
| 1618 | 1633 | func _q_make_real() -> void: |
| 1634 | if _sel_asset != "": | |
| 1635 | make_real_asset(_sel_asset) # a placed asset (#120 L4 §15.3) | |
| 1636 | return | |
| 1619 | 1637 | var s := stack_subject() |
| 1620 | 1638 | if s != "": |
| 1621 | 1639 | make_real(s) |
| … | … | @@ -1670,6 +1688,17 @@ var _pending_part_edit := Callable() |
| 1670 | 1688 | ## prompt is open, and Make Real will run `resume` on the same part of the plain brush. |
| 1671 | 1689 | func guard_shape_part(what: String, resume: Callable, brush: int = -2) -> bool: |
| 1672 | 1690 | 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 | |
| 1673 | 1702 | var sd := shape_of_brush(b) if b >= 0 else "" |
| 1674 | 1703 | if sd == "": |
| 1675 | 1704 | return true |
| … | … | @@ -1897,6 +1926,9 @@ func apply_texture_to_selection() -> int: |
| 1897 | 1926 | return 0 |
| 1898 | 1927 | # A PARAMETRIC SHAPE is textured as a whole (§13.3): one shape.set {tex}. A face.tex |
| 1899 | 1928 | # 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 | |
| 1900 | 1932 | if _sel_shape != "": |
| 1901 | 1933 | return set_shape_tex(_sel_shape, current_face_tex()) |
| 1902 | 1934 | var tex_id: String = current_face_tex()["tex"] |
| … | … | @@ -3297,6 +3329,9 @@ func move_object(dot: String, xform: Transform3D) -> bool: |
| 3297 | 3329 | if dot == "": |
| 3298 | 3330 | return false |
| 3299 | 3331 | _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 | |
| 3300 | 3335 | var op: String = _persist.reserve_dot() |
| 3301 | 3336 | if not _persist.record_at(op, "object.transform", { |
| 3302 | 3337 | "target": dot, "xform": SceneObjects.xform_to(xform)}): |
| … | … | @@ -3481,22 +3516,31 @@ func delete_brushes(ids: Array) -> int: |
| 3481 | 3516 | var shape_targets := {} |
| 3482 | 3517 | var n := 0 |
| 3483 | 3518 | _persist.begin_gesture("Delete") |
| 3519 | var asset_targets := {} | |
| 3484 | 3520 | for id: Variant in ids: |
| 3485 | 3521 | var g: String = String(level.get_brush_group(int(id))) |
| 3486 | 3522 | if shapes.has(g): |
| 3487 | 3523 | shape_targets[g] = true |
| 3488 | 3524 | 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 | |
| 3489 | 3530 | var bkey: String = String(level.get_brush_key(int(id))) |
| 3490 | 3531 | if bkey != "" and _persist.record("brush.remove", {"brush_key": bkey}) != "": |
| 3491 | 3532 | n += 1 |
| 3492 | 3533 | for g: String in shape_targets: |
| 3493 | 3534 | if _persist.remove_shape(g) != "": |
| 3494 | 3535 | n += 1 |
| 3536 | for pa2: String in asset_targets: | |
| 3537 | if _persist.record("asset.remove", {"target": pa2}) != "": | |
| 3538 | n += 1 | |
| 3495 | 3539 | _persist.end_gesture() |
| 3496 | 3540 | if n > 0: |
| 3497 | 3541 | _clear_face_selection() |
| 3498 | 3542 | _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) | |
| 3500 | 3544 | return n |
| 3501 | 3545 | |
| 3502 | 3546 | |
| … | … | @@ -3508,6 +3552,25 @@ func delete_brushes(ids: Array) -> int: |
| 3508 | 3552 | ## Hierarchy) skips the move-up: the children go too. One gesture either way. |
| 3509 | 3553 | func delete_objects(dots: Array, hierarchy: bool = false) -> int: |
| 3510 | 3554 | _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() | |
| 3511 | 3574 | var n := 0 |
| 3512 | 3575 | _persist.begin_gesture("Delete %d object%s" % [dots.size(), "" if dots.size() == 1 else "s"]) |
| 3513 | 3576 | if not hierarchy and _objects != null and is_instance_valid(_objects): |
| … | … | @@ -3550,6 +3613,8 @@ func move_objects(moves: Dictionary, label: String = "") -> int: |
| 3550 | 3613 | var n := 0 |
| 3551 | 3614 | _persist.begin_gesture(label) |
| 3552 | 3615 | for d: String in moves: |
| 3616 | if String(_persist.instance_of(d)) != "": | |
| 3617 | continue # a placed asset's object (#120 L4): read-only | |
| 3553 | 3618 | var local: Transform3D = _objects.local_for(_objects.parent_of(d), moves[d] as Transform3D) \ |
| 3554 | 3619 | if _objects != null and is_instance_valid(_objects) else moves[d] as Transform3D |
| 3555 | 3620 | if _persist.record_at(_persist.reserve_dot(), "object.transform", |
| … | … | @@ -3676,6 +3741,10 @@ func _emit_selection() -> void: |
| 3676 | 3741 | ## replacing it — the brush rule. The ACTIVE object (_sel_object, what the Object panel |
| 3677 | 3742 | ## and the Outliner's fields edit) is the last one added; G / R / Delete act on all. |
| 3678 | 3743 | func 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 | |
| 3679 | 3748 | if add and dot != "": |
| 3680 | 3749 | var at := _sel_objects.find(dot) |
| 3681 | 3750 | if at >= 0: |
| … | … | @@ -3692,6 +3761,21 @@ func select_object(dot: String, add: bool = false) -> void: |
| 3692 | 3761 | ## Replace the object selection with `dots` (the Outliner's multi-row pick); `active` |
| 3693 | 3762 | ## becomes the active one when it is among them. |
| 3694 | 3763 | func 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 | |
| 3695 | 3779 | _sel_objects.clear() |
| 3696 | 3780 | for d: Variant in dots: |
| 3697 | 3781 | if String(d) != "" and not _sel_objects.has(String(d)): |
| … | … | @@ -3708,6 +3792,7 @@ func selected_objects() -> Array: |
| 3708 | 3792 | |
| 3709 | 3793 | func _select_objects_common() -> void: |
| 3710 | 3794 | _sel_shape = "" |
| 3795 | _sel_asset = "" | |
| 3711 | 3796 | _sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else "" |
| 3712 | 3797 | _announce_selection() |
| 3713 | 3798 | _sel_brushes.clear() |
| … | … | @@ -5479,6 +5564,18 @@ func clear_selection() -> void: |
| 5479 | 5564 | |
| 5480 | 5565 | ## After a fold: the selected shape's pieces have new ids (or it is gone). |
| 5481 | 5566 | func _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 | |
| 5482 | 5579 | if _sel_shape == "": |
| 5483 | 5580 | return |
| 5484 | 5581 | var dot := _sel_shape |
| … | … | @@ -5492,17 +5589,169 @@ func _reselect_shape() -> void: |
| 5492 | 5589 | _sel_shape = dot |
| 5493 | 5590 | |
| 5494 | 5591 | |
| 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.*). | |
| 5496 | 5595 | func _split_shapes(ids: Array) -> Array: |
| 5497 | 5596 | var shapes := {} |
| 5498 | 5597 | var rest: Array = [] |
| 5598 | var assets := {} | |
| 5499 | 5599 | for b: Variant in ids: |
| 5600 | var pa := placement_of_brush(int(b)) | |
| 5601 | if pa != "": | |
| 5602 | assets[pa] = true | |
| 5603 | continue | |
| 5500 | 5604 | var d := shape_of_brush(int(b)) |
| 5501 | 5605 | if d != "": |
| 5502 | 5606 | shapes[d] = true |
| 5503 | 5607 | else: |
| 5504 | 5608 | 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 | ||
| 5619 | var _sel_asset := "" | |
| 5620 | ||
| 5621 | ||
| 5622 | ## The placement a brush is a piece of, or "". | |
| 5623 | func 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. | |
| 5632 | func 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 | ||
| 5644 | func selected_asset() -> String: | |
| 5645 | return _sel_asset | |
| 5646 | ||
| 5647 | ||
| 5648 | func 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. | |
| 5655 | func _record_asset_frame(pdot: String, frame: Transform3D) -> bool: | |
| 5656 | return _persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)}) != "" | |
| 5657 | ||
| 5658 | ||
| 5659 | func _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. | |
| 5665 | func 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. | |
| 5683 | func 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 | |
| 5506 | 5755 | |
| 5507 | 5756 | |
| 5508 | 5757 | ## Write a shape's new frame (and size). Refolds; returns true if recorded. |
| … | … | @@ -6166,6 +6415,7 @@ func make_real(dot: String) -> int: |
| 6166 | 6415 | |
| 6167 | 6416 | func select_brushes(ids: PackedInt32Array) -> void: |
| 6168 | 6417 | _sel_shape = "" |
| 6418 | _sel_asset = "" | |
| 6169 | 6419 | _sel_object = "" # one selection at a time; see _sel_object |
| 6170 | 6420 | _sel_objects.clear() |
| 6171 | 6421 | _announce_selection() |
| … | … | @@ -6217,6 +6467,18 @@ func rotate_brushes(ids: Array, axis: Vector3, deg: float, pivot: Vector3) -> in |
| 6217 | 6467 | _ensure_persistence() |
| 6218 | 6468 | # A SHAPE turns its FRAME about the pivot (shape.xform); its pieces regenerate. |
| 6219 | 6469 | 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 | |
| 6220 | 6482 | if not (sp[0] as Dictionary).is_empty(): |
| 6221 | 6483 | var r := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO) |
| 6222 | 6484 | var about := Transform3D(Basis.IDENTITY, pivot) * r * Transform3D(Basis.IDENTITY, -pivot) |
| … | … | @@ -6256,6 +6518,15 @@ func translate_selection(delta: Vector3) -> int: |
| 6256 | 6518 | # A SHAPE moves its FRAME (shape.xform); its pieces regenerate where it now is. |
| 6257 | 6519 | var ids: Array = _sel_brushes.duplicate() |
| 6258 | 6520 | 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 | |
| 6259 | 6530 | if not (sp[0] as Dictionary).is_empty(): |
| 6260 | 6531 | var ns := 0 |
| 6261 | 6532 | for d: String in (sp[0] as Dictionary): |
| … | … | @@ -6314,6 +6585,8 @@ func scale_shapes_local(ids: Array, f: Vector3, pivot: Vector3) -> int: |
| 6314 | 6585 | return 0 |
| 6315 | 6586 | _ensure_persistence() |
| 6316 | 6587 | 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") | |
| 6317 | 6590 | var ns := 0 |
| 6318 | 6591 | for d: String in (sp[0] as Dictionary): |
| 6319 | 6592 | var st: Dictionary = _persist.shape_state(d) |
| … | … | @@ -6351,6 +6624,9 @@ func scale_brushes(ids: Array, factor: Vector3, pivot: Vector3) -> int: |
| 6351 | 6624 | # extent scales by the factor of the world axis it lies on. A turned frame cannot |
| 6352 | 6625 | # take a non-uniform world scale as a size; it is refused, not approximated. |
| 6353 | 6626 | 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 | |
| 6354 | 6630 | if not (sp[0] as Dictionary).is_empty(): |
| 6355 | 6631 | var ns := 0 |
| 6356 | 6632 | for d: String in (sp[0] as Dictionary): |
| … | … | @@ -6463,6 +6739,7 @@ func _selection_centre() -> Vector3: |
| 6463 | 6739 | |
| 6464 | 6740 | func _clear_face_selection() -> void: |
| 6465 | 6741 | _sel_shape = "" |
| 6742 | _sel_asset = "" | |
| 6466 | 6743 | _sel_object = "" |
| 6467 | 6744 | _sel_objects.clear() |
| 6468 | 6745 | _announce_selection() |
| … | … | @@ -6485,6 +6762,7 @@ func _note_selected(brush: int, add: bool) -> void: |
| 6485 | 6762 | # object. It did not — select an object in the Outliner, click a wall, press G, |
| 6486 | 6763 | # and the OBJECT moved, because MoveOperator checks _sel_object first. |
| 6487 | 6764 | _sel_shape = "" |
| 6765 | _sel_asset = "" | |
| 6488 | 6766 | _sel_object = "" |
| 6489 | 6767 | _sel_objects.clear() |
| 6490 | 6768 | _announce_selection() |
| … | … | @@ -6805,6 +7083,16 @@ func _pick_element(add: bool = false, at: Variant = null) -> void: |
| 6805 | 7083 | # every new box is a shape, so element selection looked broken). The pieces are still |
| 6806 | 7084 | # read-only (docs/parametric-shapes-design.md §7): an EDIT of one asks first |
| 6807 | 7085 | # (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 | |
| 6808 | 7096 | var sd := shape_of_brush(brush) |
| 6809 | 7097 | if sd != "" and _sel_mode == "brush": |
| 6810 | 7098 | select_shape(sd) |
| … | … | @@ -7941,6 +8229,19 @@ func duplicate_selection(linked: bool = false) -> bool: |
| 7941 | 8229 | return false |
| 7942 | 8230 | select_objects(made, made[-1]) |
| 7943 | 8231 | 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]) | |
| 7944 | 8245 | elif _sel_shape != "": |
| 7945 | 8246 | var src := _sel_shape |
| 7946 | 8247 | _persist.begin_gesture("Duplicate") |
| … | … | @@ -700,7 +700,11 @@ func _confirm_main(d: Vector3) -> bool: |
| 700 | 700 | # while the ring walked off — the terrain then kept a passage where the arch no |
| 701 | 701 | # longer was. Every selected brush translates, one rebuild at the end. |
| 702 | 702 | 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"): | |
| 704 | 708 | # The map owns the journal write. The organizer's numeric fields move a |
| 705 | 709 | # group through the same call, and two writers of one journal path is how |
| 706 | 710 | # they come to disagree about what a record means. |
| … | … | @@ -0,0 +1,310 @@ |
| 1 | extends 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 | ||
| 27 | const AssetLibrary := preload("res://scenes/maps/asset_library.gd") | |
| 28 | const CollectionFold := preload("res://scenes/maps/collection_fold.gd") | |
| 29 | ||
| 30 | var fails := 0 | |
| 31 | var checks := 0 | |
| 32 | var map: Node3D | |
| 33 | var persist: Node | |
| 34 | var level: Node | |
| 35 | var lib: Node | |
| 36 | ||
| 37 | ||
| 38 | func _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 | ||
| 45 | func _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 | ||
| 67 | func _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 | ||
| 73 | static 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 | ||
| 77 | func _frame(pd: String) -> Transform3D: | |
| 78 | return (persist.placements() as Dictionary).get(pd, {}).get("frame", Transform3D()) | |
| 79 | ||
| 80 | ||
| 81 | func _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. | |
| 87 | func _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 | ||
| 94 | func _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 | ||
| 110 | func _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 | ||
| 118 | func _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) |
| … | … | @@ -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"] | |
| 6 | script = ExtResource("1") |