Files
Magnus HedemarkandGitHub 44809386a5 feat(ascii-city-engine): deep enrichment — street furniture, signage, dense Raleigh (#322)
* feat(ascii-city-engine): deep enrichment — street furniture, signage, dense Raleigh

Enriches the merged v0.1 skill with a dense, real-data streetscape.

Schema (backward-compatible): props gain optional label + provenance; new
optional signs layer for street-name text; buildings gain name/address/use.
world.schema.json admits signs; all v0.1 packs still validate.

Validator: per-kind prop glyph map with unknown-kind flag; sign validation;
signs included in content-bounds; v0.1 guards (isinstance crash-guard,
O(n^2) DoS caps) intact.

Engine: spatially-indexed prop billboards (signals, trees, crossings, transit
stops, bollards, benches, hydrants) depth-tested at terrain(x,y);
perspective-projected street-name sign text as an overlay pass; road
surface-material and lit ground styling; crosswalk bands; wayfinding HUD
naming the current street and the building faced. Reads spawn + first tile
from manifest.json.

Raleigh sample regenerated dense (same authoritative bbox): 159 buildings
(64 named, addresses, uses), 899 surfaces (86 named, surface/lit/lanes),
298 props across 7 kinds, 29 real street-name signs. 520K, under 2 MB.

Docs + evals: contract/engine-architecture/gis-ingestion/raleigh-poc updated
for the new layers and acceptance checks; 2 new evals (no fabricated signage;
props anchored to terrain).

Verified: validator 1410 PASS / 0 FAIL; broken fixture + AttributeError repro
exit 1; 5000-vertex DoS rejected in 31 ms; node --check OK; HTTP smoke 200
on engine/manifest/tile; validate-skills.rb 154 green; eval-coverage passes;
blocklist clean; headless-Chrome render shows readable in-world 'North
Wilmington Street' sign, signals, crosswalks, and on-street HUD.

AI-assisted contribution (Hermes Agent, spec-driven-development pipeline).

* fix(ascii-city-engine): address droid-review findings on PR #322

1. [P1] facingBuilding(): wrap the heading delta to [-pi,pi] before taking
   abs, so the Facing: HUD stops reporting a behind-the-camera building after
   the player turns past ~180 degrees.
2. [P2] buildIndices(): rasterize surface polyline edges into grid cells so
   long straight road segments register for surface styling and the On:
   HUD street name, instead of only indexing endpoint vertices.
3. [P1] validator: guard the new props kinds comprehension and the props/
   signs loops in all_points() against null, so 'props: null' or 'signs: null'
   produce a structured FAIL instead of an uncaught TypeError traceback.
4. [P3][security] validator: enforce the previously-declared
   MAX_FEATURES_PER_TILE (buildings+surfaces+props+signs) to keep the O(n^2)
   geometry and duplicate-ID paths bounded in aggregate.
5. [P3] engine: paint marked crossings as a distinct ground band (=) instead
   of a floating billboard, matching the documented crosswalk rendering.
6. [P3] eval: align prop-null-terrain expected_output with the render-time
   skip behavior actually implemented.

Verified: dense pack 1411/0 exit 0; broken/crash/dos/null packs all exit 1
with clean FAIL reports (no tracebacks); JS node --check OK; validate-skills
154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 2 on PR #322

1. [P1] Engine: skip crossing props in the billboard loop so crosswalks
   render only as the documented ground band (no more floating '=' above
   each of the 168 crossings — a regression from the prior fix).
2. [P3] Engine: nearestStreet() filters to kind==='road' so the HUD 'On:'
   line names the street, not a named plaza/sidewalk ('Market Plaza' etc).
3. [P2] Validator: require each sign's text to be a recorded road name
   (collected from surfaces), enforcing the documented never-invented
   signage contract. A fabricated 'Made Up Avenue' sign now FAILs.
4. [P2][security] Validator: short-circuit the tile loop when
   MAX_FEATURES_PER_TILE is exceeded, and replace O(n^2) duplicate-id
   .count() scans with single-pass Counters, bounding the quadratic paths.

Verified: dense pack 1411/0 exit 0; fabricated-sign repro flags only the
injected sign and exits 1; broken/crash/dos/null packs all exit 1 with no
tracebacks; JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 3 on PR #322

1. [P2] Validator: emit the signs rule unconditionally so a null/non-list
   'signs' value FAILs instead of passing silently (was gated on a truthy
   list check).
2. [P2] Validator: validate sign text against a pack-wide road-name set
   gathered across all tiles, so a sign in one tile may name a road whose
   surface lives in another (the documented multi-tile case).
3. [P2] Schema: require non-empty id/kind/text (minLength 1) on props and
   signs so the schema and validator agree on empty-string rejection.
4. [P3] Engine: drop dead signGrid/IX.key (the sign overlay iterates
   world.signs directly); cap edge-rasterization steps so a degenerate
   resolution (0) or pathologically long edge cannot spin unboundedly.
5. [P3] Validator: unknown prop kinds now pass with a reported fallback-'?'
   note instead of hard-failing, matching the documented fallback glyph and
   the engine's behavior.

Verified: dense pack 1411/0 exit 0; signs:null FAILs; fabricated sign FAILs;
multi-tile sign-to-road reference PASSes; broken/crash/dos/null all exit 1;
JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 4 on PR #322

1. [P2] Engine: render props as once-per-frame perspective-projected one-cell
   billboards in an overlay pass (like signs) instead of during the ray march,
   eliminating the multi-row vertical streak a close prop produced. Verified in
   a live browser: signals/trees/crosswalks now render as discrete single cells.
2. [P3][security] Engine: bound aggregate rasterization in buildIndices() —
   cap surfaces (5000) and cells per surface (40000) so a crafted pack cannot
   freeze the tab on load (the validator's caps are not applied client-side).
3. [P2][security] Engine: guard sign text (missing/non-string text now skips
   the sign instead of throwing in the rAF loop and freezing the view).
4. [P3] Validator: still collect building/surface IDs for oversized tiles so
   pack-wide uniqueness detection runs even when the per-feature geometry checks
   are short-circuited (duplicates in an over-cap tile are no longer hidden).

Verified: dense pack 1411/0 exit 0; live browser render shows discrete props
(no streaks); broken/crash/dos/null/nullsign/fabric all exit 1, valid
multi-tile pack exit 0; JS node --check OK; validate-skills 154 green;
blocklist clean.

* fix(ascii-city-engine): address droid-review round 5 on PR #322

1. [P2][security] Engine: guard terrain() against non-finite x/y and guard the
   prop/sign overlay passes against non-array, non-object entries, so a
   malformed pack (missing y, signs=42, null entries) degrades gracefully
   instead of throwing in the rAF loop and freezing the view. Verified in a
   live browser: a pack with signs=42 + a prop missing y renders with the
   frame loop alive and no console errors beyond the favicon 404.
2. [P3] Engine: per-surface 'seen' set now dedupes cells across edges (was
   per-edge), eliminating the repeated linear includes() scan that made the
   rasterizer quadratic in the worst case.
3. [P3] Engine: raise the per-edge step cap to 20000 since the per-surface
   cell cap bounds total work, so long edges are fully sampled at the 2-5 m
   resolutions raleigh-poc.md recommends (fixes road-styling drops).
4. [P3] Validator: lower MAX_FEATURES_PER_TILE to 50,000 (shipped pack is
   1,385), bounding the quadratic pair tests more tightly.
5. [P3] raleigh-poc: correct walkthrough step 3 — East Hargett sign is ~141 m
   behind the spawn, not ahead; only North Wilmington is ahead. HUD count
   guards signs/props as arrays.

Verified: dense pack 1411/0 exit 0; broken/crash/dos/null/nullsign/fabric all
exit 1, valid multi-tile pack exit 0; malformed-pack live render survives;
JS node --check OK; validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 6 on PR #322

1. [P1] Engine: props/signs overlay passes now use the corrected perpendicular
   distance (d*cos(ray_angle-heading)) for row projection, distance scaling,
   and the depth test — matching the ray march — so FOV-edge objects project to
   the right row and no longer falsely occlude or poison later depth tests.
2. [P2][security] Engine: terrain() guards malformed terrain metadata (missing
   terrain/resolution, non-positive resolution, missing origin, null elevations),
   so a crafted pack degrades to a clean error instead of freezing the tab.
   Verified live: a resolution-0/null-elevations pack shows 'Cannot load...'
   with no page errors.
3. [P3] raleigh-poc: walkthrough step 3 corrected — W/S only translate, so a
   167-deg-off sign needs A/D rotation, not 'hold S'.
4. [P3][security] Validator: all_points() guards buildings/surfaces/props/signs
   against truthy non-iterables (e.g. props=42), matching the other null guards,
   so malformed packs report structured FAIL instead of an uncaught TypeError.

Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/
broken all exit 1 (no tracebacks); valid multi-tile exit 0; malformed-terrain
live render shows clean error, no freeze; JS node --check OK; validate-skills
154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 7 on PR #322

1. [P2] Engine: spatial-index buildings (footprint bbox -> grid cells) so the
   render loop and collision test find nearby buildings in O(nearby) instead of
   scanning the whole O(buildings) list per ray sample. Browser-measured frame
   cost dropped ~62ms (16 FPS) to 12.5ms mean (~80 FPS) on the dense pack.
2. [P2][security] Engine: terrain() guards null/ragged elevation rows, so a
   pack with a null row degrades to a clean error instead of freezing the tab
   (live-verified: null-row pack shows 'Cannot load...', no page errors).
3. [P2][security] Engine: cap sign text at 80 chars in the overlay pass, so a
   pathological pack-supplied sign cannot drive an unbounded per-frame loop.
4. [P3] Validator: reference FALLBACK_GLYPH constant (was dead) in the
   unknown-kinds report message.
5. [P3] raleigh-poc: fix stale expected validator tail (was 30/25; actual is
   159/899, rules_passed=1411).

Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/
broken all exit 1, valid multi-tile exit 0; null-row pack shows clean error,
no freeze; ~80 FPS browser-measured on dense pack; JS node --check OK;
validate-skills 154 green; blocklist clean.

* fix(ascii-city-engine): address droid-review round 8 on PR #322

1. [P1][security] Engine: bound the building spatial-index rasterization with
   MAX_BUILDING_PTS (2000) and MAX_BUILDING_CELLS (40000) and require >=3 finite
   footprint points, so a ~100-byte crafted footprint cannot drive a ~1e10-
   iteration synchronous hang on load (the surface rasterizer's cap, applied
   to the building index I added in round 7).
2. [P2][security] Engine: facingBuilding() filters footprints to finite points
   before reducing, so a building with a null element in its footprint no
   longer throws in the rAF loop on frame 1.
3. [P2][security] Engine: collides() and pointNearPolyline() filter footprints/
   polylines to valid array points before edge tests, so null footprint points
   no longer throw once the player enters those cells.
4. [P3] Validator: correct the feature-cap comment to 'buildings + surfaces +
   props + signs combined' (signs were already counted).

Verified: dense pack 1411/0 exit 0; ~85 FPS browser-measured (perf fix intact);
badfoot pack (null-point + missing-footprint buildings) renders with zero page
errors and frame loop alive; crash/dos/null/nullsign/fabric/props42/broken all
exit 1, valid multi-tile exit 0; JS node --check OK; validate-skills 154 green;
blocklist clean.
2026-08-16 05:51:25 -04:00

8.6 KiB
Raw Permalink Blame History

Raleigh Proof of Concept

This walkthrough reproduces the committed ../assets/raleigh-downtown-sample/ pack and scales it up to a fuller downtown pack. The committed sample is intentionally coarse (10 m terrain, simplified footprints, <= 2 MB) so the repository stays light; the full pipeline below is what you run for a denser local pack.

Downtown bounding box (authoritative for this POC)

WGS84 (EPSG:4326), south-west to north-east:

min: lon -78.6420, lat 35.7760
max: lon -78.6350, lat 35.7830

This box covers the Fayetteville Street core, the State Capitol grounds, Nash and Moore Squares' west edges, and the Hillsborough Street corridor. Through the tangent-plane conversion in gis-ingestion.md the box measures about 632 m east-west by 779 m north-south; the committed sample's manifest bounds and terrain grid are normalized to an exact 630 m by 770 m working extent (78 rows x 64 cols at 10 m) that sits inside it, so the documented formula and the shipped grid differ by roughly 1-2 m at the edges. It is deliberately compact so the sample stays small and every acceptance check is walkable in minutes. Expand symmetrically (for example to -78.6520..-78.6280, 35.7680..35.7910) for a fuller downtown pack.

Local pack CRS: equirectangular tangent plane, origin [-78.6420, 35.7760], x east / y north, meters (see gis-ingestion.md §Local tangent-plane fallback).

Data sources (all retrieved 2026-08-15)

Layer Source URL License Role
Elevation USGS 3DEP Elevation Point Query Service https://epqs.nationalmap.gov/v1/json US public domain Terrain heights
Buildings (footprints + heights) OpenStreetMap via Overpass https://overpass-api.de/api/interpreter ODbL 1.0 Runtime footprints, tagged/estimated heights
Roads, sidewalks, paths OpenStreetMap via Overpass (Kumi mirror) https://overpass.kumi.systems/api/interpreter ODbL 1.0 Walkable surface network
Buildings (municipal cross-check) City of Raleigh Building Footprints FeatureServer https://services.arcgis.com/v400IkDOw1ad7Yad/arcgis/rest/services/Building_Footprints/FeatureServer City of Raleigh Open Data terms; attribution required Geometry verification; heights absent

Observed yields on 2026-08-15 for the authoritative box: OSM returned 160 building ways, 41 carrying height or building:levels; the Raleigh FeatureServer returned 95 footprint polygons in the same box; EPQS center observation (-78.6385, 35.7795) = 106.517 m. EPQS corner observations for the sample grid: 95.727, 97.789, 101.578, 104.562 m — about 9 m of relief across the box.

Acquisition commands

Run from anywhere; outputs land in /tmp. Each step must succeed before the next (fail on HTTP error, then sanity-check the parse).

# 1. OSM buildings (footprints + height/level tags)
curl --fail --silent --show-error --get \
  --data-urlencode 'data=[out:json][timeout:60];(way[building](35.7760,-78.6420,35.7830,-78.6350););out tags geom;' \
  --output /tmp/raleigh-buildings.json \
  https://overpass-api.de/api/interpreter

# 2. OSM roads/sidewalks/paths (primary endpoint 504s under load; use a mirror)
curl --fail --silent --show-error --get \
  --data-urlencode 'data=[out:json][timeout:90];way[highway](35.7760,-78.6420,35.7830,-78.6350);out tags geom;' \
  --output /tmp/raleigh-roads.json \
  https://overpass.kumi.systems/api/interpreter

# 3. Municipal footprints (cross-check; 1 page is enough at this box size)
curl --fail --silent --show-error --get \
  --data-urlencode 'where=1=1' \
  --data-urlencode 'geometry=-78.6420,35.7760,-78.6350,35.7830' \
  --data-urlencode 'geometryType=esriGeometryEnvelope' \
  --data-urlencode 'inSR=4326' \
  --data-urlencode 'spatialRel=esriSpatialRelIntersects' \
  --data-urlencode 'outFields=*' \
  --data-urlencode 'returnGeometry=true' \
  --data-urlencode 'f=geojson' \
  --output /tmp/raleigh-city-footprints.geojson \
  'https://services.arcgis.com/v400IkDOw1ad7Yad/arcgis/rest/services/Building_Footprints/FeatureServer/0/query'

# 4. Elevation: one EPQS call per grid node for a coarse sample;
#    for a full pack, download the 3DEP 1/3 arc-second DEM for the tile and resample.
curl --fail --silent --show-error \
  --output /tmp/raleigh-elevation-center.json \
  'https://epqs.nationalmap.gov/v1/json?x=-78.6385&y=35.7795&units=Meters&wkid=4326&includeDate=False'

Verification per gis-ingestion.md: check HTTP status, byte size, JSON parseability, and record a SHA-256 per file before conversion. If the City of Raleigh URL has moved, search the portal (https://data.raleighnc.gov) for "Building Footprints" and record the new service URL plus your retrieval date; the committed sample keeps this POC runnable regardless.

Conversion

Reproject lon/lat to the local tangent plane (gis-ingestion.md §fallback), clip to the box, normalize polygons, estimate missing heights at 3.2 m per floor with lowered confidence, emit manifest.json plus world/tile-0.json, then validate:

python3 scripts/validate-city-pack.py assets/raleigh-downtown-sample

Expected tail of a good run:

PASS tile[0].content.bounds — world/tile-0.json
PASS buildings.unique-ids
PASS surfaces.unique-ids
PASS manifest.spawn.bounds
SUMMARY rules_passed=1411 rules_failed=0 buildings=159 terrain_extent=[0.0, 0.0]..[630.0, 770.0] surfaces=899

The deliberately-broken fixture must fail:

python3 scripts/validate-city-pack.py assets/deliberately-broken-pack
# FAIL manifest.bounds / manifest.tiles.nonempty / manifest.provenance ... ; exit 1

Human acceptance checks (run after any acquisition)

Serve the repo root (python3 -m http.server 8000) and open http://localhost:8000/assets/ascii-city-engine.html.

  1. Load. The HUD reads 159 buildings · 298 props · 29 signs with no error text. Colored building walls rise against dark sky, and the wayfinding line reads On: South Wilmington Street (the spawn street).
  2. Spawn. Camera starts at local (340, 390) facing north (heading 90°), mid-corridor on the Wilmington Street axis. HUD elevation reads about 100 m.
  3. Street-name sign. Hold W and walk north ~120 m (about 30 s at 4 m/s) along Wilmington Street. The light-gray text North Wilmington Street (anchored ~143 m ahead, y≈532) floats in-world as you approach. The East Hargett Street sign is ~141 m behind the spawn (y≈249) and ~167° off the north-facing axis, so press A or D to rotate around to it (W/S only translate, they never turn the camera). These names come from the road name tags, never invented.
  4. Street furniture. Along the walk, yellow T traffic signals cluster at intersections, white = crosswalk bands cross the road, green t trees dot the verges, and a cyan B marks a bus stop. All are point records from the OSM furniture layer.
  5. Walk with a grade. The HUD elevation climbs continuously from ~100.1 m to ~101.0 m over the ~120 m walk — feet_z follows terrain and movement is never rejected on this gentle grade.
  6. Solid footprint. Turn toward the nearest building wall and hold W into it. Forward motion stops at the wall face; strafing (A/D while holding W) slides along it. You cannot pass through or under it.

Re-run the validator after every fresh acquisition; if a data update moves a footprint onto the documented spawn, pick a new walkable spawn inside bounds and update manifest.json before publishing the pack.

Known limitations of the committed sample

  • Terrain is bilinear interpolation of four EPQS corner observations — smooth and correct in trend, but it cannot show curb-level detail. A full pack should resample the 3DEP DEM at 25 m.
  • Most buildings use the OSM building:levels × 3.2 m/floor estimate (confidence 0.72); a minority carry an explicit OSM height tag (confidence 0.88). The 0.55/0.35 fallback tiers in gis-ingestion.md step 7 apply to lower-evidence cases than this sample contains. Skyline proportions are right; individual roof elevations may not be.
  • Buildings and surfaces crossing the bbox edge are clipped to the 630 × 770 m working extent and marked clipped: true; a small number that collapse below 3 vertices or self-intersect after clipping are dropped rather than weaken the validator's simple-polygon guarantee.
  • OSM does not record highway=street_lamp nodes for downtown Raleigh, so lamps are absent from props; road lit tags still drive ground brightness. Municipal furniture layers (signals, lamps, trees) on the City of Raleigh ArcGIS portal can fill this gap but were not required for the current census.
  • Raleigh municipal footprints are used as a geometry cross-check only; runtime footprints stay on ODbL-licensed OSM geometry so the sample remains redistributable with attribution.