| … | … | @@ -1185,22 +1185,32 @@ placement whose blob is in the cache, it emits the blob's records as SYNTHETIC r |
| 1185 | 1185 | (never journalled), so ShapeFold / ModFold / OpFold / CurveFold / the level see ordinary |
| 1186 | 1186 | shapes, curves, cuts and brushes: |
| 1187 | 1187 | |
| 1188 | - Each local dot `L:<n>` becomes `<placement author>/A<n>:<placement seq>` — the shape | |
| 1189 | pieces' naming scheme (§ shape_fold.gd header) one level up: deterministic, the same on | |
| 1190 | every peer, unique per placement, sorting at the placement's create position. | |
| 1191 | - Frames are `placement frame × local frame`; curve points and brush planes are | |
| 1192 | transformed the same way (ShapeFold.transform_planes). The whole result is RIGID | |
| 1188 | - Each local dot `L:<n>` (and each brush object's local GROUP name, which is not a | |
| 1189 | record) becomes `<placement author>/A<nnnn>:<placement seq>`, zero-padded so the order | |
| 1190 | inside a placement is the blob's order (stacks and cuts keep theirs): deterministic, the | |
| 1191 | same on every peer, unique per placement, sorting at the placement's create position. | |
| 1192 | Synthetic records are made by the journal's own parser (`LevelPersistence._parse_as`), | |
| 1193 | so each is exactly what the same payload journalled would be. | |
| 1194 | - `M = frame × translate(−pivot)`: shapes' frames, curve points, a radial modifier's | |
| 1195 | world pivot and top-level objects take M; brush objects are created as captured, then | |
| 1196 | `brush.rotate` (about the origin) + `brush.translate` with tex_lock, so their textures | |
| 1197 | follow through the store's texture lock. The whole result is RIGID | |
| 1193 | 1198 | (no scale in v1: a scaled convex piece stays convex, but cables and curves would |
| 1194 | 1199 | need their radii scaled too — later). |
| 1195 | - `<ver>` of each synthetic shape = max(its local ver, the placement's latest xform/set | |
| 1196 | seq), so moving a placement renames its pieces (BrushCreate restores by name). | |
| 1197 | - Synthetic records carry `inst: <placement dot>`; group names are | |
| 1198 | `<placement dot>/<local group>`. Selection maps any of them back to the placement. | |
| 1199 | - Nested `asset.place` inside a blob expands recursively (frames compose). | |
| 1200 | - **Blob not here yet** (a peer has not fetched it): the placement folds to ONE clearance | |
| 1201 | -style wire box of `bounds` (no collision, csg4 ignores it), `ModelCache`-style fetch | |
| 1202 | via `ContentOverlay.request(oid)`, and a refold when it arrives. Never an empty hole | |
| 1203 | that looks like a delete. | |
| 1200 | - A moved placement renames its shapes' pieces (BrushCreate restores by name): each | |
| 1201 | shape also gets a same-frame `shape.xform` at the placement's latest xform/set seq — | |
| 1202 | ShapeFold's own `<ver>` rule. | |
| 1203 | - Synthetic records carry `inst: <placement dot>`; `LevelPersistence.instance_of(dot or | |
| 1204 | group)` maps any synthetic shape, brush group or object back to its placement. | |
| 1205 | - `asset.*` records break the tail fold (they must be expanded, never handed to DCLevel's | |
| 1206 | tail, where they would be skipped). | |
| 1207 | - Nested placements: deferred to step 4 (a blob holds none until placements can be | |
| 1208 | collection members). | |
| 1209 | - **Blob not here yet** (`LevelPersistence.put_asset_blob(oid, bytes)` not yet called — | |
| 1210 | it checks the hash and decodes, then re-folds): the placement folds to ONE wire box | |
| 1211 | (cutter: drawn, clips nothing, not in the shell), a 1 m cube at its frame until the | |
| 1212 | library knows its bounds; `missing_assets()` lists the oids to fetch. Never an empty | |
| 1213 | hole that looks like a delete. | |
| 1204 | 1214 | - Followed curves: an instance's curves are inside it, so a curve edit there is not |
| 1205 | 1215 | possible (read-only); no new follow rules. |
| 1206 | 1216 | |
| … | … | @@ -1291,11 +1301,15 @@ Save as asset is menu-only for now). |
| 1291 | 1301 | cutter and its cut are left out and reported; no room dot survives, every reference |
| 1292 | 1302 | local; bounds / pivot; decode refuses Objects, other kinds, room dots, versions, |
| 1293 | 1303 | garbage. 5 mutants killed. **DONE 2026-10-19.** |
| 1294 | 2. **AssetFold**: `asset.*` records, the expansion, nesting, the missing-blob box. | |
| 1295 | `gate_asset_fold`: a collection saved and placed at its own pivot folds to the SAME | |
| 1296 | geometry digest as the original (the oracle is the room's own fold — independent); | |
| 1297 | two placements, one moved; a nested asset; a placement re-pointed (`asset.set`); undo | |
| 1298 | of each; a missing blob → the wire box, then the real pieces when it arrives. | |
| 1304 | 2. **AssetFold**: `asset.*` records, the expansion, the missing-blob box. | |
| 1305 | `gate_asset_fold` (21): placed at its own pivot = the originals' face windings | |
| 1306 | bit-identical (the room's own fold is the oracle); moved / turned = the originals' | |
| 1307 | boxes moved / turned; renamed on move; a second placement; undo of a turn and of a | |
| 1308 | remove; missing blob → wire box → pieces; refused blobs; the instance's object and | |
| 1309 | wire cutter; a placement folded with no live cut in the room (tail). 6 mutants: 4 | |
| 1310 | killed, 1 equivalent (two-step frame: exact by Sterbenz), the tail one killed after F11 | |
| 1311 | was added (a live cut's OP_SEQ_BASE hid it). `asset.set` re-pointing is exercised in | |
| 1312 | step 3 (Update placements). **DONE 2026-10-19.** | |
| 1299 | 1313 | 3. **Libraries + Save as asset**: room (ContentOverlay + AssetSync kind `asset`, the |
| 1300 | 1314 | extra field) and personal (user://assets). `gate_asset_library`: save → listed in |
| 1301 | 1315 | both; re-save → new oid, YOUR placements follow (one undo restores them), OTHERS' |
| … | … | @@ -0,0 +1,256 @@ |
| 1 | extends RefCounted | |
| 2 | ## PLACED ASSETS IN THE FOLD (#120 L4 §15.2): asset.* journal records -> the SYNTHETIC | |
| 3 | ## curve / shape / mod / op / brush / object records their blobs hold, so ShapeFold, | |
| 4 | ## ModFold, OpFold, CurveFold, DCLevel and SceneObjects see ordinary things. Pure and | |
| 5 | ## deterministic, like the other folds; runs FIRST in LevelPersistence.refold. | |
| 6 | ## | |
| 7 | ## asset.place {asset: oid, name, frame} the placement's dot is its identity | |
| 8 | ## asset.xform {target, frame} the latest wins | |
| 9 | ## asset.set {target, asset: oid} another version of the asset (latest wins) | |
| 10 | ## asset.remove {target} gone (undo brings it back) | |
| 11 | ## | |
| 12 | ## THE TRANSFORM: M = frame x translate(-pivot). A placement at its own pivot is M = identity | |
| 13 | ## exactly, and folds to the very planes the collection had (the gate's oracle). (Applying | |
| 14 | ## translate(-pivot) and the frame as two steps is exact there too, for any coordinates | |
| 15 | ## within a factor of 2 of the pivot — x - p is then exact (Sterbenz) — so the gate cannot | |
| 16 | ## and need not tell the two apart.) Shapes' frames, curve points, a radial modifier's world pivot and top-level | |
| 17 | ## objects take M directly; brush objects are created as captured and then turned and moved | |
| 18 | ## by brush.rotate / brush.translate records with tex_lock, so their textures follow through | |
| 19 | ## the store's own texture lock (asset_doc.gd header). No scale (§15.2). | |
| 20 | ## | |
| 21 | ## THE NAMES: local dot "L:<n>" -> "<placement author>/A<nnnn>:<placement seq>", zero-padded | |
| 22 | ## so the (seq, author) order inside a placement is the blob's order (modifier stacks and | |
| 23 | ## cuts keep their order). A shape's <ver> must move with the placement (BrushCreate | |
| 24 | ## restores by name): when a later asset.xform / asset.set is live, each shape also gets a | |
| 25 | ## same-frame shape.xform at that seq, which is how ShapeFold bumps <ver> for any shape. | |
| 26 | ## | |
| 27 | ## A BLOB NOT HERE YET folds to ONE wire box of its bounds (cutter: drawn, clips nothing, | |
| 28 | ## not in the shell), named "<placement author>/A0000:<seq>" — never an empty hole that | |
| 29 | ## reads as a delete. `missing` lists the oids to fetch. | |
| 30 | ||
| 31 | const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd") | |
| 32 | ||
| 33 | const KINDS := ["asset.place", "asset.xform", "asset.set", "asset.remove"] | |
| 34 | ||
| 35 | ||
| 36 | static func handles(kind: String) -> bool: | |
| 37 | return KINDS.has(kind) | |
| 38 | ||
| 39 | ||
| 40 | static func _before(a: Dictionary, b: Dictionary) -> bool: | |
| 41 | if int(a["seq"]) != int(b["seq"]): | |
| 42 | return int(a["seq"]) < int(b["seq"]) | |
| 43 | return String(a["author"]) < String(b["author"]) | |
| 44 | ||
| 45 | ||
| 46 | ## Every placement's final state: {dot: {dot, author, seq, asset, name, frame, ver, removed}}. | |
| 47 | ## `ver` = the highest seq among its live xform / set records (its create's seq without). | |
| 48 | static func resolve_states(records: Array) -> Dictionary: | |
| 49 | var live: Array = [] | |
| 50 | for r: Dictionary in records: | |
| 51 | if handles(String(r.get("kind", ""))) and not bool(r.get("undone", false)): | |
| 52 | live.append(r) | |
| 53 | live.sort_custom(_before) | |
| 54 | var states := {} | |
| 55 | for r: Dictionary in live: | |
| 56 | var kind := String(r["kind"]) | |
| 57 | if kind == "asset.place": | |
| 58 | var dot := "%s:%d" % [String(r["author"]), int(r["seq"])] | |
| 59 | states[dot] = {"dot": dot, "author": String(r["author"]), "seq": int(r["seq"]), | |
| 60 | "asset": String(r.get("asset", "")), "name": String(r.get("name", "")), | |
| 61 | "frame": r.get("frame", Transform3D.IDENTITY), "ver": int(r["seq"]), "removed": false} | |
| 62 | continue | |
| 63 | var t := String(r.get("target", "")) | |
| 64 | if not states.has(t): | |
| 65 | continue | |
| 66 | var s: Dictionary = states[t] | |
| 67 | match kind: | |
| 68 | "asset.xform": | |
| 69 | s["frame"] = r.get("frame", s["frame"]) | |
| 70 | s["ver"] = maxi(int(s["ver"]), int(r["seq"])) | |
| 71 | "asset.set": | |
| 72 | if String(r.get("asset", "")) != "": | |
| 73 | s["asset"] = String(r["asset"]) | |
| 74 | s["ver"] = maxi(int(s["ver"]), int(r["seq"])) | |
| 75 | "asset.remove": | |
| 76 | s["removed"] = true | |
| 77 | return states | |
| 78 | ||
| 79 | ||
| 80 | ## The synthetic records for every live placement. `docs` = oid -> decoded AssetDoc; | |
| 81 | ## `parse` = func(author: String, seq: int, payload: Dictionary) -> Dictionary, the journal's | |
| 82 | ## own record parser (LevelPersistence._parse_as), so a synthetic record is exactly what the | |
| 83 | ## same payload journalled would be. | |
| 84 | ## {records: [parsed records, each with inst = the placement dot], missing: [oid…], | |
| 85 | ## inst: {synthetic dot / group -> placement dot}} | |
| 86 | static func expand(records: Array, docs: Dictionary, parse: Callable) -> Dictionary: | |
| 87 | var out: Array = [] | |
| 88 | var missing := {} | |
| 89 | var inst := {} | |
| 90 | var states := resolve_states(records) | |
| 91 | var order: Array = states.values() | |
| 92 | order.sort_custom(_before) | |
| 93 | for p: Dictionary in order: | |
| 94 | if bool(p["removed"]): | |
| 95 | continue | |
| 96 | var pdot := String(p["dot"]) | |
| 97 | var doc: Dictionary = docs.get(String(p["asset"]), {}) | |
| 98 | if doc.is_empty(): | |
| 99 | missing[String(p["asset"])] = true | |
| 100 | var box := _missing_box(p, parse) | |
| 101 | if not box.is_empty(): | |
| 102 | out.append(box) | |
| 103 | inst[pdot] = pdot # the box's group is the placement's dot | |
| 104 | continue | |
| 105 | _expand_one(p, doc, parse, out, inst) | |
| 106 | var ms: Array = missing.keys() | |
| 107 | ms.sort() | |
| 108 | return {"records": out, "missing": ms, "inst": inst} | |
| 109 | ||
| 110 | ||
| 111 | ## M = frame x translate(-pivot), composed so that frame = translate(pivot) gives IDENTITY. | |
| 112 | static func placement_xform(frame: Transform3D, pivot: Vector3) -> Transform3D: | |
| 113 | return Transform3D(frame.basis, frame.origin - frame.basis * pivot) | |
| 114 | ||
| 115 | ||
| 116 | static func _expand_one(p: Dictionary, doc: Dictionary, parse: Callable, out: Array, inst: Dictionary) -> void: | |
| 117 | var pa := String(p["author"]) | |
| 118 | var ps := int(p["seq"]) | |
| 119 | var pdot := String(p["dot"]) | |
| 120 | var m := placement_xform(p["frame"], doc["pivot"]) | |
| 121 | var moved: bool = int(p["ver"]) > ps | |
| 122 | var names := {} # "L:<n>" -> synthetic author | |
| 123 | for r: Dictionary in (doc["records"] as Array): | |
| 124 | var n := int(String(r["dot"]).substr(2)) | |
| 125 | names[String(r["dot"])] = "%s/A%04d" % [pa, n] | |
| 126 | # A brush object's GROUP is a local name its brush.create records share, not a | |
| 127 | # record of its own: it needs a name too ("" would merge it with every ungrouped | |
| 128 | # brush in the level). | |
| 129 | var lg := String(r.get("group", "")) if String(r["kind"]) == "brush.create" else "" | |
| 130 | if lg.begins_with("L:") and not names.has(lg): | |
| 131 | names[lg] = "%s/A%04d" % [pa, int(lg.substr(2))] | |
| 132 | var dot_of := func(local: String) -> String: | |
| 133 | return "%s:%d" % [names[local], ps] if names.has(local) else "" | |
| 134 | var emit := func(author: String, seq: int, payload: Dictionary) -> void: | |
| 135 | var rec: Dictionary = parse.call(author, seq, payload) | |
| 136 | if not rec.is_empty(): | |
| 137 | rec["inst"] = pdot | |
| 138 | out.append(rec) | |
| 139 | for r: Dictionary in (doc["records"] as Array): | |
| 140 | var au: String = names[String(r["dot"])] | |
| 141 | var kind := String(r["kind"]) | |
| 142 | var pl: Dictionary = r.duplicate(true) | |
| 143 | pl.erase("dot") | |
| 144 | match kind: | |
| 145 | "curve.create": | |
| 146 | var pts: Array = [] | |
| 147 | for q: Dictionary in (pl["points"] as Array): | |
| 148 | var q2 := q.duplicate(true) | |
| 149 | q2["p"] = _arr(m * _v3(q.get("p", [0, 0, 0]))) | |
| 150 | for hk: String in ["h_in", "h_out", "n"]: | |
| 151 | if q.has(hk): | |
| 152 | q2[hk] = _arr(m.basis * _v3(q[hk])) | |
| 153 | pts.append(q2) | |
| 154 | pl["points"] = pts | |
| 155 | emit.call(au, ps, pl) | |
| 156 | "shape.create": | |
| 157 | var f := m * SceneObjects.xform_from(pl["frame"]) | |
| 158 | pl["frame"] = SceneObjects.xform_to(f) | |
| 159 | var sp: Dictionary = pl["params"] | |
| 160 | for ck: String in ["curve", "profile_curve"]: | |
| 161 | if sp.has(ck): | |
| 162 | sp[ck] = dot_of.call(String(sp[ck])) | |
| 163 | if String(pl.get("link", "")) != "": | |
| 164 | pl["link"] = dot_of.call(String(pl["link"])) | |
| 165 | else: | |
| 166 | pl.erase("link") | |
| 167 | emit.call(au, ps, pl) | |
| 168 | inst[dot_of.call(String(r["dot"]))] = pdot | |
| 169 | if moved: | |
| 170 | # Same frame, later seq: ShapeFold's <ver> rule renames the pieces. | |
| 171 | emit.call(au, int(p["ver"]), {"kind": "shape.xform", "target": dot_of.call(String(r["dot"])), | |
| 172 | "frame": pl["frame"]}) | |
| 173 | "mod.create": | |
| 174 | var mp: Dictionary = pl["params"] | |
| 175 | if mp.has("curve"): | |
| 176 | mp["curve"] = dot_of.call(String(mp["curve"])) | |
| 177 | if mp.has("pivot"): | |
| 178 | mp["pivot"] = _arr(m * _v3(mp["pivot"])) | |
| 179 | pl["target"] = dot_of.call(String(pl["target"])) | |
| 180 | var on := bool(pl.get("on", true)) | |
| 181 | pl.erase("on") | |
| 182 | emit.call(au, ps, pl) | |
| 183 | if not on: | |
| 184 | emit.call(au + "s", ps, {"kind": "mod.set", "target": dot_of.call(String(r["dot"])), "on": false}) | |
| 185 | "op.create": | |
| 186 | pl["cutter"] = dot_of.call(String(pl["cutter"])) | |
| 187 | var tg: Array = [] | |
| 188 | for t: Variant in (pl["targets"] as Array): | |
| 189 | tg.append(dot_of.call(String(t))) | |
| 190 | pl["targets"] = tg | |
| 191 | var on2 := bool(pl.get("on", true)) | |
| 192 | pl.erase("on") | |
| 193 | emit.call(au, ps, pl) | |
| 194 | if not on2: | |
| 195 | emit.call(au + "s", ps, {"kind": "op.set", "target": dot_of.call(String(r["dot"])), "on": false}) | |
| 196 | "brush.create": | |
| 197 | pl["group"] = dot_of.call(String(pl["group"])) | |
| 198 | inst[String(pl["group"])] = pdot | |
| 199 | emit.call(au, ps, pl) | |
| 200 | var key: String = dot_of.call(String(r["dot"])) | |
| 201 | # Turned about the world origin by M's basis, then moved by M's origin: the | |
| 202 | # same map as M, through the store's texture lock. Identity emits nothing. | |
| 203 | var q3 := m.basis.get_rotation_quaternion() | |
| 204 | var ang := rad_to_deg(q3.get_angle()) | |
| 205 | if absf(ang) > 1.0e-9: | |
| 206 | emit.call(au + "r", ps, {"kind": "brush.rotate", "brush_key": key, "axis": _arr(q3.get_axis()), | |
| 207 | "angle": ang, "pivot": [0, 0, 0], "tex_lock": true}) | |
| 208 | if m.origin != Vector3.ZERO: | |
| 209 | emit.call(au + "t", ps, {"kind": "brush.translate", "brush_key": key, "delta": _arr(m.origin), | |
| 210 | "tex_lock": true}) | |
| 211 | "object.create": | |
| 212 | var par := String(pl.get("parent", "")) | |
| 213 | var x := SceneObjects.xform_from(pl["xform"]) | |
| 214 | var props: Dictionary = pl.get("props", {}) | |
| 215 | emit.call(au, ps, {"kind": "object.create", "type": pl["type"], | |
| 216 | "xform": SceneObjects.xform_to(x if par != "" else m * x)}) | |
| 217 | inst[dot_of.call(String(r["dot"]))] = pdot | |
| 218 | if par != "": | |
| 219 | emit.call(au + "p", ps, {"kind": "object.parent", "target": dot_of.call(String(r["dot"])), | |
| 220 | "parent": dot_of.call(par), "xform": SceneObjects.xform_to(x)}) | |
| 221 | if not props.is_empty(): | |
| 222 | emit.call(au + "q", ps, {"kind": "object.set_prop", "target": dot_of.call(String(r["dot"])), | |
| 223 | "props": props.duplicate(true)}) | |
| 224 | _: | |
| 225 | pass # col.create: organisation, not geometry | |
| 226 | ||
| 227 | ||
| 228 | ## The wire box of a placement whose blob is not here: bounds unknown too, so a 1 m cube at | |
| 229 | ## the frame (the catalogue's bounds arrive with the blob). Cutter: drawn, clips nothing. | |
| 230 | static func _missing_box(p: Dictionary, parse: Callable) -> Dictionary: | |
| 231 | var f: Transform3D = p["frame"] | |
| 232 | var local := PackedFloat32Array([-1, 0, 0, 0.5, 1, 0, 0, 0.5, 0, -1, 0, 0.0, 0, 1, 0, 1.0, | |
| 233 | 0, 0, -1, 0.5, 0, 0, 1, 0.5]) | |
| 234 | var planes := ShapeFold.transform_planes(local, f) | |
| 235 | var pa: Array = [] | |
| 236 | for v: float in planes: | |
| 237 | pa.append(v) | |
| 238 | var rec: Dictionary = parse.call("%s/A0000" % String(p["author"]), int(p["seq"]), {"kind": "brush.create", | |
| 239 | "planes": pa, "proj": [], "material_id": 0, "level_id": 0, "sky": false, "clearance": false, | |
| 240 | "group": String(p["dot"]), "face_tex": []}) | |
| 241 | if not rec.is_empty(): | |
| 242 | rec["cutter"] = true | |
| 243 | rec["inst"] = String(p["dot"]) | |
| 244 | return rec | |
| 245 | ||
| 246 | ||
| 247 | static func _v3(a: Variant) -> Vector3: | |
| 248 | if a is Vector3: | |
| 249 | return a | |
| 250 | if a is Array and (a as Array).size() == 3: | |
| 251 | return Vector3(float(a[0]), float(a[1]), float(a[2])) | |
| 252 | return Vector3.ZERO | |
| 253 | ||
| 254 | ||
| 255 | static func _arr(v: Vector3) -> Array: | |
| 256 | return [v.x, v.y, v.z] |
| … | … | @@ -62,6 +62,10 @@ const ModFold := preload("res://scenes/maps/mod_fold.gd") |
| 62 | 62 | const CollectionFold := preload("res://scenes/maps/collection_fold.gd") |
| 63 | 63 | ## Curves (#120 F5): no brushes either, so folded apart from the level too. |
| 64 | 64 | const CurveFold := preload("res://scenes/maps/curves/curve_fold.gd") |
| 65 | ## Placed assets (#120 L4 §15.2): asset.* records expand FIRST into synthetic records of | |
| 66 | ## every other kind, from the blobs put_asset_blob holds. | |
| 67 | const AssetFold := preload("res://scenes/maps/asset_fold.gd") | |
| 68 | const AssetDoc := preload("res://scenes/maps/asset_doc.gd") | |
| 65 | 69 | |
| 66 | 70 | const PREFIX := "world:level:" |
| 67 | 71 | const OP_INFIX := ":op:" |
| … | … | @@ -296,6 +300,9 @@ const KIND_LABELS := { |
| 296 | 300 | "curve.create": "Add curve", "curve.set": "Edit curve", "curve.remove": "Delete curve", |
| 297 | 301 | # Modifiers (#120 F2). |
| 298 | 302 | "mod.create": "Add modifier", "mod.set": "Edit modifier", "mod.remove": "Remove modifier", |
| 303 | # Placed assets (#120 L4). | |
| 304 | "asset.place": "Place asset", "asset.xform": "Move asset", "asset.set": "Update asset", | |
| 305 | "asset.remove": "Delete asset", | |
| 299 | 306 | } |
| 300 | 307 | |
| 301 | 308 | |
| … | … | @@ -758,8 +765,12 @@ func refold() -> Dictionary: |
| 758 | 765 | var op_recs: Array = [] |
| 759 | 766 | var mod_recs: Array = [] |
| 760 | 767 | var curve_recs: Array = [] |
| 768 | var asset_recs: Array = [] | |
| 761 | 769 | for r: Dictionary in records(false): |
| 762 | 770 | var rk := String(r.get("kind", "")) |
| 771 | if AssetFold.handles(rk): | |
| 772 | asset_recs.append(r) | |
| 773 | continue | |
| 763 | 774 | if CurveFold.handles(rk): |
| 764 | 775 | curve_recs.append(r) # no brushes, but a follow-curve reads them |
| 765 | 776 | continue |
| … | … | @@ -773,6 +784,28 @@ func refold() -> Dictionary: |
| 773 | 784 | mod_recs.append(r) |
| 774 | 785 | else: |
| 775 | 786 | brush_recs.append(r) |
| 787 | # PLACED ASSETS (#120 L4 §15.2) first: each live placement's blob becomes | |
| 788 | # synthetic records of every kind, sorted into the same lists as journalled | |
| 789 | # ones — so everything below treats an instance as ordinary shapes, curves, | |
| 790 | # cuts and brushes. Its objects go to the objects half (_asset_objects). | |
| 791 | var ex: Dictionary = AssetFold.expand(asset_recs, _asset_docs, _parse_as) | |
| 792 | _asset_missing = ex["missing"] | |
| 793 | _asset_inst = ex["inst"] | |
| 794 | _asset_objects = [] | |
| 795 | for sr: Dictionary in (ex["records"] as Array): | |
| 796 | var sk := String(sr["kind"]) | |
| 797 | if CurveFold.handles(sk): | |
| 798 | curve_recs.append(sr) | |
| 799 | elif ShapeFold.handles(sk): | |
| 800 | shape_recs.append(sr) | |
| 801 | elif OpFold.handles(sk): | |
| 802 | op_recs.append(sr) | |
| 803 | elif ModFold.handles(sk): | |
| 804 | mod_recs.append(sr) | |
| 805 | elif SceneObjects.handles(sk): | |
| 806 | _asset_objects.append(sr) | |
| 807 | else: | |
| 808 | brush_recs.append(sr) | |
| 776 | 809 | # PARAMETRIC SHAPES become ordinary brush.create records here, named |
| 777 | 810 | # "<author>/<i>:<seq>" at the shape's own seq, so DCLevel folds them in order with |
| 778 | 811 | # everything else. They never reach DCLevel as shape.* (it would count them skipped). |
| … | … | @@ -820,7 +853,7 @@ func refold() -> Dictionary: |
| 820 | 853 | else: |
| 821 | 854 | _last_fold = {"applied": 0, "skipped": 0, "undone": 0, "records": 0} |
| 822 | 855 | if has_objects: |
| 823 | var os: Dictionary = objects.apply_journal(_object_records()) | |
| 856 | var os: Dictionary = objects.apply_journal(_object_records() + _asset_objects) | |
| 824 | 857 | # Merged so one `folded` payload still describes the whole journal, and |
| 825 | 858 | # reported separately so a skipped OBJECT (an unknown prefab id — the |
| 826 | 859 | # version-skew case) is not read as geometry disagreeing. |
| … | … | @@ -867,6 +900,57 @@ var _shape_states: Dictionary = {} |
| 867 | 900 | var _live_shapes: Dictionary = {} |
| 868 | 901 | |
| 869 | 902 | |
| 903 | ## ---- PLACED ASSETS (#120 L4 §15.2) ------------------------------------------------ | |
| 904 | ||
| 905 | var _asset_docs: Dictionary = {} # oid -> decoded AssetDoc (put_asset_blob) | |
| 906 | var _asset_missing: Array = [] # oids the last brush fold wanted and did not have | |
| 907 | var _asset_inst: Dictionary = {} # synthetic dot / group -> placement dot | |
| 908 | var _asset_objects: Array = [] # the last brush fold's synthetic object records | |
| 909 | ||
| 910 | ||
| 911 | ## Hold an asset blob for the fold. Refused unless its bytes hash to `oid` and decode | |
| 912 | ## (AssetDoc.decode trusts nothing). A new one re-folds the level: a placement waiting on | |
| 913 | ## it turns from its wire box into its pieces. | |
| 914 | func put_asset_blob(oid: String, bytes: PackedByteArray) -> bool: | |
| 915 | if AssetDoc.hash_of(bytes) != oid: | |
| 916 | return false | |
| 917 | var doc := AssetDoc.decode(bytes) | |
| 918 | if doc.is_empty(): | |
| 919 | return false | |
| 920 | if _asset_docs.has(oid): | |
| 921 | return true | |
| 922 | _asset_docs[oid] = doc | |
| 923 | _ix_brush_ver += 1 | |
| 924 | _ix_tail_ok = false | |
| 925 | _dirty = true | |
| 926 | return true | |
| 927 | ||
| 928 | ||
| 929 | func has_asset_blob(oid: String) -> bool: | |
| 930 | return _asset_docs.has(oid) | |
| 931 | ||
| 932 | ||
| 933 | ## The oids live placements need and this peer has not got (the library fetches them). | |
| 934 | func missing_assets() -> Array: | |
| 935 | return _asset_missing.duplicate() | |
| 936 | ||
| 937 | ||
| 938 | ## The placement a synthetic shape / group / object belongs to, or "". | |
| 939 | func instance_of(dot_or_group: String) -> String: | |
| 940 | return String(_asset_inst.get(dot_or_group, "")) | |
| 941 | ||
| 942 | ||
| 943 | ## Every placement's state (AssetFold.resolve_states over the journal). | |
| 944 | func placements() -> Dictionary: | |
| 945 | return AssetFold.resolve_states(records(false)) | |
| 946 | ||
| 947 | ||
| 948 | ## A payload parsed exactly as the journal parses one written by `author` at `seq` — how | |
| 949 | ## AssetFold makes its synthetic records. | |
| 950 | func _parse_as(author: String, seq: int, payload: Dictionary) -> Dictionary: | |
| 951 | return _to_record("%s%s%s%d" % [PREFIX, author, OP_INFIX, seq], payload) | |
| 952 | ||
| 953 | ||
| 870 | 954 | ## ---- FLATTEN HISTORY (2026-10-05) -------------------------------------------- |
| 871 | 955 | ## |
| 872 | 956 | ## Replace this MAP's journal with the minimum records that rebuild what is there NOW, |
| … | … | @@ -1387,7 +1471,7 @@ func _ix_put(key: String, value: Variant) -> bool: |
| 1387 | 1471 | elif _ix_meta.has(key) and not _ix_obj_keys.has(key) and not _ix_col_keys.has(key) \ |
| 1388 | 1472 | and not _ix_curve_keys.has(key): |
| 1389 | 1473 | var tk := String(_ix_meta[key]["kind"]) |
| 1390 | if ShapeFold.handles(tk) or OpFold.handles(tk) or ModFold.handles(tk): | |
| 1474 | if ShapeFold.handles(tk) or OpFold.handles(tk) or ModFold.handles(tk) or AssetFold.handles(tk): | |
| 1391 | 1475 | _ix_tail_ok = false |
| 1392 | 1476 | else: |
| 1393 | 1477 | _ix_tail_keys.append(key) |
| … | … | @@ -1577,6 +1661,20 @@ func _to_record(key: String, d: Dictionary) -> Dictionary: |
| 1577 | 1661 | rec["target"] = String(d.get("target", "")) |
| 1578 | 1662 | var p: Variant = d.get("props", {}) |
| 1579 | 1663 | rec["props"] = p if p is Dictionary else {} |
| 1664 | "asset.place": | |
| 1665 | # A LIVE INSTANCE of an asset blob (#120 L4 §15.2): the blob by its hash, the | |
| 1666 | # frame it is placed at. AssetFold expands it. | |
| 1667 | rec["asset"] = String(d.get("asset", "")) | |
| 1668 | rec["name"] = String(d.get("name", "")) | |
| 1669 | rec["frame"] = SceneObjects.xform_from(d.get("frame", [])) | |
| 1670 | "asset.xform": | |
| 1671 | rec["target"] = String(d.get("target", "")) | |
| 1672 | rec["frame"] = SceneObjects.xform_from(d.get("frame", [])) | |
| 1673 | "asset.set": | |
| 1674 | rec["target"] = String(d.get("target", "")) | |
| 1675 | rec["asset"] = String(d.get("asset", "")) | |
| 1676 | "asset.remove": | |
| 1677 | rec["target"] = String(d.get("target", "")) | |
| 1580 | 1678 | "col.create": |
| 1581 | 1679 | rec["name"] = String(d.get("name", "Collection")) |
| 1582 | 1680 | rec["parent"] = String(d.get("parent", "")) |
| … | … | @@ -0,0 +1,278 @@ |
| 1 | extends Node | |
| 2 | ## GATE — PLACED ASSETS IN THE FOLD (#120 L4 step 2, docs/node-tree-and-stacks.md §15.2, | |
| 3 | ## asset_fold.gd). gate_asset_doc's room ("Kit": a box A with a mirror cut by K from | |
| 4 | ## "Cutters", a cable on its curve, a plain brush object, a scene object, "Sub" with B), | |
| 5 | ## saved as a blob, then PLACED. The oracle is the room's OWN fold of the originals — an | |
| 6 | ## independent path (shape / mod / op / curve / brush records the user wrote) against the | |
| 7 | ## synthetic one: | |
| 8 | ## F1 placed at its own pivot: the SAME brushes, face windings bit-identical (digest) | |
| 9 | ## F2 moved +20 m x: every instance brush = an original's box + 20 m (1 mm) | |
| 10 | ## F3 turned 90° about y: every instance brush = an original's box turned (1 mm) | |
| 11 | ## F4 the instance's pieces are RENAMED by the move (BrushCreate restores by name) | |
| 12 | ## F5 a second placement: twice the brushes; both instance groups map to their placement | |
| 13 | ## F6 undo the turn -> F2's brushes again; asset.remove -> none; undo -> back | |
| 14 | ## F7 a blob not here: ONE wire (cutter) box, the oid listed missing; the blob arrives -> | |
| 15 | ## the real pieces | |
| 16 | ## F8 put_asset_blob refuses bytes that do not hash to the oid, and garbage | |
| 17 | ## F9 the instance's object exists, moved with it | |
| 18 | ## F10 the instance's cut: its cutter is wire (not solid), the target is cut (A's pieces) | |
| 19 | ## F11 a placement right after a fold with NO live cut (so the tail fold is tried): it is | |
| 20 | ## expanded, not handed to DCLevel's tail and skipped | |
| 21 | ## | |
| 22 | ## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_fold.tscn | |
| 23 | ||
| 24 | const AssetDoc := preload("res://scenes/maps/asset_doc.gd") | |
| 25 | ||
| 26 | var fails := 0 | |
| 27 | var checks := 0 | |
| 28 | var map: Node3D | |
| 29 | var persist: Node | |
| 30 | var level: Node | |
| 31 | ||
| 32 | ||
| 33 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 34 | checks += 1 | |
| 35 | if not cond: | |
| 36 | fails += 1 | |
| 37 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 38 | ||
| 39 | ||
| 40 | func _ready() -> void: | |
| 41 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 42 | gs.route("world:level:*", 1, "peer") | |
| 43 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 44 | if keet != null: | |
| 45 | keet.current_member_id = "probe" | |
| 46 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 47 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 48 | add_child(map) | |
| 49 | for i in 6: | |
| 50 | await get_tree().process_frame | |
| 51 | persist = map.get("_persist") | |
| 52 | level = map.get("level") | |
| 53 | _run() | |
| 54 | if checks < 18: | |
| 55 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 56 | fails += 1 | |
| 57 | print("\nGATE asset_fold: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 58 | get_tree().quit(0 if fails == 0 else 1) | |
| 59 | ||
| 60 | ||
| 61 | func _box(at: Vector3, size: Vector3) -> String: | |
| 62 | return persist.record("shape.create", {"type": "box", "gv": 1, "params": {}, | |
| 63 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z], | |
| 64 | "material_id": 2, "level_id": 0}) | |
| 65 | ||
| 66 | ||
| 67 | static func _box_planes(lo: Vector3, hi: Vector3) -> Array: | |
| 68 | 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] | |
| 69 | ||
| 70 | ||
| 71 | ## Live brushes whose group is in `groups`. | |
| 72 | func _brushes_in(groups: Dictionary) -> Array: | |
| 73 | var out: Array = [] | |
| 74 | for b: int in (level.get_live_brush_ids() as PackedInt32Array): | |
| 75 | if groups.has(String(level.get_brush_group(b))): | |
| 76 | out.append(b) | |
| 77 | return out | |
| 78 | ||
| 79 | ||
| 80 | ## Live brushes of placement `pdot`. | |
| 81 | func _brushes_of(pdot: String) -> Array: | |
| 82 | var out: Array = [] | |
| 83 | for b: int in (level.get_live_brush_ids() as PackedInt32Array): | |
| 84 | if persist.instance_of(String(level.get_brush_group(b))) == pdot: | |
| 85 | out.append(b) | |
| 86 | return out | |
| 87 | ||
| 88 | ||
| 89 | ## Every brush's face windings, as bytes, sorted; hashed. | |
| 90 | func _digest(ids: Array) -> String: | |
| 91 | var blobs: Array = [] | |
| 92 | for b: int in ids: | |
| 93 | var bb := PackedByteArray() | |
| 94 | for f in (level.get_brush(b)["planes"] as PackedFloat32Array).size() / 4: | |
| 95 | bb.append_array((level.get_face_winding(b, f) as PackedVector3Array).to_byte_array()) | |
| 96 | bb.append(255) | |
| 97 | blobs.append(bb) | |
| 98 | blobs.sort_custom(func(x: PackedByteArray, y: PackedByteArray) -> bool: return x.hex_encode() < y.hex_encode()) | |
| 99 | var h := HashingContext.new() | |
| 100 | h.start(HashingContext.HASH_SHA256) | |
| 101 | for bb2: PackedByteArray in blobs: | |
| 102 | h.update(bb2) | |
| 103 | return h.finish().hex_encode().substr(0, 16) | |
| 104 | ||
| 105 | ||
| 106 | ## Each brush's box (corners mapped by `xf`), matched one to one against `want` (1 mm). | |
| 107 | func _boxes_match(got: Array, want: Array, xf: Transform3D) -> bool: | |
| 108 | if got.size() != want.size(): | |
| 109 | return false | |
| 110 | var pool: Array = [] | |
| 111 | for b: int in want: | |
| 112 | var d: Dictionary = level.get_brush(b) | |
| 113 | pool.append(xf * AABB(d["aabb_min"], (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3))) | |
| 114 | for b2: int in got: | |
| 115 | var d2: Dictionary = level.get_brush(b2) | |
| 116 | var g := AABB(d2["aabb_min"], (d2["aabb_max"] as Vector3) - (d2["aabb_min"] as Vector3)) | |
| 117 | var hit := -1 | |
| 118 | for i in pool.size(): | |
| 119 | var w: AABB = pool[i] | |
| 120 | if w.position.distance_to(g.position) < 1.0e-3 and w.end.distance_to(g.end) < 1.0e-3: | |
| 121 | hit = i | |
| 122 | break | |
| 123 | if hit < 0: | |
| 124 | return false | |
| 125 | pool.remove_at(hit) | |
| 126 | return true | |
| 127 | ||
| 128 | ||
| 129 | func _keys(ids: Array) -> Array: | |
| 130 | var out: Array = [] | |
| 131 | for b: int in ids: | |
| 132 | out.append(String(level.get_brush_key(b))) | |
| 133 | out.sort() | |
| 134 | return out | |
| 135 | ||
| 136 | ||
| 137 | func _place(oid: String, frame: Transform3D) -> String: | |
| 138 | persist.begin_gesture("Place asset") | |
| 139 | var d: String = persist.record("asset.place", {"asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(frame)}) | |
| 140 | persist.end_gesture() | |
| 141 | persist.refold() | |
| 142 | return d | |
| 143 | ||
| 144 | ||
| 145 | func _xform(pdot: String, frame: Transform3D) -> void: | |
| 146 | persist.begin_gesture("Move asset") | |
| 147 | persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)}) | |
| 148 | persist.end_gesture() | |
| 149 | persist.refold() | |
| 150 | ||
| 151 | ||
| 152 | func _run() -> void: | |
| 153 | # NON-ROUND on purpose: at exact binary values (500, 502.5) (x - p) + p == x anyway, and | |
| 154 | # F1 could not tell a transform composed first from one applied in two steps. | |
| 155 | var O0 := Vector3(500.137, 0.31, 499.873) | |
| 156 | persist.begin_gesture("asset room") | |
| 157 | var a := _box(O0, Vector3(2, 2, 2)) | |
| 158 | var b := _box(O0 + Vector3(6, 0, 0), Vector3(1, 3, 1)) | |
| 159 | var k := _box(O0 + Vector3(0.5, 0.5, 0), Vector3(1, 1, 3)) | |
| 160 | persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}}) | |
| 161 | var op1: String = persist.record("op.create", {"op": "subtract", "cutter": k, "targets": [a]}) | |
| 162 | var grp := "probe:grp1" | |
| 163 | for i in 2: | |
| 164 | var lo := O0 + Vector3(0, 0, 6 + i * 2) | |
| 165 | persist.record("brush.create", {"planes": _box_planes(lo, lo + Vector3(1, 1, 1)), "proj": [], | |
| 166 | "material_id": 1, "level_id": 0, "sky": false, "clearance": false, "group": grp, "face_tex": []}) | |
| 167 | persist.end_gesture() | |
| 168 | persist.refold() | |
| 169 | var cable: String = map.add_cable([CurveMath.point(O0 + Vector3(0, 1, -4)), CurveMath.point(O0 + Vector3(6, 1, -4))], | |
| 170 | false, {"radius": 0.05}) | |
| 171 | var obj: String = map.create_object(PrefabCatalogue.ids()[0], Transform3D(Basis.IDENTITY, O0 + Vector3(3, 0, 3))) | |
| 172 | persist.begin_gesture("asset collections") | |
| 173 | var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""}) | |
| 174 | var sub: String = persist.record("col.create", {"name": "Sub", "parent": kit}) | |
| 175 | var cuts: String = persist.record("col.create", {"name": "Cutters", "parent": ""}) | |
| 176 | persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + cable, "g:" + grp, "obj:" + obj]}) | |
| 177 | persist.record("col.link", {"target": sub, "members": ["g:" + b]}) | |
| 178 | persist.record("col.link", {"target": cuts, "members": ["g:" + k]}) | |
| 179 | persist.end_gesture() | |
| 180 | persist.refold() | |
| 181 | ||
| 182 | var w: Dictionary = AssetDoc.write(persist, kit) | |
| 183 | var bytes := AssetDoc.encode(w["doc"]) | |
| 184 | var oid := AssetDoc.hash_of(bytes) | |
| 185 | var doc: Dictionary = w["doc"] | |
| 186 | var pivot: Vector3 = doc["pivot"] | |
| 187 | var originals := _brushes_in({a: true, b: true, k: true, cable: true, grp: true}) | |
| 188 | _ok("setup: the Kit's brushes (A + mirror + cut pieces, B, K, the cable, 2 brushes)", originals.size() >= 8, originals.size()) | |
| 189 | ||
| 190 | # ---- F8 first: refusals ---- | |
| 191 | var tampered := bytes.duplicate() | |
| 192 | tampered[tampered.size() - 1] ^= 1 | |
| 193 | _ok("F8 put_asset_blob refuses bytes that do not hash to the oid", not persist.put_asset_blob(oid, tampered)) | |
| 194 | var junk := PackedByteArray([9, 9, 9, 9]) | |
| 195 | _ok("F8 ... and garbage under its own hash", not persist.put_asset_blob(AssetDoc.hash_of(junk), junk)) | |
| 196 | ||
| 197 | # ---- F7: placed before the blob is here ---- | |
| 198 | var at_pivot := Transform3D(Basis.IDENTITY, pivot) | |
| 199 | var p1 := _place(oid, at_pivot) | |
| 200 | var waiting := _brushes_of(p1) | |
| 201 | _ok("F7 no blob yet: ONE wire box", waiting.size() == 1 and bool(level.get_brush(waiting[0]).get("cutter", false)) if waiting.size() == 1 else false, waiting.size()) | |
| 202 | _ok("F7 ... and the oid is listed missing", (persist.missing_assets() as Array).has(oid)) | |
| 203 | _ok("F7 the blob arrives (put_asset_blob)", persist.put_asset_blob(oid, bytes)) | |
| 204 | persist.refold() | |
| 205 | ||
| 206 | # ---- F1 ---- | |
| 207 | var inst := _brushes_of(p1) | |
| 208 | _ok("F1 at its own pivot: as many brushes as the originals", inst.size() == originals.size(), [inst.size(), originals.size()]) | |
| 209 | _ok("F1 ... with bit-identical face windings (digest)", _digest(inst) == _digest(originals), [_digest(inst), _digest(originals)]) | |
| 210 | _ok("F7 ... and nothing is missing any more", (persist.missing_assets() as Array).is_empty()) | |
| 211 | ||
| 212 | # ---- F10 ---- | |
| 213 | var cutter_n := 0 | |
| 214 | for b3: int in inst: | |
| 215 | if bool(level.get_brush(b3).get("cutter", false)): | |
| 216 | cutter_n += 1 | |
| 217 | var k_n := (level.get_group_brush_ids(k) as PackedInt32Array).size() | |
| 218 | _ok("F10 the instance's cutter is wire, as the original's", cutter_n == k_n and k_n > 0, [cutter_n, k_n]) | |
| 219 | ||
| 220 | # ---- F2 / F4 ---- | |
| 221 | var keys0 := _keys(inst) | |
| 222 | _xform(p1, Transform3D(Basis.IDENTITY, pivot + Vector3(20, 0, 0))) | |
| 223 | var moved := _brushes_of(p1) | |
| 224 | _ok("F2 moved +20 m x: every brush = an original's box + 20 m", _boxes_match(moved, originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0)))) | |
| 225 | var keys1 := _keys(moved) | |
| 226 | var shared := keys1.filter(func(x: String) -> bool: return keys0.has(x) and not x.contains("/A0007") and not x.contains("/A0008")) | |
| 227 | _ok("F4 the move renamed the shapes' pieces", keys1.size() == keys0.size() and shared.size() < keys0.size() / 2, [shared.size(), keys0.size()]) | |
| 228 | ||
| 229 | # ---- F3 ---- | |
| 230 | var turn := Basis(Vector3.UP, PI / 2) | |
| 231 | _xform(p1, Transform3D(turn, pivot + Vector3(20, 0, 0))) | |
| 232 | var turned := _brushes_of(p1) | |
| 233 | var m := Transform3D(turn, pivot + Vector3(20, 0, 0)) * Transform3D(Basis.IDENTITY, -pivot) | |
| 234 | _ok("F3 turned 90° about y: every brush = an original's box turned", _boxes_match(turned, originals, m)) | |
| 235 | ||
| 236 | # ---- F9 ---- | |
| 237 | var objn := 0 | |
| 238 | var obj_ok := false | |
| 239 | for e: Dictionary in (map.get_node("SceneObjects").entries() as Array): | |
| 240 | if persist.instance_of(String(e["dot"])) == p1: | |
| 241 | objn += 1 | |
| 242 | var want: Vector3 = m * (map.get_node("SceneObjects").node_for(obj) as Node3D).position | |
| 243 | obj_ok = (e["node"] as Node3D).position.distance_to(want) < 1.0e-3 | |
| 244 | _ok("F9 the instance's object exists, turned and moved with it", objn == 1 and obj_ok, objn) | |
| 245 | ||
| 246 | # ---- F6 ---- | |
| 247 | persist.undo_last() | |
| 248 | persist.refold() | |
| 249 | _ok("F6 undo the turn -> F2's brushes again", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0)))) | |
| 250 | persist.begin_gesture("Delete asset") | |
| 251 | persist.record("asset.remove", {"target": p1}) | |
| 252 | persist.end_gesture() | |
| 253 | persist.refold() | |
| 254 | _ok("F6 asset.remove -> no instance brushes", _brushes_of(p1).is_empty()) | |
| 255 | persist.undo_last() | |
| 256 | persist.refold() | |
| 257 | _ok("F6 undo the remove -> back", _brushes_of(p1).size() == originals.size()) | |
| 258 | ||
| 259 | # ---- F5 ---- | |
| 260 | var p2 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 40))) | |
| 261 | _ok("F5 a second placement: its own brushes, as many", _brushes_of(p2).size() == originals.size() | |
| 262 | and _boxes_match(_brushes_of(p2), originals, Transform3D(Basis.IDENTITY, Vector3(0, 0, 40)))) | |
| 263 | _ok("F5 ... and the first is untouched", _boxes_match(_brushes_of(p1), originals, Transform3D(Basis.IDENTITY, Vector3(20, 0, 0)))) | |
| 264 | _ok("F5 the fold skipped nothing", int(persist.get("_last_fold").get("skipped", 0)) == 0, persist.get("_last_fold").get("skipped", 0)) | |
| 265 | ||
| 266 | # ---- F11: a placement as the ONLY new record after a fold with no live cut ---- | |
| 267 | # A live cut folds at OP_SEQ_BASE + seq, so while one exists every new record sorts | |
| 268 | # before it and the tail fold is refused anyway. With none, an asset.place must still | |
| 269 | # not be handed to DCLevel's tail (it would be skipped there, never expanded). | |
| 270 | persist.begin_gesture("clear") | |
| 271 | persist.record("asset.remove", {"target": p1}) | |
| 272 | persist.record("asset.remove", {"target": p2}) | |
| 273 | persist.record("op.remove", {"target": op1}) | |
| 274 | persist.end_gesture() | |
| 275 | persist.refold() | |
| 276 | var p3 := _place(oid, Transform3D(Basis.IDENTITY, pivot + Vector3(0, 0, 80))) | |
| 277 | _ok("F11 placed with no live cut in the room: all its brushes", _brushes_of(p3).size() == inst.size(), | |
| 278 | [_brushes_of(p3).size(), inst.size(), persist.get("_last_fold").get("brush_fold", "")]) |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_asset_fold.gd" id="1"] | |
| 4 | ||
| 5 | [node name="GateAssetFold" type="Node"] | |
| 6 | script = ExtResource("1") |