* 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.
8.6 KiB
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.
- Load. The HUD reads
159 buildings · 298 props · 29 signswith no error text. Colored building walls rise against dark sky, and the wayfinding line readsOn: South Wilmington Street(the spawn street). - Spawn. Camera starts at local
(340, 390)facing north (heading 90°), mid-corridor on the Wilmington Street axis. HUD elevation reads about 100 m. - 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. TheEast Hargett Streetsign 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 roadnametags, never invented. - Street furniture. Along the walk, yellow
Ttraffic signals cluster at intersections, white=crosswalk bands cross the road, greenttrees dot the verges, and a cyanBmarks a bus stop. All are point records from the OSM furniture layer. - Walk with a grade. The HUD elevation climbs continuously from ~100.1 m to ~101.0 m over the ~120 m walk —
feet_zfollows terrain and movement is never rejected on this gentle grade. - 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 2–5 m.
- Most buildings use the
OSM building:levels × 3.2 m/floorestimate (confidence 0.72); a minority carry an explicit OSMheighttag (confidence 0.88). The 0.55/0.35 fallback tiers ingis-ingestion.mdstep 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_lampnodes for downtown Raleigh, so lamps are absent from props; roadlittags 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.