Give street props colliders, zoned placements and variants
Full build / Full desktop build + optional release (push) Successful in 4m2s
Full build / Import + headless tests (push) Successful in 31s

Props were decoration: the guard and guests walked straight through benches,
bins, bollards and planters. They now block movement and steer guests around.

Collision
- _add_prop_model() fits a StaticBody3D from the model's mesh bounds in
  holder-local space: a cylinder for bollard/bin/hydrant, a box otherwise, so
  placement rotation and variant scale carry over.
- Fallback primitives go through the same fit, so a missing .glb cannot silently
  remove collision.
- Each prop also publishes a NavigationObstacle3D; NpcAvatar enables agent
  avoidance and moves with the safe velocity from velocity_computed. The navmesh
  stays a flat walkable plane and avoidance does the steering.

Placement
- PROP_PLACEMENTS groups furniture by entrance, mid-block and curb zones, with a
  static placement_problems() validator for bounds, overlap/min-spacing,
  duplicates, and the door/steps/guest-lane exclusions.
- variant_for(index) gives deterministic yaw jitter, subtle scale, and optional
  clutter (bag by the bin, crate by the newspaper boxes, cone by the hydrant).

Verification
- New windowed smoke_props.gd: guard walks into a bench and stops; a guest
  headed beyond the bench routes around it and never enters the collider.
- Headless 182 -> 187: placement validation and its failure modes, variant
  determinism/range, collider + obstacle on every placed prop, fallback collider.
- Other smokes (PLAN verbs, crowd, emergency, timeline) still pass; Python
  tooling 53/53; screenshots recaptured; compare 9 good / 0 bad.
- Version 0.4.0. TODO: collision/placement section moved to DONE; prop pipeline
  checks remain.
This commit is contained in:
zwitschi committed 2026-11-01 10:16:53 +01:00
1 parent a3e90accdd
commit bb40a98537
18 files changed
+518 -66

No files matched your search

