extends Node
## THE ASSET LIBRARIES (#120 L4 §15.4): the ROOM's and a PERSONAL one, and the fetch that
## feeds placed assets' blobs to the fold. A child of the map (MapDCLevel.asset_library()).
##
## ROOM blob bytes served by ContentOverlay (publish), named in the room by AssetSync
## kind "asset": world:assets:asset:<name> -> {oid, size, ext, bounds} (newest
## wins per name, as for models). AssetSync fetches every entry it sees into
## user://content/<oid>.hasset; _on_content_ready hands it to the fold.
## PERSONAL user://assets/<oid>.hasset + index.json {assets: [{name, oid, saved}]} on
## this machine, any room. Placing one in a room publishes it there first.
## (Under CI: user://assets_ci — a gate never writes the player's library.)
##
## SAVE (§15.4, user 2026-10-19): AssetDoc of the collection -> blob -> both libraries.
## Saving under a name that exists makes a new oid; placements YOU made of that name follow
## (asset.set, one gesture: one Ctrl+Z undoes it), others' stay until Update placements.
##
## DELETION STORY: nothing here deletes a blob. Re-saving replaces the personal index's
## entry for the name, and the old file stays (a placement may pin it).
const AssetDoc := preload("res://scenes/maps/asset_doc.gd")
const KIND := "asset"
const CACHE_DIR := "user://content"
var map: Node = null
var persist: Node = null
func setup(map_node: Node, persistence: Node) -> void:
map = map_node
persist = persistence
var co := _overlay()
if co != null and not co.content_ready.is_connected(_on_content_ready):
co.content_ready.connect(_on_content_ready)
if persist != null and not persist.folded.is_connected(_on_folded):
persist.folded.connect(_on_folded)
static func personal_dir() -> String:
return "user://assets_ci" if OS.get_environment("CI") != "" else "user://assets"
func _overlay() -> Node:
return get_node_or_null("/root/ContentOverlay")
func _sync() -> Node:
return get_node_or_null("/root/AssetSync")
static func blob_path(oid: String) -> String:
return CACHE_DIR.path_join(oid + "." + AssetDoc.EXT)
# ---- save ---------------------------------------------------------------------
## Save collection `col` as asset `name`. {oid, report, followed} or {error, report}.
func save_collection(col: String, name: String, room: bool = true, personal: bool = true) -> Dictionary:
if persist == null:
return {"error": "no journal", "report": []}
var w: Dictionary = AssetDoc.write(persist, col, name)
var doc: Dictionary = w["doc"]
if doc.is_empty():
return {"error": "nothing in the collection an asset can carry", "report": w["report"]}
var bytes := AssetDoc.encode(doc)
var oid := AssetDoc.hash_of(bytes)
if oid == "":
return {"error": "the content overlay is not running — cannot name the asset", "report": w["report"]}
if not _write(blob_path(oid), bytes):
return {"error": "cannot write %s" % blob_path(oid), "report": w["report"]}
persist.put_asset_blob(oid, bytes)
var followed := 0
if room:
if not _publish_room(name, oid, bytes.size(), doc):
return {"error": "the room's library refused it", "report": w["report"]}
followed = _follow_mine(name, oid)
if personal:
_save_personal(name, oid, bytes)
# THE THUMBNAIL (§15.6) renders over the next frames; the tile shows it when it lands.
_thumb_after_save.call_deferred(name, oid, collection_brushes(col), room, personal)
return {"oid": oid, "report": w["report"], "followed": followed}
func _write(path: String, bytes: PackedByteArray) -> bool:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir()))
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return false
f.store_buffer(bytes)
f.close()
return true
func _publish_room(name: String, oid: String, size: int, doc: Dictionary) -> bool:
var co := _overlay()
var sync := _sync()
if co == null or sync == null:
return false
if String(co.publish(blob_path(oid))) != oid:
return false
var b: Array = doc["bounds"]
return String(sync.publish_asset(KIND, name, oid, size, AssetDoc.EXT,
{"bounds": [(b[0] as Vector3).x, (b[0] as Vector3).y, (b[0] as Vector3).z,
(b[1] as Vector3).x, (b[1] as Vector3).y, (b[1] as Vector3).z]})) == oid
## My placements of `name` on another version follow the new one: ONE gesture.
func _follow_mine(name: String, oid: String) -> int:
var me := String(persist.call("_author"))
var mine: Array = []
for p: Dictionary in (persist.placements() as Dictionary).values():
if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid \
and String(p["author"]) == me:
mine.append(String(p["dot"]))
if mine.is_empty():
return 0
persist.begin_gesture("Save asset: %s" % name)
for d: String in mine:
persist.record("asset.set", {"target": d, "asset": oid})
persist.end_gesture()
persist.refold()
return mine.size()
func _save_personal(name: String, oid: String, bytes: PackedByteArray) -> void:
var dir := personal_dir()
_write(dir.path_join(oid + "." + AssetDoc.EXT), bytes)
var idx := personal_index()
var rows: Array = idx.filter(func(r: Dictionary) -> bool: return String(r["name"]) != name)
rows.append({"name": name, "oid": oid, "saved": int(Time.get_unix_time_from_system()), "thumb": ""})
var f := FileAccess.open(dir.path_join("index.json"), FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify({"assets": rows}, "\t"))
f.close()
## The personal library: [{name, oid, saved}].
static func personal_index() -> Array:
var f := FileAccess.open(personal_dir().path_join("index.json"), FileAccess.READ)
if f == null:
return []
var v: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (v is Dictionary) or not ((v as Dictionary).get("assets") is Array):
return []
var out: Array = []
for r: Variant in (v as Dictionary)["assets"]:
if r is Dictionary and String((r as Dictionary).get("name", "")) != "" \
and String((r as Dictionary).get("oid", "")).length() == 64:
out.append({"name": String(r["name"]), "oid": String(r["oid"]), "saved": int(r.get("saved", 0)),
"thumb": String(r.get("thumb", ""))})
return out
# ---- list / place / update ---------------------------------------------------------
## Both libraries, by name: [{name, oid, room, personal, older}] — `room` / `personal`
## whether each holds the name (their oids can differ: the room's is shown, else yours);
## `older` = live placements of the name on another version than the room's.
func rows() -> Array:
var by := {}
var sync := _sync()
if sync != null:
for r: Dictionary in (sync.list(KIND) as Array):
by[String(r["name"])] = {"name": String(r["name"]), "oid": String(r["oid"]), "room": true,
"personal": false, "older": 0, "bounds": r.get("bounds", []), "thumb": String(r.get("thumb", ""))}
for r2: Dictionary in personal_index():
var n := String(r2["name"])
if by.has(n):
by[n]["personal"] = true
if String(by[n].get("thumb", "")) == "":
by[n]["thumb"] = String(r2.get("thumb", ""))
else:
by[n] = {"name": n, "oid": String(r2["oid"]), "room": false, "personal": true, "older": 0, "bounds": [],
"thumb": String(r2.get("thumb", ""))}
if persist != null:
for p: Dictionary in (persist.placements() as Dictionary).values():
var n2 := String(p["name"])
if not bool(p["removed"]) and by.has(n2) and bool(by[n2]["room"]) and String(p["asset"]) != String(by[n2]["oid"]):
by[n2]["older"] = int(by[n2]["older"]) + 1
var names: Array = by.keys()
names.sort()
return names.map(func(n3: String) -> Dictionary: return by[n3])
## Place asset `name` at `frame`, ONE gesture. A personal-only asset is published to the
## room first (blob + catalogue), so every peer can fetch what it is about to see.
## Returns the placement's dot, or "".
func place(name: String, frame: Transform3D) -> String:
var row: Dictionary = {}
for r: Dictionary in rows():
if String(r["name"]) == name:
row = r
if row.is_empty():
return ""
var oid := String(row["oid"])
if not bool(row["room"]):
var bytes := FileAccess.get_file_as_bytes(personal_dir().path_join(oid + "." + AssetDoc.EXT))
if AssetDoc.hash_of(bytes) != oid or not _write(blob_path(oid), bytes):
return ""
var doc := AssetDoc.decode(bytes)
if doc.is_empty() or not _publish_room(name, oid, bytes.size(), doc):
return ""
persist.put_asset_blob(oid, bytes)
persist.begin_gesture("Place asset: %s" % name)
var dot: String = persist.record("asset.place", {"asset": oid, "name": name,
"frame": SceneObjects.xform_to(frame)})
persist.end_gesture()
persist.refold()
return dot
## Update placements: every live placement of `name` on another version -> the room's, ONE
## gesture. Returns how many.
func update_placements(name: String) -> int:
var oid := ""
for r: Dictionary in rows():
if String(r["name"]) == name and bool(r["room"]):
oid = String(r["oid"])
if oid == "":
return 0
var todo: Array = []
for p: Dictionary in (persist.placements() as Dictionary).values():
if not bool(p["removed"]) and String(p["name"]) == name and String(p["asset"]) != oid:
todo.append(String(p["dot"]))
if todo.is_empty():
return 0
persist.begin_gesture("Update placements: %s" % name)
for d: String in todo:
persist.record("asset.set", {"target": d, "asset": oid})
persist.end_gesture()
persist.refold()
return todo.size()
# ---- fetch: blobs the fold is waiting for ------------------------------------------
## After every fold: hand the fold any blob it is missing that is on disk (the room's
## cache, then the personal library), and ask the mesh for the rest.
func _on_folded(_st: Dictionary) -> void:
fetch_missing()
func fetch_missing() -> int:
if persist == null:
return 0
var asked := 0
for oid: String in (persist.missing_assets() as Array):
if _load(blob_path(oid), oid) or _load(personal_dir().path_join(oid + "." + AssetDoc.EXT), oid):
continue
var co := _overlay()
if co != null and String(co.request(oid, oid + "." + AssetDoc.EXT)) != "":
asked += 1
return asked
func _load(path: String, oid: String) -> bool:
if not FileAccess.file_exists(path):
return false
return persist.put_asset_blob(oid, FileAccess.get_file_as_bytes(path))
func _on_content_ready(content_hash: String, path: String) -> void:
if persist != null and path.ends_with("." + AssetDoc.EXT) and not persist.has_asset_blob(content_hash):
_load(path, content_hash)
# ---- thumbnails (§15.6) ---------------------------------------------------------------
const THUMB_PX := 128
const THUMB_RENDER_PX := 256
signal thumbnail_ready(name: String, thumb: String)
## The live brushes the collection's subtree folds to (shapes, brush objects, placements),
## cutters (wire) left out: what its thumbnail shows.
func collection_brushes(col: String) -> PackedInt32Array:
var out := PackedInt32Array()
var level: Node = persist.get("level")
if level == null:
return out
var t: Dictionary = persist.collection_tree()
for m: String in AssetDoc.CollectionFold.members_of(t, col, true):
var ids := PackedInt32Array()
if m.begins_with("g:"):
ids = level.get_group_brush_ids(m.substr(2))
elif m.begins_with("a:") and map.has_method("asset_brush_ids"):
ids = map.asset_brush_ids(m.substr(2))
for b: int in ids:
if not bool(level.get_brush(b).get("cutter", false)):
out.append(b)
return out
## Render `ids` alone — a world of their own (a SubViewport with own_world_3d), the level's
## material, one light, a camera framing their bounds from front-left-above — to a
## THUMB_PX PNG named by the overlay's hash and served. Async (two frames). "" when there
## is nothing to draw or the renderer gives no image (the headless dummy).
func render_thumbnail(ids: PackedInt32Array) -> String:
var level: Node = persist.get("level")
if level == null or ids.is_empty():
return ""
var bb: Dictionary = level.get_brushes_bounds(ids)
if int(bb["count"]) == 0:
return ""
var vp := SubViewport.new()
vp.size = Vector2i(THUMB_RENDER_PX, THUMB_RENDER_PX)
vp.own_world_3d = true
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
add_child(vp)
var space := Node3D.new() # level space: the brush meshes and bounds live there
vp.add_child(space)
space.transform = (level as Node3D).global_transform
var lm: Node = level.get_node_or_null("LevelMesh")
var mat: Material = (lm as MeshInstance3D).material_override if lm is MeshInstance3D else null
for b: int in ids:
var mesh: Mesh = level.get_brush_mesh(b)
if mesh == null or mesh.get_surface_count() == 0:
continue
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = mat
space.add_child(mi)
var lo: Vector3 = bb["aabb_min"]
var hi: Vector3 = bb["aabb_max"]
var c := (lo + hi) * 0.5
var r := maxf((hi - lo).length() * 0.5, 0.05)
var cam := Camera3D.new()
cam.fov = 40.0
cam.near = r * 0.05
cam.far = r * 20.0
space.add_child(cam)
var eye := c + Vector3(-1.0, 0.8, 1.0).normalized() * r / sin(deg_to_rad(cam.fov * 0.5)) * 1.1
cam.look_at_from_position(eye, c, Vector3.UP)
cam.current = true
var sun := DirectionalLight3D.new()
space.add_child(sun)
sun.look_at_from_position(c + Vector3(-1, 2, 1.5), c, Vector3.UP)
var env := WorldEnvironment.new()
env.environment = Environment.new()
env.environment.background_mode = Environment.BG_COLOR
env.environment.background_color = Color(0.16, 0.17, 0.19)
env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.environment.ambient_light_color = Color(0.55, 0.55, 0.6)
space.add_child(env)
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var img: Image = vp.get_texture().get_image() if vp.get_texture() != null else null
vp.queue_free()
if img == null or img.is_empty():
return ""
img.resize(THUMB_PX, THUMB_PX, Image.INTERPOLATE_LANCZOS)
var bytes := img.save_png_to_buffer()
var tmp := CACHE_DIR.path_join(".thumb.tmp.png")
if not _write(tmp, bytes):
return ""
var co := _overlay()
var h := String(co.content_hash_of_file(tmp)) if co != null else ""
DirAccess.remove_absolute(ProjectSettings.globalize_path(tmp))
if h == "" or not _write(thumb_path(h), bytes):
return ""
co.publish(thumb_path(h))
return h
static func thumb_path(thumb: String) -> String:
return CACHE_DIR.path_join(thumb + ".png")
func _thumb_after_save(name: String, oid: String, ids: PackedInt32Array, room: bool, personal: bool) -> void:
var th := await render_thumbnail(ids)
if th == "":
return
var sync := _sync()
if room and sync != null:
# Only while the room still names THIS save: a peer's newer one is never clobbered.
var v: Variant = sync.state.get_state("world:assets:%s:%s" % [KIND, name], null)
if v is Dictionary and String((v as Dictionary).get("oid", "")) == oid:
var extra := (v as Dictionary).duplicate()
extra["thumb"] = th
sync.publish_asset(KIND, name, oid, int(extra.get("size", 0)), AssetDoc.EXT, extra)
if personal:
var rows := personal_index()
for r: Dictionary in rows:
if String(r["name"]) == name and String(r["oid"]) == oid:
r["thumb"] = th
var f := FileAccess.open(personal_dir().path_join("index.json"), FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify({"assets": rows}, "\t"))
f.close()
thumbnail_ready.emit(name, th)