OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 12daecf

#120 L4 §15 step 6: asset thumbnails, rendered at save
After a save, the collection's brushes render alone in a SubViewport with its own world (their meshes under the level's transform, the level's material, a light, a camera framing their bounds) — no flicker in the room. 128 px PNG named by the overlay's hash, served; the room's entry and the personal index gain `thumb` (the room's only while it still names this save's oid). Browser tiles show it, fetching it over the mesh if absent. gate_asset_thumb 9/9 (windowed: real pixels); 2 mutants killed. Headless asset gates green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12daecf944f79a9b291e35ff0e313762377ead89
by plankatron <fleeky@gmail.com> · 2026-10-19T23:40:28.000Z

Changed files (5) +315 -5

docs/node-tree-and-stacks.md +11 -1
……@@ -1360,7 +1360,17 @@ Save as asset is menu-only for now).
13601360 now requires a real `false`, not `not <result>`, which passed on null). The ghost and
13611361 the live drag itself are not exercised headless (no reliable camera ray). **DONE
13621362 2026-10-19.**
13636. **Thumbnails** (SubViewport render at save).
13636. **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.**
136413747. **Measure**: fold cost of N placements of a 50-shape asset (the _probe_fold_split
13651375 method) — the follow-up perf issue is #128.
13661376
scenes/game/asset_browser.gd +37 -0
……@@ -46,6 +46,12 @@ func setup(map_node: Node) -> void:
4646 var p: Node = map.get("_persist") if map != null else null
4747 if p != null:
4848 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)
4955
5056
5157func _on_state_changed(key: String, _value: Variant, _channel: int) -> void:
……@@ -154,6 +160,11 @@ func refresh() -> void:
154160 b.clip_text = true
155161 b.focus_mode = Control.FOCUS_NONE
156162 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
157168 b.pressed.connect(pick.bind(n))
158169 b.set_drag_forwarding(_tile_drag.bind(n), Callable(), Callable())
159170 col.add_child(b)
……@@ -171,6 +182,32 @@ func refresh() -> void:
171182 _current.text = "current: %s" % (cur if cur != "" else "—")
172183
173184
185var _wanted_thumbs := {}
186
187
188func _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.
195func _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
174211func _process(_delta: float) -> void:
175212 if _dirty and is_visible_in_tree():
176213 refresh()
scenes/maps/asset_library.gd +136 -4
……@@ -75,6 +75,8 @@ func save_collection(col: String, name: String, room: bool = true, personal: boo
7575 followed = _follow_mine(name, oid)
7676 if personal:
7777 _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)
7880 return {"oid": oid, "report": w["report"], "followed": followed}
7981
8082
……@@ -124,7 +126,7 @@ func _save_personal(name: String, oid: String, bytes: PackedByteArray) -> void:
124126 _write(dir.path_join(oid + "." + AssetDoc.EXT), bytes)
125127 var idx := personal_index()
126128 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": ""})
128130 var f := FileAccess.open(dir.path_join("index.json"), FileAccess.WRITE)
129131 if f != null:
130132 f.store_string(JSON.stringify({"assets": rows}, "\t"))
……@@ -144,7 +146,8 @@ static func personal_index() -> Array:
144146 for r: Variant in (v as Dictionary)["assets"]:
145147 if r is Dictionary and String((r as Dictionary).get("name", "")) != "" \
146148 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", ""))})
148151 return out
149152
150153
……@@ -159,13 +162,16 @@ func rows() -> Array:
159162 if sync != null:
160163 for r: Dictionary in (sync.list(KIND) as Array):
161164 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", ""))}
163166 for r2: Dictionary in personal_index():
164167 var n := String(r2["name"])
165168 if by.has(n):
166169 by[n]["personal"] = true
170 if String(by[n].get("thumb", "")) == "":
171 by[n]["thumb"] = String(r2.get("thumb", ""))
167172 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", ""))}
169175 if persist != null:
170176 for p: Dictionary in (persist.placements() as Dictionary).values():
171177 var n2 := String(p["name"])
……@@ -256,3 +262,129 @@ func _load(path: String, oid: String) -> bool:
256262func _on_content_ready(content_hash: String, path: String) -> void:
257263 if persist != null and path.ends_with("." + AssetDoc.EXT) and not persist.has_asset_blob(content_hash):
258264 _load(path, content_hash)
265
266
267# ---- thumbnails (§15.6) ---------------------------------------------------------------
268
269const THUMB_PX := 128
270const THUMB_RENDER_PX := 256
271signal 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.
276func 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).
298func 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
365static func thumb_path(thumb: String) -> String:
366 return CACHE_DIR.path_join(thumb + ".png")
367
368
369func _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)
scenes/test/gate_asset_thumb.gd +125 -0
……@@ -0,0 +1,125 @@
1extends 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
14const AssetLibrary := preload("res://scenes/maps/asset_library.gd")
15
16var fails := 0
17var checks := 0
18var map: Node3D
19var persist: Node
20var lib: Node
21var _thumb := ""
22
23
24func _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
31func _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
56func _on_thumb(_n: String, t: String) -> void:
57 _thumb = t
58
59
60func _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))
scenes/test/gate_asset_thumb.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_asset_thumb.gd" id="1"]
4
5[node name="GateAssetThumb" type="Node"]
6script = ExtResource("1")