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

+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)