| … | … | @@ -1360,7 +1360,17 @@ Save as asset is menu-only for now). |
| 1360 | 1360 | now requires a real `false`, not `not <result>`, which passed on null). The ghost and |
| 1361 | 1361 | the live drag itself are not exercised headless (no reliable camera ray). **DONE |
| 1362 | 1362 | 2026-10-19.** |
| 1363 | 6. **Thumbnails** (SubViewport render at save). | |
| 1363 | 6. **Thumbnails** (`AssetLibrary.render_thumbnail`, after each save, async): the | |
| 1364 | collection's own brushes (cutters left out) in a SubViewport with its OWN world — | |
| 1365 | their meshes (DCLevel.get_brush_mesh, as MoveOperator's proxies) under a node with the | |
| 1366 | level's transform, the level's material, one light, a camera framing their bounds from | |
| 1367 | front-left-above — not the room with the rest hidden (that would flicker for the | |
| 1368 | player). 256 px render → 128 px PNG named by the overlay's hash, served; the catalogue | |
| 1369 | entry gains `thumb` only while it still names THIS save's oid; the personal index too; | |
| 1370 | tiles show it (fetched over the mesh if absent). Objects are not drawn yet (brushes | |
| 1371 | only). `gate_asset_thumb` (9), WINDOWED (the headless dummy renders nothing): a real | |
| 1372 | render (colours, the middle is the thing), the overlay hash, both libraries, the tile, | |
| 1373 | and a late thumbnail never clobbers a newer save. 2 mutants killed. **DONE 2026-10-19.** | |
| 1364 | 1374 | 7. **Measure**: fold cost of N placements of a 50-shape asset (the _probe_fold_split |
| 1365 | 1375 | method) — the follow-up perf issue is #128. |
| 1366 | 1376 |
| … | … | @@ -46,6 +46,12 @@ func setup(map_node: Node) -> void: |
| 46 | 46 | var p: Node = map.get("_persist") if map != null else null |
| 47 | 47 | if p != null: |
| 48 | 48 | p.folded.connect(func(_st: Dictionary) -> void: _dirty = true) |
| 49 | var lib: Node = map.asset_library() if map != null and map.has_method("asset_library") else null | |
| 50 | if lib != null and lib.has_signal("thumbnail_ready"): | |
| 51 | lib.thumbnail_ready.connect(func(_n: String, _t: String) -> void: _dirty = true) | |
| 52 | var co := get_node_or_null("/root/ContentOverlay") | |
| 53 | if co != null and co.has_signal("content_ready"): | |
| 54 | co.content_ready.connect(_on_content_ready) | |
| 49 | 55 | |
| 50 | 56 | |
| 51 | 57 | func _on_state_changed(key: String, _value: Variant, _channel: int) -> void: |
| … | … | @@ -154,6 +160,11 @@ func refresh() -> void: |
| 154 | 160 | b.clip_text = true |
| 155 | 161 | b.focus_mode = Control.FOCUS_NONE |
| 156 | 162 | b.tooltip_text = "%s\n%s%s" % [n, "room" if bool(r["room"]) else "", " · mine" if bool(r["personal"]) else ""] |
| 163 | var tex := _thumb(String(r.get("thumb", ""))) | |
| 164 | if tex != null: | |
| 165 | b.icon = tex | |
| 166 | b.expand_icon = true | |
| 167 | b.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP | |
| 157 | 168 | b.pressed.connect(pick.bind(n)) |
| 158 | 169 | b.set_drag_forwarding(_tile_drag.bind(n), Callable(), Callable()) |
| 159 | 170 | col.add_child(b) |
| … | … | @@ -171,6 +182,32 @@ func refresh() -> void: |
| 171 | 182 | _current.text = "current: %s" % (cur if cur != "" else "—") |
| 172 | 183 | |
| 173 | 184 | |
| 185 | var _wanted_thumbs := {} | |
| 186 | ||
| 187 | ||
| 188 | func _on_content_ready(content_hash: String, _path: String) -> void: | |
| 189 | if _wanted_thumbs.has(content_hash): | |
| 190 | _dirty = true | |
| 191 | ||
| 192 | ||
| 193 | ## A tile's thumbnail: the PNG named by `thumb` (§15.6) if it is here, else asked for over | |
| 194 | ## the mesh (the tile redraws when it lands). null without one. | |
| 195 | func _thumb(thumb: String) -> Texture2D: | |
| 196 | if thumb.length() != 64: | |
| 197 | return null | |
| 198 | var path := "user://content/%s.png" % thumb | |
| 199 | if not FileAccess.file_exists(path): | |
| 200 | var co := get_node_or_null("/root/ContentOverlay") | |
| 201 | if co != null and not _wanted_thumbs.has(thumb): | |
| 202 | _wanted_thumbs[thumb] = true | |
| 203 | co.request(thumb, thumb + ".png") | |
| 204 | return null | |
| 205 | var img := Image.new() | |
| 206 | if img.load_png_from_buffer(FileAccess.get_file_as_bytes(path)) != OK: | |
| 207 | return null | |
| 208 | return ImageTexture.create_from_image(img) | |
| 209 | ||
| 210 | ||
| 174 | 211 | func _process(_delta: float) -> void: |
| 175 | 212 | if _dirty and is_visible_in_tree(): |
| 176 | 213 | refresh() |
| … | … | @@ -75,6 +75,8 @@ func save_collection(col: String, name: String, room: bool = true, personal: boo |
| 75 | 75 | followed = _follow_mine(name, oid) |
| 76 | 76 | if personal: |
| 77 | 77 | _save_personal(name, oid, bytes) |
| 78 | # THE THUMBNAIL (§15.6) renders over the next frames; the tile shows it when it lands. | |
| 79 | _thumb_after_save.call_deferred(name, oid, collection_brushes(col), room, personal) | |
| 78 | 80 | return {"oid": oid, "report": w["report"], "followed": followed} |
| 79 | 81 | |
| 80 | 82 | |
| … | … | @@ -124,7 +126,7 @@ func _save_personal(name: String, oid: String, bytes: PackedByteArray) -> void: |
| 124 | 126 | _write(dir.path_join(oid + "." + AssetDoc.EXT), bytes) |
| 125 | 127 | var idx := personal_index() |
| 126 | 128 | 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())}) | |
| 129 | rows.append({"name": name, "oid": oid, "saved": int(Time.get_unix_time_from_system()), "thumb": ""}) | |
| 128 | 130 | var f := FileAccess.open(dir.path_join("index.json"), FileAccess.WRITE) |
| 129 | 131 | if f != null: |
| 130 | 132 | f.store_string(JSON.stringify({"assets": rows}, "\t")) |
| … | … | @@ -144,7 +146,8 @@ static func personal_index() -> Array: |
| 144 | 146 | for r: Variant in (v as Dictionary)["assets"]: |
| 145 | 147 | if r is Dictionary and String((r as Dictionary).get("name", "")) != "" \ |
| 146 | 148 | 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))}) | |
| 149 | out.append({"name": String(r["name"]), "oid": String(r["oid"]), "saved": int(r.get("saved", 0)), | |
| 150 | "thumb": String(r.get("thumb", ""))}) | |
| 148 | 151 | return out |
| 149 | 152 | |
| 150 | 153 | |
| … | … | @@ -159,13 +162,16 @@ func rows() -> Array: |
| 159 | 162 | if sync != null: |
| 160 | 163 | for r: Dictionary in (sync.list(KIND) as Array): |
| 161 | 164 | by[String(r["name"])] = {"name": String(r["name"]), "oid": String(r["oid"]), "room": true, |
| 162 | "personal": false, "older": 0, "bounds": r.get("bounds", [])} | |
| 165 | "personal": false, "older": 0, "bounds": r.get("bounds", []), "thumb": String(r.get("thumb", ""))} | |
| 163 | 166 | for r2: Dictionary in personal_index(): |
| 164 | 167 | var n := String(r2["name"]) |
| 165 | 168 | if by.has(n): |
| 166 | 169 | by[n]["personal"] = true |
| 170 | if String(by[n].get("thumb", "")) == "": | |
| 171 | by[n]["thumb"] = String(r2.get("thumb", "")) | |
| 167 | 172 | else: |
| 168 | by[n] = {"name": n, "oid": String(r2["oid"]), "room": false, "personal": true, "older": 0, "bounds": []} | |
| 173 | by[n] = {"name": n, "oid": String(r2["oid"]), "room": false, "personal": true, "older": 0, "bounds": [], | |
| 174 | "thumb": String(r2.get("thumb", ""))} | |
| 169 | 175 | if persist != null: |
| 170 | 176 | for p: Dictionary in (persist.placements() as Dictionary).values(): |
| 171 | 177 | var n2 := String(p["name"]) |
| … | … | @@ -256,3 +262,129 @@ func _load(path: String, oid: String) -> bool: |
| 256 | 262 | func _on_content_ready(content_hash: String, path: String) -> void: |
| 257 | 263 | if persist != null and path.ends_with("." + AssetDoc.EXT) and not persist.has_asset_blob(content_hash): |
| 258 | 264 | _load(path, content_hash) |
| 265 | ||
| 266 | ||
| 267 | # ---- thumbnails (§15.6) --------------------------------------------------------------- | |
| 268 | ||
| 269 | const THUMB_PX := 128 | |
| 270 | const THUMB_RENDER_PX := 256 | |
| 271 | signal thumbnail_ready(name: String, thumb: String) | |
| 272 | ||
| 273 | ||
| 274 | ## The live brushes the collection's subtree folds to (shapes, brush objects, placements), | |
| 275 | ## cutters (wire) left out: what its thumbnail shows. | |
| 276 | func collection_brushes(col: String) -> PackedInt32Array: | |
| 277 | var out := PackedInt32Array() | |
| 278 | var level: Node = persist.get("level") | |
| 279 | if level == null: | |
| 280 | return out | |
| 281 | var t: Dictionary = persist.collection_tree() | |
| 282 | for m: String in AssetDoc.CollectionFold.members_of(t, col, true): | |
| 283 | var ids := PackedInt32Array() | |
| 284 | if m.begins_with("g:"): | |
| 285 | ids = level.get_group_brush_ids(m.substr(2)) | |
| 286 | elif m.begins_with("a:") and map.has_method("asset_brush_ids"): | |
| 287 | ids = map.asset_brush_ids(m.substr(2)) | |
| 288 | for b: int in ids: | |
| 289 | if not bool(level.get_brush(b).get("cutter", false)): | |
| 290 | out.append(b) | |
| 291 | return out | |
| 292 | ||
| 293 | ||
| 294 | ## Render `ids` alone — a world of their own (a SubViewport with own_world_3d), the level's | |
| 295 | ## material, one light, a camera framing their bounds from front-left-above — to a | |
| 296 | ## THUMB_PX PNG named by the overlay's hash and served. Async (two frames). "" when there | |
| 297 | ## is nothing to draw or the renderer gives no image (the headless dummy). | |
| 298 | func render_thumbnail(ids: PackedInt32Array) -> String: | |
| 299 | var level: Node = persist.get("level") | |
| 300 | if level == null or ids.is_empty(): | |
| 301 | return "" | |
| 302 | var bb: Dictionary = level.get_brushes_bounds(ids) | |
| 303 | if int(bb["count"]) == 0: | |
| 304 | return "" | |
| 305 | var vp := SubViewport.new() | |
| 306 | vp.size = Vector2i(THUMB_RENDER_PX, THUMB_RENDER_PX) | |
| 307 | vp.own_world_3d = true | |
| 308 | vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS | |
| 309 | add_child(vp) | |
| 310 | var space := Node3D.new() # level space: the brush meshes and bounds live there | |
| 311 | vp.add_child(space) | |
| 312 | space.transform = (level as Node3D).global_transform | |
| 313 | var lm: Node = level.get_node_or_null("LevelMesh") | |
| 314 | var mat: Material = (lm as MeshInstance3D).material_override if lm is MeshInstance3D else null | |
| 315 | for b: int in ids: | |
| 316 | var mesh: Mesh = level.get_brush_mesh(b) | |
| 317 | if mesh == null or mesh.get_surface_count() == 0: | |
| 318 | continue | |
| 319 | var mi := MeshInstance3D.new() | |
| 320 | mi.mesh = mesh | |
| 321 | mi.material_override = mat | |
| 322 | space.add_child(mi) | |
| 323 | var lo: Vector3 = bb["aabb_min"] | |
| 324 | var hi: Vector3 = bb["aabb_max"] | |
| 325 | var c := (lo + hi) * 0.5 | |
| 326 | var r := maxf((hi - lo).length() * 0.5, 0.05) | |
| 327 | var cam := Camera3D.new() | |
| 328 | cam.fov = 40.0 | |
| 329 | cam.near = r * 0.05 | |
| 330 | cam.far = r * 20.0 | |
| 331 | space.add_child(cam) | |
| 332 | var eye := c + Vector3(-1.0, 0.8, 1.0).normalized() * r / sin(deg_to_rad(cam.fov * 0.5)) * 1.1 | |
| 333 | cam.look_at_from_position(eye, c, Vector3.UP) | |
| 334 | cam.current = true | |
| 335 | var sun := DirectionalLight3D.new() | |
| 336 | space.add_child(sun) | |
| 337 | sun.look_at_from_position(c + Vector3(-1, 2, 1.5), c, Vector3.UP) | |
| 338 | var env := WorldEnvironment.new() | |
| 339 | env.environment = Environment.new() | |
| 340 | env.environment.background_mode = Environment.BG_COLOR | |
| 341 | env.environment.background_color = Color(0.16, 0.17, 0.19) | |
| 342 | env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR | |
| 343 | env.environment.ambient_light_color = Color(0.55, 0.55, 0.6) | |
| 344 | space.add_child(env) | |
| 345 | await RenderingServer.frame_post_draw | |
| 346 | await RenderingServer.frame_post_draw | |
| 347 | var img: Image = vp.get_texture().get_image() if vp.get_texture() != null else null | |
| 348 | vp.queue_free() | |
| 349 | if img == null or img.is_empty(): | |
| 350 | return "" | |
| 351 | img.resize(THUMB_PX, THUMB_PX, Image.INTERPOLATE_LANCZOS) | |
| 352 | var bytes := img.save_png_to_buffer() | |
| 353 | var tmp := CACHE_DIR.path_join(".thumb.tmp.png") | |
| 354 | if not _write(tmp, bytes): | |
| 355 | return "" | |
| 356 | var co := _overlay() | |
| 357 | var h := String(co.content_hash_of_file(tmp)) if co != null else "" | |
| 358 | DirAccess.remove_absolute(ProjectSettings.globalize_path(tmp)) | |
| 359 | if h == "" or not _write(thumb_path(h), bytes): | |
| 360 | return "" | |
| 361 | co.publish(thumb_path(h)) | |
| 362 | return h | |
| 363 | ||
| 364 | ||
| 365 | static func thumb_path(thumb: String) -> String: | |
| 366 | return CACHE_DIR.path_join(thumb + ".png") | |
| 367 | ||
| 368 | ||
| 369 | func _thumb_after_save(name: String, oid: String, ids: PackedInt32Array, room: bool, personal: bool) -> void: | |
| 370 | var th := await render_thumbnail(ids) | |
| 371 | if th == "": | |
| 372 | return | |
| 373 | var sync := _sync() | |
| 374 | if room and sync != null: | |
| 375 | # Only while the room still names THIS save: a peer's newer one is never clobbered. | |
| 376 | var v: Variant = sync.state.get_state("world:assets:%s:%s" % [KIND, name], null) | |
| 377 | if v is Dictionary and String((v as Dictionary).get("oid", "")) == oid: | |
| 378 | var extra := (v as Dictionary).duplicate() | |
| 379 | extra["thumb"] = th | |
| 380 | sync.publish_asset(KIND, name, oid, int(extra.get("size", 0)), AssetDoc.EXT, extra) | |
| 381 | if personal: | |
| 382 | var rows := personal_index() | |
| 383 | for r: Dictionary in rows: | |
| 384 | if String(r["name"]) == name and String(r["oid"]) == oid: | |
| 385 | r["thumb"] = th | |
| 386 | var f := FileAccess.open(personal_dir().path_join("index.json"), FileAccess.WRITE) | |
| 387 | if f != null: | |
| 388 | f.store_string(JSON.stringify({"assets": rows}, "\t")) | |
| 389 | f.close() | |
| 390 | thumbnail_ready.emit(name, th) |
| … | … | @@ -0,0 +1,125 @@ |
| 1 | extends Node | |
| 2 | ## GATE — ASSET THUMBNAILS (#120 L4 step 6, docs/node-tree-and-stacks.md §15.6). WINDOWED: | |
| 3 | ## it needs a real renderer (the headless dummy draws nothing). A collection "Kit" (a box | |
| 4 | ## with a mirror + a stair) saved; its thumbnail renders over the next frames. | |
| 5 | ## T1 a save yields a thumbnail (thumbnail_ready) | |
| 6 | ## T2 a 128 px PNG on disk, named by the CONTENT OVERLAY's hash of it | |
| 7 | ## T3 it is a RENDER of the thing: many colours, and the middle is not background | |
| 8 | ## T4 the room's catalogue entry and the personal index carry it | |
| 9 | ## T5 the browser's tile shows it | |
| 10 | ## T6 a late thumbnail never clobbers a NEWER save's entry (only its own oid's) | |
| 11 | ## | |
| 12 | ## RUN WINDOWED: CI=true DISPLAY=:0 HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --path . res://scenes/test/gate_asset_thumb.tscn | |
| 13 | ||
| 14 | const AssetLibrary := preload("res://scenes/maps/asset_library.gd") | |
| 15 | ||
| 16 | var fails := 0 | |
| 17 | var checks := 0 | |
| 18 | var map: Node3D | |
| 19 | var persist: Node | |
| 20 | var lib: Node | |
| 21 | var _thumb := "" | |
| 22 | ||
| 23 | ||
| 24 | func _ok(what: String, cond: bool, note: Variant = "") -> void: | |
| 25 | checks += 1 | |
| 26 | if not cond: | |
| 27 | fails += 1 | |
| 28 | print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)]) | |
| 29 | ||
| 30 | ||
| 31 | func _ready() -> void: | |
| 32 | var gs: Node = get_tree().root.get_node_or_null("/root/GameState") | |
| 33 | gs.route("world:level:*", 1, "peer") | |
| 34 | var keet: Node = get_tree().root.get_node_or_null("/root/KeetService") | |
| 35 | if keet != null: | |
| 36 | keet.current_member_id = "probe" | |
| 37 | get_tree().root.get_node("/root/GameMode").set_mode("build") | |
| 38 | var pd := AssetLibrary.personal_dir() | |
| 39 | if pd.ends_with("_ci"): | |
| 40 | for f: String in DirAccess.get_files_at(pd): | |
| 41 | DirAccess.remove_absolute(ProjectSettings.globalize_path(pd.path_join(f))) | |
| 42 | map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate() | |
| 43 | add_child(map) | |
| 44 | for i in 6: | |
| 45 | await get_tree().process_frame | |
| 46 | persist = map.get("_persist") | |
| 47 | lib = map.asset_library() | |
| 48 | await _run() | |
| 49 | if checks < 8: | |
| 50 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) | |
| 51 | fails += 1 | |
| 52 | print("\nGATE asset_thumb: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) | |
| 53 | get_tree().quit(0 if fails == 0 else 1) | |
| 54 | ||
| 55 | ||
| 56 | func _on_thumb(_n: String, t: String) -> void: | |
| 57 | _thumb = t | |
| 58 | ||
| 59 | ||
| 60 | func _run() -> void: | |
| 61 | var O0 := Vector3(950.137, 0.31, 949.873) | |
| 62 | persist.begin_gesture("room") | |
| 63 | var a: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {}, | |
| 64 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0)), "size": [2, 2, 2], "material_id": 2, "level_id": 0}) | |
| 65 | persist.record("mod.create", {"target": a, "mod": "mirror", "params": {"axis": "x", "offset": 0.0}}) | |
| 66 | var st: String = persist.record("shape.create", {"type": "stairs", "gv": 1, "params": {}, | |
| 67 | "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, O0 + Vector3(0, 0, 4))), "size": [2, 2, 3], | |
| 68 | "material_id": 2, "level_id": 0}) | |
| 69 | var kit: String = persist.record("col.create", {"name": "Kit", "parent": ""}) | |
| 70 | persist.record("col.link", {"target": kit, "members": ["g:" + a, "g:" + st]}) | |
| 71 | persist.end_gesture() | |
| 72 | persist.refold() | |
| 73 | lib.thumbnail_ready.connect(_on_thumb) | |
| 74 | var r: Dictionary = lib.save_collection(kit, "Kit") | |
| 75 | var oid := String(r.get("oid", "")) | |
| 76 | for i in 120: | |
| 77 | if _thumb != "": | |
| 78 | break | |
| 79 | await get_tree().process_frame | |
| 80 | ||
| 81 | # ---- T1 / T2 ---- | |
| 82 | _ok("T1 a save yields a thumbnail", _thumb.length() == 64, _thumb) | |
| 83 | var path: String = AssetLibrary.thumb_path(_thumb) | |
| 84 | var img := Image.new() | |
| 85 | var loaded := FileAccess.file_exists(path) and img.load_png_from_buffer(FileAccess.get_file_as_bytes(path)) == OK | |
| 86 | _ok("T2 a 128 px PNG on disk", loaded and img.get_width() == 128 and img.get_height() == 128, | |
| 87 | [img.get_width(), img.get_height()] if loaded else path) | |
| 88 | var co: Node = get_tree().root.get_node("ContentOverlay") | |
| 89 | _ok("T2 ... named by the overlay's own hash of it", loaded and String(co.content_hash_of_file(path)) == _thumb) | |
| 90 | ||
| 91 | # ---- T3 ---- | |
| 92 | var colours := {} | |
| 93 | var bg := Color(0.16, 0.17, 0.19) | |
| 94 | var not_bg := 0 | |
| 95 | if loaded: | |
| 96 | img.convert(Image.FORMAT_RGBA8) | |
| 97 | for y in range(0, 128, 2): | |
| 98 | for x in range(0, 128, 2): | |
| 99 | var c := img.get_pixel(x, y) | |
| 100 | colours[c.to_html(false)] = true | |
| 101 | for y2 in range(40, 88): | |
| 102 | for x2 in range(40, 88): | |
| 103 | var c2 := img.get_pixel(x2, y2) | |
| 104 | if absf(c2.r - bg.r) + absf(c2.g - bg.g) + absf(c2.b - bg.b) > 0.08: | |
| 105 | not_bg += 1 | |
| 106 | _ok("T3 it is a render: many colours", colours.size() > 20, colours.size()) | |
| 107 | _ok("T3 ... and the middle is the thing, not background", not_bg > 48 * 48 / 4, not_bg) | |
| 108 | ||
| 109 | # ---- T4 ---- | |
| 110 | var sync: Node = get_tree().root.get_node("AssetSync") | |
| 111 | var e: Variant = sync.state.get_state("world:assets:asset:Kit", null) | |
| 112 | _ok("T4 the room's catalogue entry carries it", e is Dictionary and String((e as Dictionary).get("thumb", "")) == _thumb, e) | |
| 113 | _ok("T4 the personal index carries it", AssetLibrary.personal_index().any(func(x: Dictionary) -> bool: | |
| 114 | return String(x["oid"]) == oid and String(x["thumb"]) == _thumb)) | |
| 115 | ||
| 116 | # ---- T5 ---- | |
| 117 | var ab: Node = map.get("_asset_browser") | |
| 118 | ab.refresh() | |
| 119 | _ok("T5 the browser's tile shows it", ab.tile("Kit") != null and ab.tile("Kit").icon != null) | |
| 120 | ||
| 121 | # ---- T6 ---- | |
| 122 | var before: Variant = sync.state.get_state("world:assets:asset:Kit", null) | |
| 123 | await lib._thumb_after_save("Kit", "0".repeat(64), lib.collection_brushes(kit), true, false) | |
| 124 | _ok("T6 a thumbnail for another oid leaves the entry alone", | |
| 125 | str(sync.state.get_state("world:assets:asset:Kit", null)) == str(before)) |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | [gd_scene load_steps=2 format=3] | |
| 2 | ||
| 3 | [ext_resource type="Script" path="res://scenes/test/gate_asset_thumb.gd" id="1"] | |
| 4 | ||
| 5 | [node name="GateAssetThumb" type="Node"] | |
| 6 | script = ExtResource("1") |