| … | … | @@ -118,10 +118,16 @@ func publish_file(kind: String, name: String, abs_path: String, ext: String = "" |
| 118 | 118 | |
| 119 | 119 | |
| 120 | 120 | ## MUTABLE: name the newest bytes for this kind+name. Newest publish wins. |
| 121 | func publish_asset(kind: String, name: String, oid: String, size: int, ext: String = "") -> String: | |
| 121 | ## `extra`: more fields for the entry (an asset's bounds, #120 L4) — never oid/size/ext. | |
| 122 | func publish_asset(kind: String, name: String, oid: String, size: int, ext: String = "", | |
| 123 | extra: Dictionary = {}) -> String: | |
| 122 | 124 | if not is_ready() or oid == "": |
| 123 | 125 | return "" |
| 124 | state.set_state(_mutable_key(kind, name), {"oid": oid, "size": size, "ext": ext}) | |
| 126 | var entry := extra.duplicate(true) | |
| 127 | entry["oid"] = oid | |
| 128 | entry["size"] = size | |
| 129 | entry["ext"] = ext | |
| 130 | state.set_state(_mutable_key(kind, name), entry) | |
| 125 | 131 | return oid |
| 126 | 132 | |
| 127 | 133 |
| … | … | @@ -383,6 +383,8 @@ const DEFAULTS: Dictionary = { |
| 383 | 383 | # OBJECTS AND COLLECTIONS (#120 F1, Blender 2.79's keys: tools/keymap/blender_279_left.json). |
| 384 | 384 | # A brush object is a group of brushes; a collection holds objects and collections. |
| 385 | 385 | "builder.collection_new": {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"}, |
| 386 | # #120 L4: menu-only (user 2026-10-19), Object menu. | |
| 387 | "builder.save_asset": {"label": "Save collection as asset…"}, | |
| 386 | 388 | "builder.collection_move": {"key": KEY_M, "label": "Move to collection…"}, |
| 387 | 389 | "builder.collection_link": {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"}, |
| 388 | 390 | "builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"}, |
| … | … | @@ -1312,10 +1312,22 @@ Save as asset is menu-only for now). |
| 1312 | 1312 | killed, 1 equivalent (two-step frame: exact by Sterbenz), the tail one killed after F11 |
| 1313 | 1313 | was added (a live cut's OP_SEQ_BASE hid it). `asset.set` re-pointing is exercised in |
| 1314 | 1314 | step 3 (Update placements). **DONE 2026-10-19.** |
| 1315 | 3. **Libraries + Save as asset**: room (ContentOverlay + AssetSync kind `asset`, the | |
| 1316 | extra field) and personal (user://assets). `gate_asset_library`: save → listed in | |
| 1317 | both; re-save → new oid, YOUR placements follow (one undo restores them), OTHERS' | |
| 1318 | stay until Update placements; a personal asset placed in a room is published first. | |
| 1315 | 3. **Libraries + Save as asset** (`asset_library.gd`, a child of the map made with the | |
| 1316 | journal; `map.asset_library()`): room (ContentOverlay publish + AssetSync kind `asset` | |
| 1317 | with `bounds` via publish_asset's new `extra`) and personal (`user://assets`, | |
| 1318 | `_ci` under CI). Object → **Save collection as asset…** (menu-only `builder.save_asset`; | |
| 1319 | the outliner's selected collection, else the selection's first collection; named in | |
| 1320 | the Q-menu name prompt, prefilled). Both libraries by default — the per-save choice | |
| 1321 | waits for the browser (step 5); the outliner has no context menu yet, so its entry | |
| 1322 | waits too. Blobs reach the fold from disk (the cache, then the personal library) after | |
| 1323 | each fold, else by ContentOverlay.request; AssetSync already fetches every catalogue | |
| 1324 | entry it sees. `gate_asset_library` (20): save → on disk under the overlay's hash, | |
| 1325 | both libraries list it; place; another member's placement (delivered as replication | |
| 1326 | does); re-save → new oid, MINE follow (one undo), THEIRS stay, the row counts 1 older; | |
| 1327 | Update placements (one gesture); personal-only published on place; a lost blob found | |
| 1328 | on disk after the fold; empty collection refused; the menu entry. 4 mutants killed | |
| 1329 | (the fetch one after the gate flushed AssetSync's deferred content_ready signals, | |
| 1330 | which had restored the blob by another path). **DONE 2026-10-19.** | |
| 1319 | 1331 | 4. **Editor**: outliner rows, selection mapping, G / R / X / Shift+D / Alt+D, Make Real. |
| 1320 | 1332 | `gate_asset_edit`: each, plus Make Real = the same digest, then ordinary editable |
| 1321 | 1333 | records (a shape param edit works on one). |
| … | … | @@ -38,7 +38,7 @@ const MENUS: Array = [ |
| 38 | 38 | "builder.pivot_individual", "-", "builder.proportional", "builder.proportional_falloff", |
| 39 | 39 | "builder.proportional_connected", "-", "builder.make_parametric", "builder.assign_generator", "builder.add_modifier", "builder.edit_curve", "builder.drop_cable", "-", "builder.parent", "builder.clear_parent", "-", |
| 40 | 40 | "builder.join", "builder.separate", "-", "builder.collection_new", "builder.collection_move", |
| 41 | "builder.collection_link", "builder.collection_remove"]], | |
| 41 | "builder.collection_link", "builder.collection_remove", "builder.save_asset"]], | |
| 42 | 42 | ["Texture", ["builder.uv_menu", "-", "builder.texture_apply", "builder.texture_pick", "-", |
| 43 | 43 | "builder.uv_hotspot", "builder.uv_reroll", "builder.uv_pin", "builder.uv_fit", "builder.uv_align"]], |
| 44 | 44 | ["View", ["builder.fly", "builder.wireframe", "builder.toggle_sidebar", "builder.toggle_toolbar", "builder.reset_panels", "-", |
| … | … | @@ -0,0 +1,258 @@ |
| 1 | extends Node | |
| 2 | ## THE ASSET LIBRARIES (#120 L4 §15.4): the ROOM's and a PERSONAL one, and the fetch that | |
| 3 | ## feeds placed assets' blobs to the fold. A child of the map (MapDCLevel.asset_library()). | |
| 4 | ## | |
| 5 | ## ROOM blob bytes served by ContentOverlay (publish), named in the room by AssetSync | |
| 6 | ## kind "asset": world:assets:asset:<name> -> {oid, size, ext, bounds} (newest | |
| 7 | ## wins per name, as for models). AssetSync fetches every entry it sees into | |
| 8 | ## user://content/<oid>.hasset; _on_content_ready hands it to the fold. | |
| 9 | ## PERSONAL user://assets/<oid>.hasset + index.json {assets: [{name, oid, saved}]} on | |
| 10 | ## this machine, any room. Placing one in a room publishes it there first. | |
| 11 | ## (Under CI: user://assets_ci — a gate never writes the player's library.) | |
| 12 | ## | |
| 13 | ## SAVE (§15.4, user 2026-10-19): AssetDoc of the collection -> blob -> both libraries. | |
| 14 | ## Saving under a name that exists makes a new oid; placements YOU made of that name follow | |
| 15 | ## (asset.set, one gesture: one Ctrl+Z undoes it), others' stay until Update placements. | |
| 16 | ## | |
| 17 | ## DELETION STORY: nothing here deletes a blob. Re-saving replaces the personal index's | |
| 18 | ## entry for the name, and the old file stays (a placement may pin it). | |
| 19 | ||
| 20 | const AssetDoc := preload("res://scenes/maps/asset_doc.gd") | |
| 21 | const KIND := "asset" | |
| 22 | const CACHE_DIR := "user://content" | |
| 23 | ||
| 24 | var map: Node = null | |
| 25 | var persist: Node = null | |
| 26 | ||
| 27 | ||
| 28 | func setup(map_node: Node, persistence: Node) -> void: | |
| 29 | map = map_node | |
| 30 | persist = persistence | |
| 31 | var co := _overlay() | |
| 32 | if co != null and not co.content_ready.is_connected(_on_content_ready): | |
| 33 | co.content_ready.connect(_on_content_ready) | |
| 34 | if persist != null and not persist.folded.is_connected(_on_folded): | |
| 35 | persist.folded.connect(_on_folded) | |
| 36 | ||
| 37 | ||
| 38 | static func personal_dir() -> String: | |
| 39 | return "user://assets_ci" if OS.get_environment("CI") != "" else "user://assets" | |
| 40 | ||
| 41 | ||
| 42 | func _overlay() -> Node: | |
| 43 | return get_node_or_null("/root/ContentOverlay") | |
| 44 | ||
| 45 | ||
| 46 | func _sync() -> Node: | |
| 47 | return get_node_or_null("/root/AssetSync") | |
| 48 | ||
| 49 | ||
| 50 | static func blob_path(oid: String) -> String: | |
| 51 | return CACHE_DIR.path_join(oid + "." + AssetDoc.EXT) | |
| 52 | ||
| 53 | ||
| 54 | # ---- save --------------------------------------------------------------------- | |
| 55 | ||
| 56 | ## Save collection `col` as asset `name`. {oid, report, followed} or {error, report}. | |
| 57 | func save_collection(col: String, name: String, room: bool = true, personal: bool = true) -> Dictionary: | |
| 58 | if persist == null: | |
| 59 | return {"error": "no journal", "report": []} | |
| 60 | var w: Dictionary = AssetDoc.write(persist, col, name) | |
| 61 | var doc: Dictionary = w["doc"] | |
| 62 | if doc.is_empty(): | |
| 63 | return {"error": "nothing in the collection an asset can carry", "report": w["report"]} | |
| 64 | var bytes := AssetDoc.encode(doc) | |
| 65 | var oid := AssetDoc.hash_of(bytes) | |
| 66 | if oid == "": | |
| 67 | return {"error": "the content overlay is not running — cannot name the asset", "report": w["report"]} | |
| 68 | if not _write(blob_path(oid), bytes): | |
| 69 | return {"error": "cannot write %s" % blob_path(oid), "report": w["report"]} | |
| 70 | persist.put_asset_blob(oid, bytes) | |
| 71 | var followed := 0 | |
| 72 | if room: | |
| 73 | if not _publish_room(name, oid, bytes.size(), doc): | |
| 74 | return {"error": "the room's library refused it", "report": w["report"]} | |
| 75 | followed = _follow_mine(name, oid) | |
| 76 | if personal: | |
| 77 | _save_personal(name, oid, bytes) | |
| 78 | return {"oid": oid, "report": w["report"], "followed": followed} | |
| 79 | ||
| 80 | ||
| 81 | func _write(path: String, bytes: PackedByteArray) -> bool: | |
| 82 | DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir())) | |
| 83 | var f := FileAccess.open(path, FileAccess.WRITE) | |
| 84 | if f == null: | |
| 85 | return false | |
| 86 | f.store_buffer(bytes) | |
| 87 | f.close() | |
| 88 | return true | |
| 89 | ||
| 90 | ||
| 91 | func _publish_room(name: String, oid: String, size: int, doc: Dictionary) -> bool: | |
| 92 | var co := _overlay() | |
| 93 | var sync := _sync() | |
| 94 | if co == null or sync == null: | |
| 95 | return false | |
| 96 | if String(co.publish(blob_path(oid))) != oid: | |
| 97 | return false | |
| 98 | var b: Array = doc["bounds"] | |
| 99 | return String(sync.publish_asset(KIND, name, oid, size, AssetDoc.EXT, | |
| 100 | {"bounds": [(b[0] as Vector3).x, (b[0] as Vector3).y, (b[0] as Vector3).z, | |
| 101 | (b[1] as Vector3).x, (b[1] as Vector3).y, (b[1] as Vector3).z]})) == oid | |
| 102 | ||
| 103 | ||
| 104 | ## My placements of `name` on another version follow the new one: ONE gesture. | |
| 105 | func _follow_mine(name: String, oid: String) -> int: | |
| 106 | var me := String(persist.call("_author")) | |
| 107 | var mine: Array = [] | |
| 108 | for p: Dictionary in (persist.placements() as Dictionary).values(): | |
| 109 | if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid \ | |
| 110 | and String(p["author"]) == me: | |
| 111 | mine.append(String(p["dot"])) | |
| 112 | if mine.is_empty(): | |
| 113 | return 0 | |
| 114 | persist.begin_gesture("Save asset: %s" % name) | |
| 115 | for d: String in mine: | |
| 116 | persist.record("asset.set", {"target": d, "asset": oid}) | |
| 117 | persist.end_gesture() | |
| 118 | persist.refold() | |
| 119 | return mine.size() | |
| 120 | ||
| 121 | ||
| 122 | func _save_personal(name: String, oid: String, bytes: PackedByteArray) -> void: | |
| 123 | var dir := personal_dir() | |
| 124 | _write(dir.path_join(oid + "." + AssetDoc.EXT), bytes) | |
| 125 | var idx := personal_index() | |
| 126 | var rows: Array = idx.filter(func(r: Dictionary) -> bool: return String(r["name"]) != name) | |
| 127 | rows.append({"name": name, "oid": oid, "saved": int(Time.get_unix_time_from_system())}) | |
| 128 | var f := FileAccess.open(dir.path_join("index.json"), FileAccess.WRITE) | |
| 129 | if f != null: | |
| 130 | f.store_string(JSON.stringify({"assets": rows}, "\t")) | |
| 131 | f.close() | |
| 132 | ||
| 133 | ||
| 134 | ## The personal library: [{name, oid, saved}]. | |
| 135 | static func personal_index() -> Array: | |
| 136 | var f := FileAccess.open(personal_dir().path_join("index.json"), FileAccess.READ) | |
| 137 | if f == null: | |
| 138 | return [] | |
| 139 | var v: Variant = JSON.parse_string(f.get_as_text()) | |
| 140 | f.close() | |
| 141 | if not (v is Dictionary) or not ((v as Dictionary).get("assets") is Array): | |
| 142 | return [] | |
| 143 | var out: Array = [] | |
| 144 | for r: Variant in (v as Dictionary)["assets"]: | |
| 145 | if r is Dictionary and String((r as Dictionary).get("name", "")) != "" \ | |
| 146 | and String((r as Dictionary).get("oid", "")).length() == 64: | |
| 147 | out.append({"name": String(r["name"]), "oid": String(r["oid"]), "saved": int(r.get("saved", 0))}) | |
| 148 | return out | |
| 149 | ||
| 150 | ||
| 151 | # ---- list / place / update --------------------------------------------------------- | |
| 152 | ||
| 153 | ## Both libraries, by name: [{name, oid, room, personal, older}] — `room` / `personal` | |
| 154 | ## whether each holds the name (their oids can differ: the room's is shown, else yours); | |
| 155 | ## `older` = live placements of the name on another version than the room's. | |
| 156 | func rows() -> Array: | |
| 157 | var by := {} | |
| 158 | var sync := _sync() | |
| 159 | if sync != null: | |
| 160 | for r: Dictionary in (sync.list(KIND) as Array): | |
| 161 | by[String(r["name"])] = {"name": String(r["name"]), "oid": String(r["oid"]), "room": true, | |
| 162 | "personal": false, "older": 0, "bounds": r.get("bounds", [])} | |
| 163 | for r2: Dictionary in personal_index(): | |
| 164 | var n := String(r2["name"]) | |
| 165 | if by.has(n): | |
| 166 | by[n]["personal"] = true | |
| 167 | else: | |
| 168 | by[n] = {"name": n, "oid": String(r2["oid"]), "room": false, "personal": true, "older": 0, "bounds": []} | |
| 169 | if persist != null: | |
| 170 | for p: Dictionary in (persist.placements() as Dictionary).values(): | |
| 171 | var n2 := String(p["name"]) | |
| 172 | if not bool(p["removed"]) and by.has(n2) and bool(by[n2]["room"]) and String(p["asset"]) != String(by[n2]["oid"]): | |
| 173 | by[n2]["older"] = int(by[n2]["older"]) + 1 | |
| 174 | var names: Array = by.keys() | |
| 175 | names.sort() | |
| 176 | return names.map(func(n3: String) -> Dictionary: return by[n3]) | |
| 177 | ||
| 178 | ||
| 179 | ## Place asset `name` at `frame`, ONE gesture. A personal-only asset is published to the | |
| 180 | ## room first (blob + catalogue), so every peer can fetch what it is about to see. | |
| 181 | ## Returns the placement's dot, or "". | |
| 182 | func place(name: String, frame: Transform3D) -> String: | |
| 183 | var row: Dictionary = {} | |
| 184 | for r: Dictionary in rows(): | |
| 185 | if String(r["name"]) == name: | |
| 186 | row = r | |
| 187 | if row.is_empty(): | |
| 188 | return "" | |
| 189 | var oid := String(row["oid"]) | |
| 190 | if not bool(row["room"]): | |
| 191 | var bytes := FileAccess.get_file_as_bytes(personal_dir().path_join(oid + "." + AssetDoc.EXT)) | |
| 192 | if AssetDoc.hash_of(bytes) != oid or not _write(blob_path(oid), bytes): | |
| 193 | return "" | |
| 194 | var doc := AssetDoc.decode(bytes) | |
| 195 | if doc.is_empty() or not _publish_room(name, oid, bytes.size(), doc): | |
| 196 | return "" | |
| 197 | persist.put_asset_blob(oid, bytes) | |
| 198 | persist.begin_gesture("Place asset: %s" % name) | |
| 199 | var dot: String = persist.record("asset.place", {"asset": oid, "name": name, | |
| 200 | "frame": SceneObjects.xform_to(frame)}) | |
| 201 | persist.end_gesture() | |
| 202 | persist.refold() | |
| 203 | return dot | |
| 204 | ||
| 205 | ||
| 206 | ## Update placements: every live placement of `name` on another version -> the room's, ONE | |
| 207 | ## gesture. Returns how many. | |
| 208 | func update_placements(name: String) -> int: | |
| 209 | var oid := "" | |
| 210 | for r: Dictionary in rows(): | |
| 211 | if String(r["name"]) == name and bool(r["room"]): | |
| 212 | oid = String(r["oid"]) | |
| 213 | if oid == "": | |
| 214 | return 0 | |
| 215 | var todo: Array = [] | |
| 216 | for p: Dictionary in (persist.placements() as Dictionary).values(): | |
| 217 | if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid: | |
| 218 | todo.append(String(p["dot"])) | |
| 219 | if todo.is_empty(): | |
| 220 | return 0 | |
| 221 | persist.begin_gesture("Update placements: %s" % name) | |
| 222 | for d: String in todo: | |
| 223 | persist.record("asset.set", {"target": d, "asset": oid}) | |
| 224 | persist.end_gesture() | |
| 225 | persist.refold() | |
| 226 | return todo.size() | |
| 227 | ||
| 228 | ||
| 229 | # ---- fetch: blobs the fold is waiting for ------------------------------------------ | |
| 230 | ||
| 231 | ## After every fold: hand the fold any blob it is missing that is on disk (the room's | |
| 232 | ## cache, then the personal library), and ask the mesh for the rest. | |
| 233 | func _on_folded(_st: Dictionary) -> void: | |
| 234 | fetch_missing() | |
| 235 | ||
| 236 | ||
| 237 | func fetch_missing() -> int: | |
| 238 | if persist == null: | |
| 239 | return 0 | |
| 240 | var asked := 0 | |
| 241 | for oid: String in (persist.missing_assets() as Array): | |
| 242 | if _load(blob_path(oid), oid) or _load(personal_dir().path_join(oid + "." + AssetDoc.EXT), oid): | |
| 243 | continue | |
| 244 | var co := _overlay() | |
| 245 | if co != null and String(co.request(oid, oid + "." + AssetDoc.EXT)) != "": | |
| 246 | asked += 1 | |
| 247 | return asked | |
| 248 | ||
| 249 | ||
| 250 | func _load(path: String, oid: String) -> bool: | |
| 251 | if not FileAccess.file_exists(path): | |
| 252 | return false | |
| 253 | return persist.put_asset_blob(oid, FileAccess.get_file_as_bytes(path)) | |
| 254 | ||
| 255 | ||
| 256 | func _on_content_ready(content_hash: String, path: String) -> void: | |
| 257 | if persist != null and path.ends_with("." + AssetDoc.EXT) and not persist.has_asset_blob(content_hash): | |
| 258 | _load(path, content_hash) |
| … | … | @@ -1145,6 +1145,9 @@ func _collection_keys(event: InputEvent) -> bool: |
| 1145 | 1145 | var dot := CollectionOps.create_from_selection(self, n, "", cols) |
| 1146 | 1146 | print("[MapDCLevel] new collection %s \"%s\" from the selection" % [dot, n])) |
| 1147 | 1147 | return true |
| 1148 | if Keymap.tapped(event, "builder.save_asset"): | |
| 1149 | save_asset_prompt() | |
| 1150 | return true | |
| 1148 | 1151 | if Keymap.tapped(event, "builder.join"): |
| 1149 | 1152 | CollectionOps.join(self) |
| 1150 | 1153 | return true |
| … | … | @@ -1167,6 +1170,69 @@ func _collection_keys(event: InputEvent) -> bool: |
| 1167 | 1170 | return false |
| 1168 | 1171 | |
| 1169 | 1172 | |
| 1173 | ## ---- ASSETS (#120 L4 §15.4) ---- | |
| 1174 | ||
| 1175 | const AssetLibraryScript := preload("res://scenes/maps/asset_library.gd") | |
| 1176 | var _asset_library: Node = null | |
| 1177 | ||
| 1178 | ||
| 1179 | ## The room's + personal asset libraries (a child; created with the journal so placed | |
| 1180 | ## assets' blobs are fetched whether or not a panel is open). | |
| 1181 | func asset_library() -> Node: | |
| 1182 | _ensure_persistence() | |
| 1183 | return _asset_library | |
| 1184 | ||
| 1185 | ||
| 1186 | ## The collection Save as asset acts on: the outliner's selected collection row, else the | |
| 1187 | ## first collection the selection is in. "" when neither. | |
| 1188 | func asset_collection() -> String: | |
| 1189 | _ensure_persistence() | |
| 1190 | var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else [] | |
| 1191 | if not cols.is_empty(): | |
| 1192 | return String(cols[0]) | |
| 1193 | # The selection's members, READ ONLY (CollectionOps.selection_members regroups loose | |
| 1194 | # brushes — a write — and asking which collection must not write). | |
| 1195 | var ms: Array = [] | |
| 1196 | if _sel_shape != "": | |
| 1197 | ms.append("g:" + _sel_shape) | |
| 1198 | for d: Variant in selected_objects(): | |
| 1199 | ms.append("obj:" + String(d)) | |
| 1200 | for b: int in _sel_brushes: | |
| 1201 | var g := String(level.get_brush_group(b)) if level != null else "" | |
| 1202 | if g != "" and not ms.has("g:" + g): | |
| 1203 | ms.append("g:" + g) | |
| 1204 | var t: Dictionary = _persist.collection_tree() | |
| 1205 | for m: String in ms: | |
| 1206 | var cs: Array = CollectionFold.collections_of(t, m) | |
| 1207 | if not cs.is_empty(): | |
| 1208 | return String(cs[0]) | |
| 1209 | return "" | |
| 1210 | ||
| 1211 | ||
| 1212 | ## SAVE COLLECTION AS ASSET (menu only, user 2026-10-19): named first (Blender's popup, | |
| 1213 | ## prefilled with the collection's name), then into the room's and your library. | |
| 1214 | func save_asset_prompt() -> void: | |
| 1215 | var col := asset_collection() | |
| 1216 | if col == "": | |
| 1217 | print("[MapDCLevel] save as asset: select a collection (its outliner row, or something in it)") | |
| 1218 | return | |
| 1219 | var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"]) | |
| 1220 | var make := func(n: String) -> void: | |
| 1221 | var r: Dictionary = asset_library().save_collection(col, n) | |
| 1222 | for line: String in (r.get("report", []) as Array): | |
| 1223 | print("[MapDCLevel] save as asset: %s" % line) | |
| 1224 | if r.has("error"): | |
| 1225 | print("[MapDCLevel] save as asset \"%s\": %s" % [n, r["error"]]) | |
| 1226 | else: | |
| 1227 | print("[MapDCLevel] saved asset \"%s\" %s (%d of your placements updated)" | |
| 1228 | % [n, String(r["oid"]).substr(0, 12), int(r["followed"])]) | |
| 1229 | if _q_menu == null or not _q_menu.has_method("open_name"): | |
| 1230 | make.call(cname) | |
| 1231 | return | |
| 1232 | _q_menu.open_name("Save as asset", cname, make, get_viewport().get_mouse_position()) | |
| 1233 | get_viewport().set_input_as_handled() | |
| 1234 | ||
| 1235 | ||
| 1170 | 1236 | ## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be |
| 1171 | 1237 | ## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the |
| 1172 | 1238 | ## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection". |
| … | … | @@ -3724,6 +3790,12 @@ func _ensure_persistence() -> void: |
| 3724 | 3790 | _queue_nav_rebuild()) |
| 3725 | 3791 | _register_persistence_admin(_persist) |
| 3726 | 3792 | add_child(_persist) |
| 3793 | # The asset libraries (#120 L4): with the journal, so placed assets' blobs are fetched | |
| 3794 | # whatever panel is open. | |
| 3795 | _asset_library = AssetLibraryScript.new() | |
| 3796 | _asset_library.name = "AssetLibrary" | |
| 3797 | add_child(_asset_library) | |
| 3798 | _asset_library.setup(self, _persist) | |
| 3727 | 3799 | |
| 3728 | 3800 | |
| 3729 | 3801 | # ---- navigation (bots) -------------------------------------------------------- |
| … | … | @@ -0,0 +1,201 @@ |
| 1 | extends Node | |
| 2 | ## GATE — THE ASSET LIBRARIES (#120 L4 step 3, docs/node-tree-and-stacks.md §15.4, | |
| 3 | ## asset_library.gd). A room with a collection "Kit" (a box with a mirror + a brush object). | |
| 4 | ## L1 Save as asset -> the blob on disk under the CONTENT OVERLAY's own hash; the room's | |
| 5 | ## catalogue (AssetSync kind "asset", with bounds) and the personal index list it | |
| 6 | ## L2 place it -> a placement that folds to pieces | |
| 7 | ## L3 a placement by ANOTHER author | |
| 8 | ## L4 edit Kit, save under the same name -> a new oid; MY placement follows, theirs stays; | |
| 9 | ## the row reports 1 placement on an older version | |
| 10 | ## L5 undo -> mine back on the old version (the follow was ONE gesture) | |
| 11 | ## L6 Update placements -> both on the new version, one gesture | |
| 12 | ## L7 a personal-only asset: not in the room until placed; placing publishes it first | |
| 13 | ## L8 a blob the fold lost is found on disk again (fetch after the fold) -> the pieces | |
| 14 | ## L9 an empty collection is refused, with the reason | |
| 15 | ## L10 the menu: Object -> Save collection as asset… (menu-only, no key) | |
| 16 | ## | |
| 17 | ## RUN AS A SCENE: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_asset_library.tscn | |
| 18 | ||
| 19 | const AssetLibrary := preload("res://scenes/maps/asset_library.gd") | |
| 20 | ||
| 21 | var fails := 0 | |
| 22 | var checks := 0 | |
| 23 | var map: Node3D | |
| 24 | var persist: Node | |
| 25 | var level: Node | |
| 26 | var lib: Node | |
| 27 | ||
| 28 | ||
| 29 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 30 | checks += 1 | |
| 31 | if not cond: | |
| 32 | fails += 1 | |
| 33 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 34 | ||
| 35 | ||
| 36 | func _ready() -> void: | |
| 37 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 38 | gs.route("world:level:*", 1, "peer") | |
| 39 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 40 | if keet != null: | |
| 41 | keet.current_member_id = "probe" | |
| 42 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 43 | # The PERSONAL library under CI is user://assets_ci: start it empty. Never the player's. | |
| 44 | var pd := AssetLibrary.personal_dir() | |
| 45 | if pd.ends_with("_ci"): | |
| 46 | for f: String in DirAccess.get_files_at(pd): | |
| 47 | DirAccess.remove_absolute(ProjectSettings.globalize_path(pd.path_join(f))) | |
| 48 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 49 | add_child(map) | |
| 50 | for i in 6: | |
| 51 | await get_tree().process_frame | |
| 52 | persist = map.get("_persist") | |
| 53 | level = map.get("level") | |
| 54 | lib = map.asset_library() | |
| 55 | await _run() | |
| 56 | if checks < 20: | |
| 57 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 58 | fails += 1 | |
| 59 | print("\nGATE asset_library: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 60 | get_tree().quit(0 if fails == 0 else 1) | |
| 61 | ||
| 62 | ||
| 63 | func _box(at: Vector3, size: Vector3) -> String: | |
| 64 | return persist.record("shape.create", {"type": "box", "gv": 1, "params": {}, | |
| 65 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)), "size": [size.x, size.y, size.z], | |
| 66 | "material_id": 2, "level_id": 0}) | |
| 67 | ||
| 68 | ||
| 69 | func _brushes_of(pdot: String) -> int: | |
| 70 | var n := 0 | |
| 71 | for b: int in (level.get_live_brush_ids() as PackedInt32Array): | |
| 72 | if persist.instance_of(String(level.get_brush_group(b))) == pdot: | |
| 73 | n += 1 | |
| 74 | return n | |
| 75 | ||
| 76 | ||
| 77 | func _row(name: String) -> Dictionary: | |
| 78 | for r: Dictionary in (lib.rows() as Array): | |
| 79 | if String(r["name"]) == name: | |
| 80 | return r | |
| 81 | return {} | |
| 82 | ||
| 83 | ||
| 84 | func _asset_of(pdot: String) -> String: | |
| 85 | return String((persist.placements() as Dictionary).get(pdot, {}).get("asset", "")) | |
| 86 | ||
| 87 | ||
| 88 | func _run() -> void: | |
| 89 | var O0 := Vector3(600.137, 0.31, 599.873) | |
| 90 | persist.begin_gesture("room") | |
| 91 | var a := _box(O0, Vector3(2, 2, 2)) | |
| 92 | persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}}) | |
| 93 | var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""}) | |
| 94 | persist.record("col.link", {"target": kit, "members": ["g:" + a]}) | |
| 95 | var empty: String = persist.record("col.create", {"name": "Empty", "parent": ""}) | |
| 96 | persist.end_gesture() | |
| 97 | persist.refold() | |
| 98 | ||
| 99 | # ---- L1 ---- | |
| 100 | var r1: Dictionary = lib.save_collection(kit, "Kit") | |
| 101 | var oid := String(r1.get("oid", "")) | |
| 102 | _ok("L1 saved: an oid", oid.length() == 64, r1) | |
| 103 | var path: String = AssetLibrary.blob_path(oid) | |
| 104 | var co: Node = get_tree().root.get_node("ContentOverlay") | |
| 105 | _ok("L1 the blob is on disk under the overlay's own hash of it", FileAccess.file_exists(path) | |
| 106 | and String(co.content_hash_of_file(path)) == oid, path) | |
| 107 | var row := _row("Kit") | |
| 108 | _ok("L1 the room's catalogue lists it (oid, ext, bounds)", bool(row.get("room", false)) and String(row["oid"]) == oid | |
| 109 | and (row.get("bounds", []) as Array).size() == 6, row) | |
| 110 | var entry: Variant = get_tree().root.get_node("AssetSync").state.get_state("world:assets:asset:Kit", null) | |
| 111 | _ok("L1 ... the entry carries ext hasset", entry is Dictionary and String((entry as Dictionary).get("ext", "")) == "hasset", entry) | |
| 112 | _ok("L1 the personal index lists it", AssetLibrary.personal_index().any(func(x: Dictionary) -> bool: | |
| 113 | return String(x["name"]) == "Kit" and String(x["oid"]) == oid)) | |
| 114 | ||
| 115 | # ---- L2 / L3 ---- | |
| 116 | var mine: String = lib.place("Kit", Transform3D(Basis.IDENTITY, O0 + Vector3(30, 0, 0))) | |
| 117 | _ok("L2 placed: it folds to pieces", mine != "" and _brushes_of(mine) > 0, [mine, _brushes_of(mine)]) | |
| 118 | # Another member's record ARRIVES the way replication delivers one (GameState's | |
| 119 | # _on_durable_updated — this peer may not WRITE under their name, can_write refuses). | |
| 120 | var oseq := int(mine.get_slice(":", 1)) + 7 | |
| 121 | var theirs := "zz9other:%d" % oseq | |
| 122 | var gs: Node = get_tree().root.get_node("GameState") | |
| 123 | # The STORED form of a map-scoped key ("map:<map id>:<key>", _scoped's) — built here | |
| 124 | # because _scoped leaves a key bare while no map id is set, and _unscope drops bare ones. | |
| 125 | var stored := "map:%s:world:level:zz9other:op:%d" % [String(gs.current_map_id), oseq] | |
| 126 | gs._on_durable_updated("", stored, {"v": LevelPersistence.RECORD_V, | |
| 127 | "kind": "asset.place", "undone": false, "g": theirs, "src": String(persist.find_source().get("hash", "")), | |
| 128 | "asset": oid, "name": "Kit", "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 30)))}) | |
| 129 | var wrote := (persist.placements() as Dictionary).has(theirs) | |
| 130 | persist.refold() | |
| 131 | _ok("L3 another author's placement", wrote and _brushes_of(theirs) > 0, [wrote, _brushes_of(theirs)]) | |
| 132 | ||
| 133 | # ---- L4 ---- | |
| 134 | persist.begin_gesture("edit kit") | |
| 135 | persist.record("shape.size", {"target": a, "size": [3, 2, 2]}) | |
| 136 | persist.end_gesture() | |
| 137 | persist.refold() | |
| 138 | var r2: Dictionary = lib.save_collection(kit, "Kit") | |
| 139 | var oid2 := String(r2.get("oid", "")) | |
| 140 | _ok("L4 re-saved: a NEW oid", oid2.length() == 64 and oid2 != oid) | |
| 141 | _ok("L4 MY placement follows", _asset_of(mine) == oid2 and int(r2.get("followed", 0)) == 1, [_asset_of(mine).substr(0, 8), r2.get("followed")]) | |
| 142 | _ok("L4 THEIRS stays on the old version", _asset_of(theirs) == oid) | |
| 143 | _ok("L4 the row: 1 placement on an older version", int(_row("Kit").get("older", -1)) == 1, _row("Kit")) | |
| 144 | ||
| 145 | # ---- L5 ---- | |
| 146 | persist.undo_last() | |
| 147 | persist.refold() | |
| 148 | _ok("L5 undo -> mine back on the old version (one gesture)", _asset_of(mine) == oid and int(_row("Kit")["older"]) == 2) | |
| 149 | ||
| 150 | # ---- L6 ---- | |
| 151 | var n6: int = lib.update_placements("Kit") | |
| 152 | _ok("L6 Update placements: both on the new version", n6 == 2 and _asset_of(mine) == oid2 and _asset_of(theirs) == oid2 | |
| 153 | and int(_row("Kit")["older"]) == 0, n6) | |
| 154 | persist.undo_last() | |
| 155 | persist.refold() | |
| 156 | _ok("L6 ... one gesture: one undo puts both back", _asset_of(mine) == oid and _asset_of(theirs) == oid) | |
| 157 | persist.redo_last() | |
| 158 | persist.refold() | |
| 159 | ||
| 160 | # ---- L7 ---- | |
| 161 | persist.begin_gesture("solo") | |
| 162 | var s := _box(O0 + Vector3(0, 0, -20), Vector3(1, 1, 1)) | |
| 163 | var solo: String = persist.record("col.create", {"name": "Solo", "parent": ""}) | |
| 164 | persist.record("col.link", {"target": solo, "members": ["g:" + s]}) | |
| 165 | persist.end_gesture() | |
| 166 | persist.refold() | |
| 167 | var r7: Dictionary = lib.save_collection(solo, "Solo", false, true) | |
| 168 | var sync: Node = get_tree().root.get_node("AssetSync") | |
| 169 | _ok("L7 personal only: not in the room", String(sync.resolve("asset", "Solo")) == "" | |
| 170 | and bool(_row("Solo").get("personal", false)) and not bool(_row("Solo").get("room", true)), _row("Solo")) | |
| 171 | var sp: String = lib.place("Solo", Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, -40))) | |
| 172 | _ok("L7 placing it publishes it to the room first", String(sync.resolve("asset", "Solo")) == String(r7.get("oid", "")), sync.resolve("asset", "Solo")) | |
| 173 | _ok("L7 ... and it folds", sp != "" and _brushes_of(sp) > 0) | |
| 174 | ||
| 175 | # ---- L8 ---- | |
| 176 | # Flush first: every publish above made AssetSync request its blob, and those | |
| 177 | # content_ready signals are DEFERRED — pending since L1 (no frame has passed). Left | |
| 178 | # queued they restore the blob by another path and hide whether the fold's own fetch | |
| 179 | # works (a mutant without it passed). | |
| 180 | for i in 4: | |
| 181 | await get_tree().process_frame | |
| 182 | (persist.get("_asset_docs") as Dictionary).erase(oid2) | |
| 183 | persist.set("_brush_ver_folded", -1) | |
| 184 | persist.refold() # mine has no blob now: the wire box; the fold's | |
| 185 | for i in 4: # listener finds the file and hands it back | |
| 186 | await get_tree().process_frame | |
| 187 | _ok("L8 a lost blob is found on disk again: the pieces", persist.has_asset_blob(oid2) and _brushes_of(mine) > 1, | |
| 188 | [persist.has_asset_blob(oid2), _brushes_of(mine)]) | |
| 189 | ||
| 190 | # ---- L9 ---- | |
| 191 | var r9: Dictionary = lib.save_collection(empty, "Empty") | |
| 192 | _ok("L9 an empty collection is refused, with the reason", r9.has("error") and not _row("Empty").get("room", false), r9.get("error", "")) | |
| 193 | ||
| 194 | # ---- L10 ---- | |
| 195 | var bar_menus: Array = load("res://scenes/game/editor_top_bar.gd").MENUS | |
| 196 | var obj_menu: Array = [] | |
| 197 | for m: Array in bar_menus: | |
| 198 | if String(m[0]) == "Object": | |
| 199 | obj_menu = m[1] | |
| 200 | _ok("L10 Object menu: Save collection as asset…, menu-only", obj_menu.has("builder.save_asset") | |
| 201 | and not Keymap.binding_of("builder.save_asset").has("key"), Keymap.binding_of("builder.save_asset")) |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_asset_library.gd" id="1"] | |
| 4 | ||
| 5 | [node name="GateAssetLibrary" type="Node"] | |
| 6 | script = ExtResource("1") |