+33 -3
View File
@@ -73,6 +73,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
agent.target_desired_distance = 4.0
agent.radius = 0.35
agent.height = 1.2
# Steer around street furniture instead of walking into it (props publish a
# NavigationObstacle3D; the navmesh itself stays a flat walkable plane).
agent.avoidance_enabled = true
agent.neighbor_distance = 4.0
agent.max_neighbors = 8
agent.time_horizon_agents = 0.6
agent.time_horizon_obstacles = 1.2
agent.max_speed = FLEE_SPEED
agent.velocity_computed.connect(_on_safe_velocity)
add_child(agent)
# Normalize intent targets: approach the entrance, stop near the door.
@@ -98,6 +107,7 @@ var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _arms: Array[MeshInstance3D] = []
var _theater_t := 0.0
var _avoidance_active := false
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
const SKIN_TONE := Color(0.88, 0.72, 0.60)
@@ -527,6 +537,7 @@ func _advance_fsm(delta: float) -> void:
var leave_requested_by_lifetime := false
func _move_toward_target(delta: float) -> void:
_avoidance_active = false
if agent == null:
_move_direct(delta)
return
@@ -575,14 +586,33 @@ func _move_toward_target(delta: float) -> void:
return
dir = dir.normalized()
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
if agent.avoidance_enabled:
# The navigation server returns the obstacle-aware velocity through
# velocity_computed; never dash straight at the point in the meantime.
_avoidance_active = true
agent.velocity = Vector3(dir.x * speed, 0.0, dir.z * speed)
else:
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
move_and_slide()
## Safe velocity from NavigationServer avoidance: move with the steered vector.
func _on_safe_velocity(safe: Vector3) -> void:
if not _avoidance_active:
return
velocity.x = safe.x
velocity.z = safe.z
if is_on_floor():
velocity.y = 0.0
else:
velocity.y -= 9.8 * get_physics_process_delta_time()
move_and_slide()
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
func _move_direct(delta: float) -> void:
_avoidance_active = false
var to_target := target_position - global_position
to_target.y = 0.0
var dist := to_target.length()
+183 -20
View File
@@ -335,35 +335,112 @@ func _build_street_furniture() -> void:
tree.add_child(crown)
root.add_child(tree)
## Street-furniture placement table. Positions are in world space on the
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
## furniture instead of a scattered list. `accessory` names a small procedural
## addition (bag, crate, cone) for one-off variation.
const PROP_PLACEMENTS := [
# Entrance: bollards flank the foot of the steps, bins catch people who
# stop at the door.
{"zone": "entrance", "prop": "bollard", "x": -5.6, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": -4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 4.2, "z": 4.7},
{"zone": "entrance", "prop": "bollard", "x": 5.6, "z": 4.7},
{"zone": "entrance", "prop": "trash_bin", "x": -6.6, "z": 4.9, "accessory": "bag"},
{"zone": "entrance", "prop": "trash_bin", "x": 6.6, "z": 4.9},
# Mid-block: benches face the boulevard, planters break up the long run.
{"zone": "mid_block", "prop": "bench", "x": -8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "bench", "x": 8.8, "z": 6.4, "rot": 180.0},
{"zone": "mid_block", "prop": "planter_box", "x": -11.8, "z": 8.8},
{"zone": "mid_block", "prop": "planter_box", "x": 11.8, "z": 8.8},
# Curb: newspaper boxes and a hydrant along the roadway edge.
{"zone": "curb", "prop": "newspaper_box", "x": -9.6, "z": 11.0, "rot": -12.0},
{"zone": "curb", "prop": "newspaper_box", "x": -8.9, "z": 11.0, "rot": -12.0,
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
]
## The door, steps, and the lane guests walk in through stay clear of props.
const PROP_EXCLUSIONS := [
{"name": "steps and door", "x0": -4.6, "x1": 4.6, "z0": -4.6, "z1": 4.55},
{"name": "guest approach lane", "x0": -1.4, "x1": 1.4, "z0": 4.55, "z1": 14.0},
]
const PROP_BOUNDS := {"x0": -14.0, "x1": 14.0, "z0": 4.55, "z1": 11.8}
## Two prop centres closer than this read as one blob (newspaper boxes are
## deliberately lined up, so this stays below their 0.7 m spacing).
const PROP_MIN_SPACING := 0.6
static func _variant_seed(index: int) -> int:
return index * 7919 + 13
## Deterministic per-placement variation. The same index always gives the same
## rotation and scale, so the street is reproducible while repeats differ.
static func variant_for(index: int, base_rotation: float = 0.0) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = _variant_seed(index)
return {
"rotation": base_rotation + rng.randf_range(-2.0, 2.0),
"scale": rng.randf_range(0.96, 1.04),
"roll": rng.randf(),
}
## Static validation used by tests: reject duplicate, out-of-bounds, or
## overlapping placements, and any prop inside the door/steps/approach lane.
static func placement_problems(placements: Array = PROP_PLACEMENTS) -> Array[String]:
var problems: Array[String] = []
var seen := {}
for i in placements.size():
var entry: Dictionary = placements[i]
var prop := str(entry.get("prop", ""))
var x := float(entry.get("x", 0.0))
var z := float(entry.get("z", 0.0))
var key := "%s@%.2f,%.2f" % [prop, x, z]
if seen.has(key):
problems.append("duplicate placement %s" % key)
seen[key] = true
if x < float(PROP_BOUNDS["x0"]) or x > float(PROP_BOUNDS["x1"]) \
or z < float(PROP_BOUNDS["z0"]) or z > float(PROP_BOUNDS["z1"]):
problems.append("%s at (%.1f, %.1f) is outside the sidewalk" % [prop, x, z])
for zone in PROP_EXCLUSIONS:
if x > float(zone["x0"]) and x < float(zone["x1"]) \
and z > float(zone["z0"]) and z < float(zone["z1"]):
problems.append("%s at (%.1f, %.1f) blocks %s" % [prop, x, z, zone["name"]])
for j in range(i):
var other: Dictionary = placements[j]
var dx := x - float(other.get("x", 0.0))
var dz := z - float(other.get("z", 0.0))
if Vector2(dx, dz).length() < PROP_MIN_SPACING:
problems.append("%s and %s at (%.1f, %.1f) overlap" % [
prop, str(other.get("prop", "")), x, z])
return problems
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so
## the street looks the same on every run.
## boxes, a hydrant) rather than scaled boxes. Placements and their deterministic
## variants are fixed, so the street looks the same on every run.
func _build_street_detail() -> void:
var detail := Node3D.new()
detail.name = "StreetDetail"
root.add_child(detail)
# Bollards flank the foot of the steps.
for x in [-5.6, -4.2, 4.2, 5.6]:
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7))
# Benches face the boulevard.
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0)
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0)
# Bins where people leave the steps.
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9))
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9))
# Planters break up the long sidewalk run.
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8))
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
# Newspaper boxes and a hydrant along the curb.
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
for index in PROP_PLACEMENTS.size():
var entry: Dictionary = PROP_PLACEMENTS[index]
var base_rot := float(entry.get("rot", 0.0))
var variant := variant_for(index, base_rot)
var holder := _add_prop_model(detail, str(entry["prop"]),
Vector3(float(entry["x"]), 0.18, float(entry["z"])),
float(variant["rotation"]), index)
holder.scale = Vector3.ONE * float(variant["scale"])
var accessory := str(entry.get("accessory", ""))
if accessory != "":
_add_prop_accessory(holder, accessory)
## Place one generated prop. Falls back to simple primitives when the `.glb` is
## missing (fresh checkout before `godot --import`) so the world still builds.
## A static collider is fitted from the model bounds (cylinder for round props,
## box for everything else), so props block the guard and NPCs.
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
rot_y_deg: float = 0.0) -> Node3D:
rot_y_deg: float = 0.0, variant_index: int = -1) -> Node3D:
var holder := Node3D.new()
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
holder.position = pos
@@ -373,11 +450,97 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
holder.add_child(model)
loaded_models.append(prop)
else:
holder.add_child(_fallback_prop(prop))
var fallback := _fallback_prop(prop)
holder.add_child(fallback)
fallback_props.append(prop)
model = fallback
parent.add_child(holder)
_add_prop_collision(holder, prop, model)
return holder
## Fit a simple collider to the prop's meshes (in holder-local space, so the
## placement scale and rotation carry over).
func _add_prop_collision(holder: Node3D, prop: String, model: Node3D) -> void:
var bounds := _model_bounds(model, holder)
if bounds.size == Vector3.ZERO:
return
var body := StaticBody3D.new()
body.name = "Collision"
var collider := CollisionShape3D.new()
collider.name = "Shape"
var round_props := ["bollard", "trash_bin", "fire_hydrant"]
if prop in round_props:
var cylinder := CylinderShape3D.new()
cylinder.radius = maxf(bounds.size.x, bounds.size.z) * 0.5
cylinder.height = bounds.size.y
collider.shape = cylinder
else:
var box := BoxShape3D.new()
box.size = bounds.size
collider.shape = box
collider.position = bounds.get_center()
body.add_child(collider)
holder.add_child(body)
# Publish the footprint to NavigationServer so guest avoidance steers around
# the prop even though the navmesh itself is a flat walkable plane.
var obstacle := NavigationObstacle3D.new()
obstacle.name = "Avoidance"
obstacle.radius = maxf(bounds.size.x, bounds.size.z) * 0.5 + 0.25
obstacle.height = maxf(bounds.size.y, 0.5)
obstacle.position = Vector3(bounds.get_center().x, 0.0, bounds.get_center().z)
holder.add_child(obstacle)
## AABB of every mesh under `model`, expressed relative to `ancestor`.
func _model_bounds(model: Node3D, ancestor: Node3D) -> AABB:
var bounds := AABB()
var found := false
for node in model.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
var xform := _transform_to_ancestor(mi, ancestor)
var box: AABB = xform * mi.mesh.get_aabb()
bounds = box if not found else bounds.merge(box)
found = true
return bounds
func _transform_to_ancestor(node: Node, ancestor: Node) -> Transform3D:
var xform := Transform3D.IDENTITY
var current := node
while current != null and current != ancestor:
if current is Node3D:
xform = (current as Node3D).transform * xform
current = current.get_parent()
return xform
## Small deterministic clutter next to a prop: a bag by the bin, a crate by the
## newspaper boxes, a cone by the hydrant.
func _add_prop_accessory(holder: Node3D, kind: String) -> void:
match kind:
"bag":
var bag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.22
sphere.height = 0.5
bag.mesh = sphere
bag.material_override = Materials.flat(Color(0.10, 0.11, 0.13), "rubber")
bag.position = Vector3(0.38, 0.22, 0.06)
holder.add_child(bag)
_add_box(holder, Vector3(0.12, 0.10, 0.12), Vector3(0.38, 0.48, 0.06),
Color(0.16, 0.17, 0.20), false, null, Vector2.ONE, "rubber")
"crate":
_add_box(holder, Vector3(0.46, 0.40, 0.40), Vector3(0.48, 0.20, 0.08),
Color(0.45, 0.31, 0.18), true, null, Vector2.ONE, "wood")
_add_box(holder, Vector3(0.48, 0.03, 0.42), Vector3(0.48, 0.41, 0.08),
Color(0.35, 0.23, 0.13), false, null, Vector2.ONE, "wood")
"cone":
_add_cylinder_mesh(holder, 0.16, 0.55, Vector3(-0.42, 0.28, 0.10),
Color(0.95, 0.36, 0.10), "painted_metal")
_add_box(holder, Vector3(0.38, 0.05, 0.38), Vector3(-0.42, 0.03, 0.10),
Color(0.12, 0.12, 0.14), false, null, Vector2.ONE, "rubber")
_add_cylinder_mesh(holder, 0.11, 0.08, Vector3(-0.42, 0.34, 0.10),
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D:
var path := MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
+165
View File
@@ -0,0 +1,165 @@
extends SceneTree
## Prop-collision smoke: proves the guard and an NPC cannot walk through street
## furniture, and that a guest slides around a bench instead of passing through
## it. Windowed (the guard reads captured-mouse input):
##
## godot --path . --script res://src/test/smoke_props.gd
##
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 1800
const GUARD_PUSH_FRAMES := 100
var frame := 0
var gm: Node = null
var started := false
var step := 0
var step_frame := 0
var failures: Array[String] = []
var bench: Node3D = null
var bench_center := Vector3.ZERO
var bench_body: StaticBody3D = null
var guard_start := Vector3.ZERO
var npc: Node3D = null
var npc_start := Vector3.ZERO
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the collision checks finished")
return true
step_frame += 1
match step:
0:
_find_bench()
if bench == null:
return false
# Stand a couple of metres in front of the bench and walk into it.
var start := bench_center + Vector3(0, 0, 2.2)
start.y = 0.1
gm.guard.global_position = start
# Face the bench (-z): with the guard's basis, forward input at yaw PI
# walks toward lower z.
gm.guard.rotation.y = PI
guard_start = start
Input.action_press("move_forward")
step = 1
step_frame = 0
1:
if step_frame >= GUARD_PUSH_FRAMES:
Input.action_release("move_forward")
_check(guard_start.z - gm.guard.global_position.z > 0.5,
"guard walked toward the bench")
_check(gm.guard.global_position.z > bench_center.z + 0.25,
"guard stopped at the bench instead of passing through it")
_check(not _inside_bench(gm.guard.global_position),
"guard is not inside the bench collider")
# Step the guard aside so the guest test hits the bench, not the player.
gm.guard.global_position = bench_center + Vector3(4.0, -0.08, 2.2)
_stage_npc()
step = 2
step_frame = 0
2:
if step_frame % 120 == 0 and npc != null and is_instance_valid(npc):
print("npc dbg pos=%s vel=%s arrived=%s wait=%s" % [str(npc.global_position), str(npc.velocity), str(npc.arrived), str(npc.awaiting_interaction)])
if npc == null or not is_instance_valid(npc):
_stage_npc()
return false
if _npc_finished():
_check(npc.global_position.z < bench_center.z - 0.4,
"guest reached the far side of the bench")
_check(not _inside_bench(npc.global_position),
"guest is not inside the bench collider")
_check(npc_start.distance_to(npc.global_position) > 1.0,
"guest moved while routing around the prop")
_finish()
return true
return false
func _find_bench() -> void:
var detail := gm.get_node_or_null("World/StreetDetail")
if detail == null:
# The world root name differs between scene builds; search instead.
detail = root.find_child("StreetDetail", true, false)
if detail == null:
_fail("StreetDetail not found")
return
for child in detail.get_children():
if str(child.name).begins_with("Prop_bench"):
bench = child as Node3D
break
if bench == null:
_fail("no bench prop placed")
return
bench_center = bench.global_position
bench_body = bench.get_node_or_null("Collision") as StaticBody3D
if bench_body == null:
_fail("bench has no static collider")
func _stage_npc() -> void:
if gm.npcs.is_empty():
step = 0
step_frame = 0
return
npc = gm.npcs[0]
npc_start = bench_center + Vector3(0, 0.5, 2.5)
npc.global_position = npc_start
npc.velocity = Vector3.ZERO
npc.arrived = false
npc.awaiting_interaction = false
npc.lifetime_s = 999.0
npc.reaction_mode = ""
# Target well past the bench forces a straight line through it, and stays
# beyond the controller's 3 m "arrived" shortcut so the guest must route.
npc.target_position = Vector3(bench_center.x, 0.5, bench_center.z - 5.0)
func _npc_finished() -> bool:
if npc == null or not is_instance_valid(npc):
return false
return npc.global_position.z < bench_center.z - 0.4 \
or npc.global_position.distance_to(npc.target_position) < 0.9
func _inside_bench(point: Vector3) -> bool:
if bench_body == null:
return false
var collider := bench_body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or not (collider.shape is BoxShape3D):
return false
var box := collider.shape as BoxShape3D
var half := box.size * 0.5
var center := collider.global_position
half.x += 0.1
half.z += 0.1
return absf(point.x - center.x) < half.x and absf(point.z - center.z) < half.z
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
Input.action_release("move_forward")
if failures.is_empty():
print("SMOKE OK: props block the guard and guests route around them")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+90
View File
@@ -83,6 +83,96 @@ func test_world_places_generated_props() -> bool:
root.free()
return a and b and c and d
func test_prop_placements_are_valid() -> bool:
var problems := WorldBuilder.placement_problems()
return TestUtils.check(problems.is_empty(),
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
func test_placement_validator_catches_problems() -> bool:
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
var problems := WorldBuilder.placement_problems(bad)
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
var joined := " ".join(problems)
var b := TestUtils.check(joined.contains("steps and door"),
"validator reports the door/steps exclusion")
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
return a and b and c
func test_prop_variants_are_deterministic() -> bool:
var first := WorldBuilder.variant_for(3, 180.0)
var second := WorldBuilder.variant_for(3, 180.0)
var other := WorldBuilder.variant_for(4, 180.0)
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
and is_equal_approx(first["scale"], second["scale"]),
"the same placement always gets the same variant")
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
"different placements get different rotations")
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
"variant scale stays subtle")
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
"variant rotation stays near the authored angle")
return a and b and c and d
func test_every_prop_has_a_collider() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var detail := root.get_node_or_null("StreetDetail")
var problems: Array[String] = []
if detail == null:
problems.append("no StreetDetail")
else:
for holder in detail.get_children():
var body := holder.get_node_or_null("Collision")
if body == null or not (body is StaticBody3D):
problems.append("%s has no StaticBody3D" % holder.name)
continue
var collider := body.get_node_or_null("Shape") as CollisionShape3D
if collider == null or collider.shape == null:
problems.append("%s has no collision shape" % holder.name)
continue
var size: Vector3
if collider.shape is BoxShape3D:
size = (collider.shape as BoxShape3D).size
elif collider.shape is CylinderShape3D:
var cylinder := collider.shape as CylinderShape3D
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
else:
problems.append("%s uses an unexpected shape" % holder.name)
continue
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
problems.append("%s has a degenerate collider" % holder.name)
if collider.position.y + size.y * 0.5 <= 0.05:
problems.append("%s collider sits below the ground" % holder.name)
if holder.get_node_or_null("Avoidance") == null:
problems.append("%s has no navigation obstacle" % holder.name)
root.free()
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
func test_fallback_props_get_the_same_collider() -> bool:
## A missing `.glb` must not silently remove collision: fallback primitives go
## through the same bounds fit as the imported models.
var root := Node3D.new()
var builder := WorldBuilder.new(root)
var holder := Node3D.new()
var fallback := builder._fallback_prop("bench")
holder.add_child(fallback)
builder._add_prop_collision(holder, "bench", fallback)
var body := holder.get_node_or_null("Collision") as StaticBody3D
var a := TestUtils.check(body != null, "fallback bench gets a static body")
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
"fallback bench gets a box shape")
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
"fallback bench publishes a navigation obstacle")
holder.free()
root.free()
return a and b and c and d
func test_world_facade_windows_light_at_night() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)