extends Node3D
## A RUNTIME MODEL — the `model` prefab (PrefabCatalogue), placed by object.create like
## any other object, its four properties set by object.set_prop:
##
## asset content hash of the .glb (exact bytes — user, 2026-10-04)
## model_name what the Outliner and the placeholder call it
## collision auto (named meshes if the file has them, else convex) | convex |
## trimesh | decompose | named | none (ModelCollision)
## model_scale uniform; baked into the collision POINTS, never into the body
##
## Three looks, always one of them visible, so a model is never silently missing:
## PLACEHOLDER a wire box + "name… 45%" while the bytes are coming
## MODEL the shared scene from ModelCache, plus its collision
## BLOCKED a red box + the reason (failed check, unavailable, too big)
##
## The setters only mark it dirty; ONE rebuild runs at the end of the frame, because
## the fold sets all four properties back to back and building four times would make
## four bodies and parse nothing faster.
var asset: String = "":
set(v):
asset = v
_mark()
var model_name: String = "":
set(v):
model_name = v
_mark()
var collision: String = "auto":
set(v):
collision = v
_mark()
var model_scale: float = 1.0:
set(v):
model_scale = v
_mark()
var _dirty := false
var _built_key := ""
var _look := "" # "placeholder" | "model" | "blocked" — for gates
var _content: Node3D = null # whichever look is up
var _label: Label3D = null
func _ready() -> void:
var mc := _cache()
if mc != null and not mc.model_state.is_connected(_on_model_state):
mc.model_state.connect(_on_model_state)
_mark()
func _mark() -> void:
if _dirty or not is_inside_tree():
return
_dirty = true
_rebuild.call_deferred()
func look() -> String:
return _look
func _cache() -> Node:
return get_node_or_null(^"/root/ModelCache")
## LIVE PREVIEW of a scale by `k` on top of model_scale (ScaleOperator): the built holder
## is scaled about the same base the visual already is, so nothing is rebuilt per wheel
## tick (a decomposed collision would re-run VHACD). preview_scale(1.0) undoes it; the
## confirmed set_prop rebuilds a fresh holder anyway.
func preview_scale(k: float) -> void:
if _content == null or not is_instance_valid(_content):
return
var mc := _cache()
var base: Vector3 = mc.base(asset) if _look == "model" and mc != null else Vector3.ZERO
_content.transform = ModelCollision.scale_about(base, k)
func _on_model_state(oid: String) -> void:
if oid != asset:
return
var st: Dictionary = _cache().status(oid)
# Progress only changes the label; anything else is a different look.
if String(st.get("state", "")) == "fetching" and _look == "placeholder" and _label != null:
_label.text = _placeholder_text(st)
return
_mark()
## Build the current look. Public for gates that cannot wait for the deferred call.
func rebuild_now() -> void:
_dirty = true
_rebuild()
func _rebuild() -> void:
if not _dirty:
return
_dirty = false
var mc := _cache()
var st: Dictionary = {}
if asset != "" and mc != null:
st = mc.request(asset)
var state := String(st.get("state", ""))
var key := "%s|%s|%s|%s|%s" % [asset, state, collision, String.num(model_scale, 4), model_name]
if key == _built_key and _content != null and is_instance_valid(_content):
return
_built_key = key
_clear()
if asset == "":
_show_box(Color(0.6, 0.6, 0.6, 0.5), "%s (no model)" % _title(), "blocked")
elif state == "ready":
_show_model(mc)
elif state == "failed":
_show_box(Color(0.9, 0.15, 0.1, 0.6), "%s — blocked: %s" % [_title(), String(st.get("reason", "?"))], "blocked")
else:
_show_box(Color(1.0, 0.7, 0.2, 0.35), _placeholder_text(st), "placeholder")
func _title() -> String:
return model_name if model_name != "" else (asset.substr(0, 8) if asset != "" else "model")
func _placeholder_text(st: Dictionary) -> String:
var total := int(st.get("total", 0))
if String(st.get("state", "")) == "queued":
return "%s… loading" % _title()
if total > 0:
return "%s… %d%%" % [_title(), int(100.0 * float(st.get("received", 0)) / float(total))]
return "%s… waiting for a peer" % _title()
func _clear() -> void:
if _content != null and is_instance_valid(_content):
remove_child(_content)
_content.queue_free()
_content = null
_label = null
func _show_model(mc: Node) -> void:
var holder := Node3D.new()
holder.name = "Model"
var visual: Node3D = mc.instance(asset)
visual.name = "Visual"
# Scaled ABOUT THE BASE (user, 2026-10-04: "around the base, where it stands"), with
# the same transform the collision points get — ModelCollision.scale_about.
visual.transform = ModelCollision.scale_about(mc.base(asset), model_scale) * visual.transform
holder.add_child(visual)
var col: Dictionary = mc.shapes(asset, collision, model_scale)
# -colonly / -convcolonly: the author's collision mesh is not meant to be SEEN.
# Freed from THIS copy only — the cached scene is shared.
for p: Variant in col.get("hide", []):
var n := visual.get_node_or_null(p as NodePath)
if n != null:
n.get_parent().remove_child(n)
n.queue_free()
var shapes: Array = col.get("shapes", [])
if not shapes.is_empty():
var body := StaticBody3D.new()
body.name = "Body"
# Layer 2 is the grapple's. map_core adds it to every StaticBody3D at map load;
# a model built later would otherwise be the one thing the hook passes through.
body.collision_layer = 1 | 2
for s: Variant in shapes:
var cs := CollisionShape3D.new()
cs.shape = s as Shape3D
body.add_child(cs)
holder.add_child(body)
add_child(holder)
_content = holder
_look = "model"
func _show_box(color: Color, text: String, look_name: String) -> void:
var holder := Node3D.new()
holder.name = "Box"
var mi := MeshInstance3D.new()
var b := BoxMesh.new()
b.size = Vector3.ONE * maxf(0.25, model_scale)
mi.mesh = b
mi.position.y = b.size.y * 0.5
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = color
mi.material_override = mat
holder.add_child(mi)
_label = Label3D.new()
_label.text = text
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_label.no_depth_test = true
_label.pixel_size = 0.006
_label.position.y = b.size.y + 0.4
holder.add_child(_label)
add_child(holder)
_content = holder
_look = look_name