OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / asset_library.gd

391 lines · gd ref:
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)