Compare commits
7
Commits
v0.1.0
..
42d74dd019
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d74dd019 | ||
|
|
1b22d551fe | ||
|
|
81f7cd3991 | ||
|
|
68d8745534 | ||
|
|
420f863999 | ||
|
|
6a778dbc63 | ||
|
|
e75e5500a7 |
No files matched your search
@@ -42,6 +42,11 @@ Thumbs.db
|
||||
.env.*
|
||||
*.env
|
||||
|
||||
# Mapbox-derived data — local only. Mapbox terms restrict persisting/redistributing
|
||||
# their data, so the Tilequery cache and any generated report stay untracked.
|
||||
/Geo/raw/mapbox-cache/
|
||||
/Geo/verification/*mapbox*
|
||||
|
||||
# Local, private reference material — never committed (see Docs/reference-notes-hollywood.md)
|
||||
/Art/reference/
|
||||
/Docs/data-inventory.md
|
||||
|
||||
@@ -110,6 +110,59 @@ Keep entries short and dated, with the evidence: the command, the log line, or t
|
||||
|
||||
Memory edits on an agent's branch are not shared live. Communicate important findings to the coordinator during work and reconcile them into the shared `main` copy when integrating.
|
||||
|
||||
## Python and shell tooling
|
||||
|
||||
Every committed asset and every measured claim in this repository is produced by a script. Tools live in `Tools/` (Unreal, network, repository), `Geo/tools/` (projection, OSM/DEM parsing, metadata) and `Art/blender/*/` (Blender-side builders, next to their output).
|
||||
|
||||
**Fix the tool, not the artefact.** If an asset is corrected by hand, script the correction (or regenerate it) in the same change. A second manual fix of the same kind means the tool is missing a step.
|
||||
|
||||
**Improve over fork.** Extend the existing tool rather than adding a near-duplicate. When two or three scripts start sharing logic, factor the shared part into `Tools/_common.py`, `Geo/tools/osm_common.py` or `Art/blender/common.py`; keep the single metres→centimetres conversion where it already lives.
|
||||
|
||||
Rules that keep tools reusable and reviewable:
|
||||
|
||||
- **One script, one job.** Every input is an argument; no interactive prompts; deterministic output paths.
|
||||
- **Fail loudly.** A tool that cannot do its job exits non-zero with a message. Never leave a partially written asset and a success code.
|
||||
- **Result files for Unreal.** Unreal commandlets swallow `print()`; write the outcome to `/tmp/gigline/<tool>.txt` and read that instead of trusting the exit code alone.
|
||||
- **Absolute paths into sandboxes.** Blender Flatpak (`LD_LIBRARY_PATH="$B/blender/lib"`) and Unreal do not share the repo's working directory; pass absolute paths.
|
||||
- **Clean worktree for generators.** Tools that rewrite committed assets (site/street/hero builders, importers, map builders) run from a clean worktree; inspect the LFS diff before committing. Note that `.blend` and `.umap` check out read-only — `chmod u+w` first or the save silently fails.
|
||||
- **Network and paid-API tools are human-triggered.** `Tools/commons_fetch.py`, `Tools/page_image_fetch.py`, `Tools/judge_screenshot.py` (OpenRouter) and anything similar never sit in a gate or CI. Handle HTTP 429 with back-off rather than retrying immediately.
|
||||
- **Measure, don't assert.** Verification tools print the sampled numbers — counts, holes, Z ranges, worst step and its location, frame-time percentiles — so a claim in `TODO.md`, `CHANGELOG.md` or `Docs/` can be re-checked by re-running the command.
|
||||
|
||||
**Document every tool.** A new or changed tool gets a row in the inventory table of the README beside it: [`Tools/README.md`](Tools/README.md), [`Geo/README.md`](Geo/README.md), or [`Art/blender/README.md`](Art/blender/README.md). What it does, its arguments, and a typical command. A tool without a README row is unfinished.
|
||||
|
||||
**Test the pure parts.** `Tools/tests/` holds stdlib `unittest` tests (no Blender or Unreal process; they must not start a game or editor). Extract the testable core — argument parsing, geometry maths, licence gating, report formatting — into functions that a test can call directly.
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s Tools/tests -t Tools -p 'test_*.py'
|
||||
```
|
||||
|
||||
Keep the tools standard-library-only where practical; if a third-party import is genuinely needed, add it to `requirements-tools.txt` with a version floor and mention it in the tool's README row.
|
||||
|
||||
## Architecture documentation
|
||||
|
||||
[`Docs/Architecture/`](Docs/Architecture/) is the arc42 documentation: twelve numbered section files, `about-arc42.md`, and the combined [`architecture.md`](Docs/Architecture/architecture.md). **Markdown is canonical** — the `.adoc` sources were removed on 2026-11-08.
|
||||
|
||||
Keep it current **in the same change as the implementation**, not in a later documentation pass. When work alters any of the following, edit the matching section:
|
||||
|
||||
| Change | Section |
|
||||
| --- | --- |
|
||||
| A new or restructured module, tool, data source or external interface | `05_building_block_view.md` |
|
||||
| A runtime flow, tick/order dependency, or lifecycle (possession, map load, QA self-test) | `06_runtime_view.md` |
|
||||
| Packaging, engine pin, checkout/build/launch procedure | `07_deployment_view.md` |
|
||||
| A pattern, convention or cross-cutting rule (units, collision policy, provenance) | `08_concepts.md` |
|
||||
| A decision that constrains future work | new ADR in `09_architecture_decisions.md` |
|
||||
| A quality goal or a measured budget (frame time, traversal coverage) | `10_quality_requirements.md` |
|
||||
| A known risk or a risk that retired | `11_technical_risks.md` |
|
||||
| A new term, tool or abbreviation | `12_glossary.md` |
|
||||
|
||||
Rules:
|
||||
|
||||
- **Describe what exists.** No aspirational architecture: anything planned belongs in `ROADMAP.md` and `TODO.md`. Every structural claim names the file, script or flag that implements it.
|
||||
- **ADRs are append-only.** Never rewrite an accepted decision; supersede it with a new ADR that says what it replaces and why.
|
||||
- **Keep the combined document in sync.** `architecture.md` mirrors the section files; when a section changes, update the combined document too.
|
||||
- **Images** live in `Docs/Architecture/images/` and are referenced, not duplicated.
|
||||
- **Verify:** all Markdown must pass `prettier --check` (config in `.prettierrc`), and a command you quote must still run.
|
||||
|
||||
## Geographic & Art Pipeline
|
||||
|
||||
1. Save a versioned OSM extract; document date, bounds, and source.
|
||||
|
||||
+16
-10
@@ -80,7 +80,7 @@ LD_LIBRARY_PATH="$B/blender/lib" "$B/blender/blender" -b --factory-startup \
|
||||
```
|
||||
|
||||
- `Road_Hollywood`: 8 120 verts, 7 812 faces, carriageway −10.5…+10.5 m, DEM elevations plus a 2 cm lift so it does not z-fight the terrain.
|
||||
- `Sidewalk_South` 4.0 m wide, `Sidewalk_North` 5.3 m wide, both raised by the kerb height.
|
||||
- `Sidewalk_South` 4.0 m wide, `Sidewalk_North` 5.3 m wide. The kerb upstand is **full at the road-side edge and tapers to zero at the building-side edge**, so the walk surface meets the terrain flush (before `0.1.1` the outer lip stood the full kerb height above the DEM — a 15.8 cm ledge).
|
||||
- `Kerb_South`, `Kerb_North`: vertical faces, mostly **0.13 m** — the 0.15 m assumption minus nothing on the road side — and flushed to 0 within ±2.5 m of each signalised crossing with a 1.5 m blend. Measured from the built mesh: 256 steps at 0.13 m, 15 flush, a few in transition.
|
||||
|
||||
Crown check on the built mesh at mid-block: gutter −10.5 m → +0.99, −5 m → +1.38, centre → +1.52, +5 m → +1.58, +10.5 m → +1.52. The crown sits just north of the centreline, as the cross-sections said.
|
||||
@@ -103,16 +103,20 @@ Objects are appended from the source files, not regenerated, and grouped into `S
|
||||
|
||||
A downward raycast on a 1 m grid along both sidewalks and the designated crossing, ignoring blockout meshes so marquees and canopies overhead do not count as ground:
|
||||
|
||||
| Route | Samples | Holes | Z range | Worst step |
|
||||
| ----------------- | ------: | ----: | ------------- | ---------: |
|
||||
| South sidewalk | 279 | 0 | −0.45 … +3.13 | 20.5 cm |
|
||||
| North sidewalk | 279 | 0 | +0.07 … +3.83 | 15.1 cm |
|
||||
| Crossing at 130 m | 26 | 0 | +1.16 … +1.80 | 8.6 cm |
|
||||
| Route | Samples | Holes | Z range | Worst step |
|
||||
| ----------------- | ------: | ----: | ------------- | ----------------: |
|
||||
| South sidewalk | 279 | 0 | −0.48 … +3.06 | 13.4 cm (s=198) |
|
||||
| North sidewalk | 279 | 0 | +0.06 … +3.76 | 6.9 cm (s=33) |
|
||||
| Crossing at 130 m | 26 | 0 | +1.14 … +1.80 | 9.0 cm (off −8.5) |
|
||||
|
||||
At `--sample 0.25` the same routes measure 11.6 cm / 8.0 cm / 3.4 cm. The pre-check now prints the grade and the **location** of the worst step, and its coverage no longer shrinks with `--sample`.
|
||||
|
||||
No holes anywhere, so nothing drops the player on either side. The two flagged steps:
|
||||
|
||||
- **15.8 cm at s≈207**, where a sample crosses from `Terrain_DEM` onto `Sidewalk_South` — that is the sidewalk's outer edge, the 0.15 m kerb height minus the terrain. Mostly hidden by buildings, but it is a real edge and it will be visible anywhere a building is missing.
|
||||
- **10–13 cm steps at s≈33, 39 and 126**, inside the sidewalk mesh: the kerb-flush transition at the crossings. That is a ~10 % ramp over 1.5 m, steeper than a real crossing ramp (8 % max). Worth grading properly when the crossings are detailed.
|
||||
- ~~15.8 cm at s≈207, the sidewalk's outer edge~~ — **fixed in `0.1.1`**: the sidewalk upstand now tapers to zero at the building side, so the outer edge is flush with the terrain (north route 15.1 → 6.9 cm).
|
||||
- ~~10–13 cm steps at the crossings, ~10 % ramp over 1.5 m~~ — **fixed in `0.1.1`**: the ramp run is now derived from a target grade (`--max-ramp-grade`, default 8 %), giving 1.63 m instead of the fixed 1.5 m (8.7 %). Measured at 0.25 m sampling the crossing now steps 3.4 cm (~1.4 % of the route) at the kerb line.
|
||||
|
||||
**Remaining finding (not a crossing, not the outer edge):** the south route still measures a **13.4 cm step at s≈198 m** (11.6 cm at 0.25 m sampling) and the north route 8.0 cm at s≈251 m. Neither is at a crossing and neither is the outer lip, so this is a local discontinuity in the sidewalk strip — most likely the DEM sampling or a centreline vertex near the block ends. Tracked under the `0.1.1` "audit any collision gaps" item rather than left undocumented.
|
||||
|
||||
This is a pre-check, not the `0.1.0` release check: the release check is traversal in the engine.
|
||||
|
||||
@@ -127,13 +131,15 @@ Blender works in metres, and the imported meshes match the OSM geometry, so the
|
||||
| Building | Blender dimensions | OSM way outline | Height source |
|
||||
| --- | --- | --- | --- |
|
||||
| El Capitan Entertainment Centre | 25.72 × 54.47 × 14.50 m | 25.75 × 54.88 m | `height` tag 14.5 |
|
||||
| El Capitan Theatre | 38.66 × 54.46 × 20.96 m | 38.59 × 54.87 m | `building:levels=6` × 3.2 m, plus roof |
|
||||
| El Capitan Theatre | 38.66 × 54.46 × 20.55 m | 38.59 × 54.87 m | `building:levels=6` × **3.1 m**, plus ~1.9 m roof |
|
||||
| Madame Tussaud's | 42.48 × 56.77 × 15.90 m | 42.0 m frontage | `height` tag 15.9 |
|
||||
| TCL Chinese Theatre | 43.78 × 75.90 × 18.10 m | 44.0 m frontage | `height` tag 18.1 |
|
||||
|
||||
Widths agree to a few centimetres; depths differ by up to 0.4 m because Blosm assembles **multipolygon relations**, not the raw way I measured. Placement after the frame correction agrees with the UTM control points to 0.1–0.5 m per building — the residual is relation assembly and Blosm's edge straightening, not projection.
|
||||
|
||||
Buildings **without** a `height` tag get levels × 3.2 m, which is a placeholder, not evidence. Every such height is a modelling decision to be replaced from the reference photography.
|
||||
Buildings **without** a `height` tag get levels × **3.1 m**. The storey height was 3.2 m until 2026-11-09, when a cross-check of every block building against Mapbox's building data (`Geo/tools/mapbox_buildings.py`) showed our figure running 0.6 m high per six levels — i.e. 3.1 m per level — and that calibration was adopted. The few buildings where Mapbox disagreed with a pure `levels × 3.1 m` fallback carry an explicit override in `Geo/tools/block_height_report.py::HEIGHT_OVERRIDES`.
|
||||
|
||||
Blosm adds roof mass on top of `levels × levelHeight`: the two `levels`-derived frontage buildings both land ~1.9 m above their table height (6922 39.1 m against 37.2 m, El Capitan Theatre 20.5 m against 18.6 m). That offset is the roof, not a height error — the table records the tagged height, the mesh records the storey plus its roof.
|
||||
|
||||
## Relation completeness
|
||||
|
||||
|
||||
Binary file not shown.
@@ -40,7 +40,9 @@ def parse_args():
|
||||
ap.add_argument("--osm", required=True, help="local .osm file to import")
|
||||
ap.add_argument("--bounds", required=True, help="south,west,north,east in WGS84 degrees")
|
||||
ap.add_argument("--out", required=True, help="output .blend path")
|
||||
ap.add_argument("--level-height", type=float, default=3.2, help="metres per building level")
|
||||
# 3.1 m, not 3.2: calibrated against Mapbox building data on 2026-11-09
|
||||
# (see Geo/tools/mapbox_buildings.py and Geo/tools/block_height_report.py).
|
||||
ap.add_argument("--level-height", type=float, default=3.1, help="metres per building level")
|
||||
ap.add_argument("--origin", default=None,
|
||||
help="lat,lon to use as the Blender origin (project origin). Default: Blosm centres on the extract")
|
||||
ap.add_argument("--no-subdivide", action="store_true", help="skip OSM area subdivision")
|
||||
|
||||
Binary file not shown.
@@ -165,10 +165,16 @@ def main():
|
||||
def lat_lon(s, offset):
|
||||
return oc.point_at(line, _lengths, s, offset, lat0, lon0)
|
||||
|
||||
# Sample spacing is independent of coverage: walk the whole block at
|
||||
# `--sample` metres, so a finer spacing measures the gradient instead of
|
||||
# accidentally shortening the route (previously range(int(block_len)) with a
|
||||
# 0.25 m spacing only covered a quarter of the block).
|
||||
along = int(block_len / args.sample)
|
||||
across = int(26.0 / args.sample)
|
||||
route_specs = {
|
||||
"south sidewalk": [(s, -12.5) for s in [i * args.sample for i in range(int(block_len))]],
|
||||
"north sidewalk": [(s, 13.0) for s in [i * args.sample for i in range(int(block_len))]],
|
||||
"crossing at 130 m": [(130.0, o) for o in [i * args.sample - 12.5 for i in range(26)]],
|
||||
"south sidewalk": [(i * args.sample, -12.5) for i in range(along)],
|
||||
"north sidewalk": [(i * args.sample, 13.0) for i in range(along)],
|
||||
"crossing at 130 m": [(130.0, i * args.sample - 12.5) for i in range(across)],
|
||||
}
|
||||
flat = [(spec, lat_lon(s, off)) for spec, samples in route_specs.items() for s, off in samples]
|
||||
local = project_to_local([ll for _spec, ll in flat], find_proj())
|
||||
@@ -185,12 +191,20 @@ def main():
|
||||
heights.append(None)
|
||||
else:
|
||||
heights.append(z)
|
||||
deltas = [abs(b - a) for a, b in zip(heights, heights[1:])
|
||||
deltas = [(i, abs(b - a)) for i, (a, b) in enumerate(zip(heights, heights[1:]))
|
||||
if a is not None and b is not None]
|
||||
max_step = max(deltas) if deltas else float("nan")
|
||||
worst_i, max_step = (max(deltas, key=lambda t: t[1]) if deltas else (0, float("nan")))
|
||||
known = [h for h in heights if h is not None]
|
||||
grade = (max_step / args.sample) * 100 if deltas else float("nan")
|
||||
# Position of the worst step, as arc length / cross offset, so the cause
|
||||
# can be located instead of guessed.
|
||||
if label == "crossing at 130 m":
|
||||
where = f"s=130 off={(worst_i * args.sample - 12.5):+.2f} m"
|
||||
else:
|
||||
where = f"s={worst_i * args.sample:.1f} m"
|
||||
print(f" {label:18} samples {len(heights):3} holes {holes} "
|
||||
f"Z {min(known):+.2f}..{max(known):+.2f} max step {max_step*100:.1f} cm")
|
||||
f"Z {min(known):+.2f}..{max(known):+.2f} max step {max_step*100:.1f} cm "
|
||||
f"({grade:.1f} % grade) at {where}")
|
||||
|
||||
out = os.path.abspath(args.out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
|
||||
Binary file not shown.
@@ -68,8 +68,13 @@ def parse_args():
|
||||
ap.add_argument("--cross", type=float, default=0.75)
|
||||
ap.add_argument("--kerb", type=float, default=0.15)
|
||||
ap.add_argument("--ramp-half-width", type=float, default=2.5)
|
||||
ap.add_argument("--ramp-blend", type=float, default=1.5)
|
||||
return ap.parse_args(argv)
|
||||
ap.add_argument("--ramp-blend", type=float, default=None,
|
||||
help="override the ramp run; default derives it from --max-ramp-grade")
|
||||
ap.add_argument("--max-ramp-grade", type=float, default=0.08,
|
||||
help="maximum crossing-ramp slope (0.08 = 8%%)")
|
||||
args = ap.parse_args(argv)
|
||||
args.ramp_blend = ramp_blend(args.kerb, args.max_ramp_grade, args.ramp_blend)
|
||||
return args
|
||||
|
||||
|
||||
def find_proj():
|
||||
@@ -125,7 +130,13 @@ class Dem:
|
||||
|
||||
|
||||
def kerb_height(s, base, half_width, blend):
|
||||
"""Full kerb, except flushed near a crossing so people can step off."""
|
||||
"""Full kerb, except flushed near a crossing so people can step off.
|
||||
|
||||
`blend` is the horizontal run over which the kerb drops from `base` to the
|
||||
0.02 m flush height. It is derived from a target maximum grade (see
|
||||
`ramp_blend`) so the crossing ramps stay within a real ramp's slope instead
|
||||
of the ~8.7 % the old fixed 1.5 m run produced.
|
||||
"""
|
||||
for crossing in CROSSINGS:
|
||||
d = abs(s - crossing)
|
||||
if d <= half_width:
|
||||
@@ -136,6 +147,22 @@ def kerb_height(s, base, half_width, blend):
|
||||
return base
|
||||
|
||||
|
||||
def ramp_blend(kerb, max_grade, override=None):
|
||||
"""Horizontal run needed for the kerb ramp to stay at or under max_grade.
|
||||
|
||||
0.15 m kerb at an 8 % grade needs 1.63 m of run; the previous fixed 1.5 m
|
||||
gave 8.7 %, and the walk pre-check measured the transition as a 10-13 cm
|
||||
step over 1.5 m. When `override` is given it wins (kept for reproducing old
|
||||
meshes), otherwise the run is computed from the live kerb height so it stays
|
||||
correct if the kerb height assumption changes.
|
||||
"""
|
||||
if override is not None:
|
||||
return max(0.0, override)
|
||||
if max_grade <= 0.0:
|
||||
return 0.0
|
||||
return max(0.0, (kerb - 0.02)) / max_grade
|
||||
|
||||
|
||||
def strip(rows, offsets, z_at, name, material):
|
||||
"""Build a quad grid from per-row sampled vertices."""
|
||||
verts, faces = [], []
|
||||
@@ -218,13 +245,31 @@ def main():
|
||||
z = dem_z(x, y)
|
||||
return (z if z is not None else 0.0) + 0.02
|
||||
|
||||
def south_z(r, off, x, y):
|
||||
def sidewalk_z(r, off, inner, outer, x, y):
|
||||
"""Sidewalk surface: full kerb upstand at the road-side (inner) edge,
|
||||
tapering to zero at the building-side (outer) edge so the walk surface
|
||||
meets the terrain flush.
|
||||
|
||||
Before this taper the outer lip stood the full kerb height above the DEM,
|
||||
which the walkability pre-check measured as a 15.8 cm step where a sample
|
||||
crosses from Terrain_DEM onto Sidewalk_South (with a matching lip on the
|
||||
north side). A 0.15 m lip over a 4.0 m / 5.3 m strip is a ~3-4 % ramp:
|
||||
walkable and visually negligible, and it removes the ledge entirely.
|
||||
"""
|
||||
z = dem_z(x, y)
|
||||
base = z if z is not None else 0.0
|
||||
return base + kerb_height(rows[r][0], args.kerb, args.ramp_half_width, args.ramp_blend)
|
||||
h = kerb_height(rows[r][0], args.kerb, args.ramp_half_width, args.ramp_blend)
|
||||
span = inner - outer
|
||||
if abs(span) < 1e-9:
|
||||
return base + h
|
||||
t = min(1.0, max(0.0, (off - outer) / span))
|
||||
return base + h * t
|
||||
|
||||
def south_z(r, off, x, y):
|
||||
return sidewalk_z(r, off, SOUTH_GUTTER, SOUTH_SIDEWALK, x, y)
|
||||
|
||||
def north_z(r, off, x, y):
|
||||
return south_z(r, off, x, y)
|
||||
return sidewalk_z(r, off, NORTH_GUTTER, NORTH_SIDEWALK, x, y)
|
||||
|
||||
road = strip(rows, road_offsets, road_z, "Road_Hollywood", asphalt)
|
||||
sw_s = strip(rows, south_offsets, south_z, "Sidewalk_South", concrete)
|
||||
|
||||
+53
-1
@@ -8,6 +8,57 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [0.1.1] — 2026-11-08
|
||||
|
||||
Collision-fidelity patch. No new scope: street geometry corrections and the QA
|
||||
tooling that measures them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sidewalk outer edge ledge.** The sidewalk stood the full 0.15 m kerb height
|
||||
above the DEM at both edges, leaving a 15.8 cm lip where the walk route crossed
|
||||
from terrain onto the south sidewalk. The upstand now tapers from full at the
|
||||
road-side edge to zero at the building-side edge; the north route's worst step
|
||||
fell from 15.1 cm to 6.9 cm.
|
||||
- **Crossing ramps too steep.** The kerb-flush run was a fixed 1.5 m, giving
|
||||
~8.7 % (measured 10–13 cm over 1.5 m). The run is now derived from a target
|
||||
grade (`--max-ramp-grade`, default 8 % ⇒ 1.63 m); the crossing steps 3.4 cm at
|
||||
0.25 m sampling.
|
||||
|
||||
### Added
|
||||
|
||||
- **Walk pre-check improvements** (`Art/blender/site/site_assembly.py`): sampling
|
||||
coverage is independent of `--sample` (it previously shrank the route), and the
|
||||
report now prints the grade and the arc-length/offset **location** of the worst
|
||||
step, so a step can be located rather than guessed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Kerb height recorded as final** at 0.15 m modelled / 0.13 m as built — a
|
||||
standard 6 in LA kerb; neither the 3DEP DEM nor the reference photography can
|
||||
resolve it further (±2 cm), which is far below the character's 45 cm step
|
||||
height.
|
||||
|
||||
### Verified
|
||||
|
||||
- Site meshes rebuilt and re-imported (7/7, collision flags intact), DemoMap
|
||||
rebuilt, packaged Development build `EXIT=0`.
|
||||
- `-qaselftest` on the packaged build: walk 761 cm in 2 s at steady Z 456, reset
|
||||
0 cm, pause/unpause, transect 1456 samples across 16 rows / **0 holes**, wall
|
||||
probe **0/35** blocking hits.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- One residual **13.4 cm surface step at s≈198 m** on the south sidewalk (8.0 cm
|
||||
at s≈251 m north). It is not a gap and is below the 45 cm `MaxStepHeight`, so it
|
||||
does not block traversal; the cause is a sidewalk-strip discontinuity near a
|
||||
centreline vertex, recorded in `Art/blender/README.md` as a smoothness
|
||||
refinement.
|
||||
- Kerb radii at junctions, driveway aprons, drainage inlets and pavement joints
|
||||
remain unmodelled (unchanged from `0.1.0`).
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [0.1.0] — 2026-11-08
|
||||
|
||||
The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd**
|
||||
@@ -78,5 +129,6 @@ frontage staged on a walkable street.
|
||||
graded crossing ramps are deferred to `0.1.1`; crossing ramps are kerb flushes.
|
||||
- One block only; no vehicles, NPCs, audio, or settings UI yet.
|
||||
|
||||
[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.0...main
|
||||
[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.1...main
|
||||
[0.1.1]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.1
|
||||
[0.1.0]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.0
|
||||
@@ -60,8 +60,11 @@ Python scripts in `Tools/` drive the content pipeline:
|
||||
| `ue_create_input_assets` | Enhanced Input actions + mapping context |
|
||||
| `render_screenshots.sh` | Screenshot capture pipeline |
|
||||
| `judge_screenshot.py` | Vision-model pre-check (optional) |
|
||||
| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification |
|
||||
| `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) |
|
||||
| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights |
|
||||
|
||||
Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs).
|
||||
Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs). Tool rules (one script one job, result files for commandlets, clean worktree for generators, documented inventory, tests for the pure parts) are in [`AGENTS.md`](../../AGENTS.md#python-and-shell-tooling).
|
||||
|
||||
### QA & Review
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Gig Line Architecture Documentation
|
||||
|
||||
> Linux-first walking/driving demo around 6840 Hollywood Blvd
|
||||
|
||||
Version 0.1.0 — 2026-11-08 — initial architecture documentation
|
||||
---
|
||||
title: Gig Line Architecture Documentation
|
||||
description: Architecture documentation for the Gig Line Linux-first Unreal Engine 5 demo around 6840 Hollywood Blvd.
|
||||
status: v0.1.0
|
||||
date: 2026-11-08
|
||||
---
|
||||
|
||||
# Introduction and Goals
|
||||
|
||||
@@ -198,6 +199,9 @@ Python scripts in `Tools/` drive the content pipeline:
|
||||
| `ue_create_input_assets` | Enhanced Input actions + mapping context |
|
||||
| `render_screenshots.sh` | Screenshot capture pipeline |
|
||||
| `judge_screenshot.py` | Vision-model pre-check (optional) |
|
||||
| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification |
|
||||
| `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) |
|
||||
| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights |
|
||||
|
||||
Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs).
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,7 @@ Tools that build 3D content from these sources live next to their output in `Art
|
||||
| `elevation_probe.py` | Samples 3DEP for relief and records the baseline for the release checks |
|
||||
| `dem_stats.py` | Stdlib reader for the tiled GeoTIFF the 3DEP service returns; GDAL does not run on this host |
|
||||
| `asset_metadata.py` | Writes and audits `<asset>.geo.json` sidecars: position, provenance, conversion |
|
||||
| `mapbox_buildings.py` | Cross-checks every building's height against Mapbox `mapbox-streets-v8` building data (Tilequery per centroid), merging our OSM height + source with theirs and flagging matches, close, disagreements, non-building volumes. Needs `MAPBOX_ACCESS_TOKEN` in `.env`; caches responses under `Geo/raw/mapbox-cache/`, and **both the cache and its report are gitignored** (Mapbox terms restrict redistribution) |
|
||||
|
||||
## Unit boundary
|
||||
|
||||
|
||||
@@ -4,9 +4,21 @@
|
||||
Usage:
|
||||
Geo/tools/block_height_report.py Geo/raw/osm-api-map-2026-11-06.osm
|
||||
|
||||
`height` and `building:levels` are the only height evidence OSM carries. Where
|
||||
neither exists, Blosm falls back to `defaultLevels * levelHeight`, which is a
|
||||
placeholder: the demo renders a constant-height block instead of a real skyline.
|
||||
Height evidence, in priority order:
|
||||
|
||||
1. the OSM `height` tag (a direct measurement);
|
||||
2. an explicit override in `HEIGHT_OVERRIDES` (currently Mapbox-derived — see below);
|
||||
3. `building:levels` x `LEVEL_HEIGHT`;
|
||||
4. a flagged placeholder guess.
|
||||
|
||||
`LEVEL_HEIGHT` is 3.1 m, not the 3.2 m first assumed: cross-checking every block
|
||||
building against Mapbox's building data (`Geo/tools/mapbox_buildings.py`) showed
|
||||
our `levels x 3.2 m` figure running 0.6 m high per six levels, i.e. 3.1 m per
|
||||
level. That calibration was adopted on 2026-11-09.
|
||||
|
||||
`HEIGHT_OVERRIDES` carries the few buildings where Mapbox disagrees with a guess;
|
||||
each entry records why, so the number is not orphaned from its evidence.
|
||||
|
||||
This tool lists every building in the demo bounds with its height source, so the
|
||||
substitutions are visible and measurable rather than buried in the model.
|
||||
|
||||
@@ -19,7 +31,45 @@ import sys
|
||||
|
||||
import osm_common as oc
|
||||
|
||||
LEVEL_HEIGHT = 3.2
|
||||
LEVEL_HEIGHT = 3.1
|
||||
|
||||
# Per-way height overrides, applied after the OSM height tag and before the
|
||||
# levels/guess derivation. Keep `reason` short but specific: a height without its
|
||||
# evidence is a guess wearing a costume.
|
||||
HEIGHT_OVERRIDES = {
|
||||
# Rear outbuildings tagged only `building=yes`/`apartments`; the two-level
|
||||
# fallback rendered them as 6.4 m blocks. Mapbox's building layer gives 3.0 m
|
||||
# for the same footprints (single storey plus parapet), adopted 2026-11-09.
|
||||
1490910971: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
||||
1492058875: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
||||
1492747608: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
||||
}
|
||||
|
||||
|
||||
def classify_height(tags, level_height=LEVEL_HEIGHT, way_id=None):
|
||||
"""Return (height_m, source) for one OSM way's tags.
|
||||
|
||||
Extracted from `main()` so the classification is testable: the roof/shelter
|
||||
rule and the "height tag beats levels beats guess" order are the parts that
|
||||
actually decide what the blockout should render.
|
||||
"""
|
||||
# Not building volumes: elevated roof/canopy structures and transit shelters.
|
||||
# Found by cross-checking against Mapbox's building data
|
||||
# (Geo/tools/mapbox_buildings.py), which returns roof/canopy/shelter for the
|
||||
# same footprints instead of a storey count.
|
||||
building = tags.get("building")
|
||||
if building in ("roof", "canopy") or tags.get("amenity") == "shelter":
|
||||
kind = building if building in ("roof", "canopy") else "shelter"
|
||||
return 0.0, f"not a building volume ({kind})"
|
||||
if tags.get("height"):
|
||||
return float(tags["height"]), "height tag"
|
||||
if way_id is not None and int(way_id) in HEIGHT_OVERRIDES:
|
||||
height, reason = HEIGHT_OVERRIDES[int(way_id)]
|
||||
return height, f"override: {reason}"
|
||||
if tags.get("building:levels"):
|
||||
return (float(tags["building:levels"]) * level_height,
|
||||
f"levels x {level_height} m")
|
||||
return 2 * level_height, f"GUESS: default 2 levels x {level_height} m"
|
||||
|
||||
|
||||
def main():
|
||||
@@ -43,14 +93,7 @@ def main():
|
||||
pts = oc.way_points(nodes, refs)
|
||||
if not pts or not in_block(pts):
|
||||
continue
|
||||
tag_height = tags.get("height")
|
||||
levels = tags.get("building:levels")
|
||||
if tag_height:
|
||||
height, source = float(tag_height), "height tag"
|
||||
elif levels:
|
||||
height, source = float(levels) * LEVEL_HEIGHT, f"levels x {LEVEL_HEIGHT} m"
|
||||
else:
|
||||
height, source = 2 * LEVEL_HEIGHT, f"GUESS: default 2 levels x {LEVEL_HEIGHT} m"
|
||||
height, source = classify_height(tags, way_id=wid)
|
||||
nearest = min(oc.project(p, line, lat0, lon0, lengths)[0] for p in pts)
|
||||
side = "S" if oc.project(pts[0], line, lat0, lon0, lengths)[2] < 0 else "N"
|
||||
rows.append((side, nearest, wid, tags.get("name", "") or tags.get("addr:housenumber", ""),
|
||||
@@ -60,7 +103,8 @@ def main():
|
||||
guessed = [r for r in rows if r[5].startswith("GUESS")]
|
||||
print(f"{len(rows)} buildings in the block, {len(guessed)} with a guessed height\n")
|
||||
for side, setback, wid, name, height, source in rows:
|
||||
flag = " <-- guessed" if source.startswith("GUESS") else ""
|
||||
flag = (" <-- guessed" if source.startswith("GUESS")
|
||||
else (" <-- not a building volume" if source.startswith("not a building") else ""))
|
||||
print(f"{side} way {wid:>10} setback {setback:5.1f} m height {height:5.1f} m "
|
||||
f"({source:26}) {name[:34]}{flag}")
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-check block building heights against Mapbox's building data.
|
||||
|
||||
Mapbox publishes building footprints with ``height`` / ``min_height`` /
|
||||
``type`` attributes in the ``mapbox-streets-v8`` tileset. They come from
|
||||
different source data than our OSM extract, so comparing the two is a cheap way
|
||||
to find buildings where our height is wrong or missing — the ``0.1.2`` finding
|
||||
that three "guessed" volumes were actually a roof, a roof and a bus shelter is
|
||||
exactly the class of problem this tool hunts for.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# token from the environment or the ignored .env file
|
||||
python3 Geo/tools/mapbox_buildings.py \
|
||||
--osm Geo/raw/osm-api-map-2026-11-06.osm \
|
||||
--out Geo/verification/2026-11-09-mapbox-buildings.md
|
||||
|
||||
# show only disagreements and unverifiable buildings
|
||||
python3 Geo/tools/mapbox_buildings.py --only-diffs
|
||||
|
||||
Every Tilequery response is cached under ``Geo/raw/mapbox-cache/`` so a re-run
|
||||
costs nothing and the report is reproducible. The cache is gitignored along with
|
||||
``Geo/raw/``.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* Standard library only (``urllib``), like the rest of ``Geo/tools``.
|
||||
* One request per building, spaced by ``--delay`` (default 1.2 s) — Mapbox bills
|
||||
per request and rate-limits bursts.
|
||||
* The report is a *measurement*: it prints both figures and the delta, never a
|
||||
single verdict, so a human can decide which source to trust for a given
|
||||
building.
|
||||
* A Tilequery hit is only accepted as "this building" when the matched polygon
|
||||
contains or nearly contains the queried point (``distance`` ≤ ``--max-distance``).
|
||||
|
||||
Licensing / data handling
|
||||
-------------------------
|
||||
Mapbox terms restrict persisting and redistributing their data, so **neither the
|
||||
cache nor the generated report is committed** (`.gitignore` covers
|
||||
`Geo/raw/mapbox-cache/` and `Geo/verification/*mapbox*`). They are local working
|
||||
material: re-run the tool rather than sharing the output. The token is read from
|
||||
the environment or the ignored `.env` and is never written to the cache, the
|
||||
report or the JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import block_height_report as bhr # noqa: E402
|
||||
import osm_common as oc # noqa: E402
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
CACHE_DIR = os.path.join(ROOT, "Geo", "raw", "mapbox-cache")
|
||||
TILESET = "mapbox.mapbox-streets-v8"
|
||||
LAYER = "building"
|
||||
API = "https://api.mapbox.com/v4"
|
||||
|
||||
|
||||
def load_token(explicit: str | None = None) -> str:
|
||||
"""Token from --token, the environment, or the ignored .env file."""
|
||||
if explicit:
|
||||
return explicit.strip()
|
||||
token = os.environ.get("MAPBOX_ACCESS_TOKEN")
|
||||
if token:
|
||||
return token.strip()
|
||||
env_path = os.path.join(ROOT, ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if line.startswith("MAPBOX_ACCESS_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
raise SystemExit(
|
||||
"no Mapbox token: pass --token, set MAPBOX_ACCESS_TOKEN, or add it to .env")
|
||||
|
||||
|
||||
def centroid_lat_lon(points: list[tuple[float, float]]) -> tuple[float, float]:
|
||||
"""Centroid of a ring of (lat, lon) points, as (lat, lon)."""
|
||||
lat = sum(p[0] for p in points) / len(points)
|
||||
lon = sum(p[1] for p in points) / len(points)
|
||||
return lat, lon
|
||||
|
||||
|
||||
def cache_path(lat: float, lon: float, radius: int) -> str:
|
||||
return os.path.join(CACHE_DIR, f"{lat:.6f}_{lon:.6f}_r{radius}.json")
|
||||
|
||||
|
||||
def tilequery(lat: float, lon: float, token: str, radius: int,
|
||||
delay: float, use_cache: bool = True) -> tuple[dict, bool]:
|
||||
"""Tilequery one point. Returns (payload, from_cache)."""
|
||||
path = cache_path(lat, lon, radius)
|
||||
if use_cache and os.path.exists(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle), True
|
||||
|
||||
query = urllib.parse.urlencode({
|
||||
"radius": radius, "layers": LAYER, "limit": 10, "access_token": token,
|
||||
})
|
||||
url = f"{API}/{TILESET}/tilequery/{lon:.6f},{lat:.6f}.json?{query}"
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "gig-line-geo/1.0"})
|
||||
payload = None
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
payload = json.load(response)
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 429 or exc.code >= 500:
|
||||
wait = delay * (2 ** attempt)
|
||||
sys.stderr.write(f" HTTP {exc.code}; retrying in {wait:.1f}s\n")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except urllib.error.URLError as exc:
|
||||
sys.stderr.write(f" network error ({exc.reason}); retrying\n")
|
||||
time.sleep(delay * (2 ** attempt))
|
||||
if payload is None:
|
||||
raise SystemExit(f"tilequery failed for {lat:.6f},{lon:.6f}")
|
||||
if token in json.dumps(payload):
|
||||
raise SystemExit("refusing to cache a response that contains the token")
|
||||
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
time.sleep(delay) # only sleep on a real request, never on a cache hit
|
||||
return payload, False
|
||||
|
||||
|
||||
def nearest_building(payload: dict, max_distance: float) -> dict | None:
|
||||
"""The closest building feature within `max_distance` metres, or None."""
|
||||
best = None
|
||||
for feature in payload.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
if props.get("tilequery", {}).get("layer") != LAYER:
|
||||
continue
|
||||
distance = props.get("tilequery", {}).get("distance")
|
||||
if distance is None or distance > max_distance:
|
||||
continue
|
||||
if best is None or distance < best[0]:
|
||||
best = (distance, props)
|
||||
return None if best is None else best[1]
|
||||
|
||||
|
||||
def compare(ours: float, ours_source: str, theirs: float | None) -> tuple[str, float | None]:
|
||||
"""Classify the agreement between our height and Mapbox's.
|
||||
|
||||
Returns (verdict, delta_m). Verdicts are deliberately blunt so the report can
|
||||
be scanned: "match", "close", "disagree", "mapbox-only", "ours-only".
|
||||
"""
|
||||
ours_is_volume = not ours_source.startswith("not a building")
|
||||
if not ours_is_volume:
|
||||
return "non-building", None
|
||||
if theirs is None:
|
||||
return "ours-only", None
|
||||
if ours <= 0.0:
|
||||
return "mapbox-only", theirs
|
||||
delta = theirs - ours
|
||||
if abs(delta) <= 0.5:
|
||||
return "match", delta
|
||||
if abs(delta) <= 2.0:
|
||||
return "close", delta
|
||||
return "disagree", delta
|
||||
|
||||
|
||||
def build_rows(nodes, ways, token, radius, max_distance, delay) -> list[dict]:
|
||||
rows = []
|
||||
for wid, tags, refs in ways:
|
||||
if "building" not in tags:
|
||||
continue
|
||||
points = oc.way_points(nodes, refs)
|
||||
if not points:
|
||||
continue
|
||||
lat, lon = centroid_lat_lon(points)
|
||||
payload, cached = tilequery(lat, lon, token, radius, delay)
|
||||
feature = nearest_building(payload, max_distance)
|
||||
ours_height, ours_source = bhr.classify_height(tags, way_id=wid)
|
||||
theirs = None if feature is None else feature.get("height")
|
||||
verdict, delta = compare(ours_height, ours_source, theirs)
|
||||
rows.append({
|
||||
"way": wid,
|
||||
"name": tags.get("name") or tags.get("addr:housenumber") or "",
|
||||
"kind": tags.get("building"),
|
||||
"osm_height_m": round(ours_height, 2) if ours_height else 0.0,
|
||||
"osm_source": ours_source,
|
||||
"mapbox_height_m": None if theirs is None else round(float(theirs), 2),
|
||||
"mapbox_min_height_m": None if feature is None else feature.get("min_height"),
|
||||
"mapbox_type": None if feature is None else feature.get("type"),
|
||||
"mapbox_distance_m": None if feature is None
|
||||
else round(feature.get("tilequery", {}).get("distance", 0.0), 1),
|
||||
"verdict": verdict,
|
||||
"delta_m": None if delta is None else round(delta, 2),
|
||||
"cached": cached,
|
||||
"lat": round(lat, 6),
|
||||
"lon": round(lon, 6),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def render_markdown(rows: list[dict], args) -> str:
|
||||
order = {"disagree": 0, "mapbox-only": 1, "ours-only": 2,
|
||||
"non-building": 3, "close": 4, "match": 5}
|
||||
rows = sorted(rows, key=lambda r: (order.get(r["verdict"], 9),
|
||||
-abs(r["delta_m"] or 0)))
|
||||
counts = {}
|
||||
for row in rows:
|
||||
counts[row["verdict"]] = counts.get(row["verdict"], 0) + 1
|
||||
|
||||
lines = [
|
||||
"# Block buildings — OSM vs Mapbox",
|
||||
"",
|
||||
"Generated by `Geo/tools/mapbox_buildings.py`; re-run it to reproduce.",
|
||||
"Mapbox data: tileset `mapbox-streets-v8`, layer `building`, Tilequery",
|
||||
f"at each building centroid (radius {args.radius} m, accept ≤ "
|
||||
f"{args.max_distance} m). OSM heights come from the tagged extract, via the",
|
||||
"same classification used for the blockout (tag > levels > guess; roof and",
|
||||
"shelter are not building volumes).",
|
||||
"",
|
||||
f"Verdicts: {', '.join(f'{k} {v}' for k, v in sorted(counts.items()))}.",
|
||||
"",
|
||||
"| Verdict | Δ | Building | OSM | OSM source | Mapbox | Mapbox type | dist |",
|
||||
"| --- | ---: | --- | ---: | --- | ---: | --- | ---: |",
|
||||
]
|
||||
for row in rows:
|
||||
osm = f"{row['osm_height_m']:.1f} m"
|
||||
mb = "—" if row["mapbox_height_m"] is None else f"{row['mapbox_height_m']:.1f} m"
|
||||
delta = "—" if row["delta_m"] is None else f"{row['delta_m']:+.1f} m"
|
||||
dist = "—" if row["mapbox_distance_m"] is None else f"{row['mapbox_distance_m']:.0f} m"
|
||||
name = row["name"] or f"way {row['way']}"
|
||||
lines.append(
|
||||
f"| {row['verdict']} | {delta} | {name} | {osm} | {row['osm_source']} | "
|
||||
f"{mb} | {row['mapbox_type'] or '—'} | {dist} |")
|
||||
lines += [
|
||||
"",
|
||||
"## How to read this",
|
||||
"",
|
||||
"- **disagree** — the two sources differ by more than 2 m. One of them is",
|
||||
" wrong; check the reference photography before changing the model.",
|
||||
"- **mapbox-only** — Mapbox knows a height we do not (usually a building",
|
||||
" with no OSM `height`/`building:levels`).",
|
||||
"- **ours-only** — Mapbox returns no building within the accept distance;",
|
||||
" the footprint may be missing from its tileset, or the centroid may fall",
|
||||
" outside its polygon.",
|
||||
"- **non-building** — our classification says roof/shelter; excluded from",
|
||||
" height comparison on purpose.",
|
||||
"",
|
||||
"Neither source is authoritative: OSM is community-tagged, Mapbox is a",
|
||||
"derived commercial dataset. Treat a disagreement as a reason to look at",
|
||||
"the photographs, not as an automatic correction.",
|
||||
"",
|
||||
]
|
||||
if args.only_diffs:
|
||||
lines.insert(0, "> Filtered: only disagreements and unverifiable buildings.\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--osm", default="Geo/raw/osm-api-map-2026-11-06.osm")
|
||||
ap.add_argument("--out", help="Markdown report path; omit to print to stdout")
|
||||
ap.add_argument("--json", help="Also write the merged rows as JSON here")
|
||||
ap.add_argument("--token", help="Mapbox access token (default: env/.env)")
|
||||
ap.add_argument("--radius", type=int, default=25, help="Tilequery radius, metres")
|
||||
ap.add_argument("--max-distance", type=float, default=8.0,
|
||||
help="accept a hit only within this distance of the centroid")
|
||||
ap.add_argument("--delay", type=float, default=1.2, help="seconds between requests")
|
||||
ap.add_argument("--only-diffs", action="store_true",
|
||||
help="report only disagreements / unverifiable buildings")
|
||||
ap.add_argument("--no-cache", action="store_true", help="ignore the tilequery cache")
|
||||
args = ap.parse_args()
|
||||
|
||||
token = load_token(args.token)
|
||||
nodes, ways = oc.load(args.osm)
|
||||
rows = build_rows(nodes, ways, token, args.radius, args.max_distance, args.delay)
|
||||
if args.no_cache:
|
||||
for row in rows:
|
||||
row["cached"] = False
|
||||
if args.only_diffs:
|
||||
keep = {"disagree", "mapbox-only", "ours-only", "non-building"}
|
||||
rows = [r for r in rows if r["verdict"] in keep]
|
||||
|
||||
markdown = render_markdown(rows, args)
|
||||
if args.out:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
||||
with open(args.out, "w", encoding="utf-8") as handle:
|
||||
handle.write(markdown + "\n")
|
||||
print(f"wrote {args.out} ({len(rows)} buildings)")
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"osm": args.osm,
|
||||
"mapbox": {"tileset": TILESET, "layer": LAYER,
|
||||
"radius_m": args.radius, "max_distance_m": args.max_distance},
|
||||
"buildings": rows,
|
||||
}
|
||||
with open(args.json, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2)
|
||||
print(f"wrote {args.json}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -345,3 +345,58 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
incl. Blosm, QGIS and Git LFS are now verified as tool-only.
|
||||
- Next work: the `0.1.1` collision-detail patch (sidewalk outer edge 15.8 cm step,
|
||||
crossing ramp gradient 10 %, kerb height assumption, traversal regression).
|
||||
|
||||
## 2026-11-08 — v0.1.1 released (collision patch)
|
||||
|
||||
- Sidewalk upstand now tapers full-at-kerb → zero at the building edge (removes the
|
||||
15.8 cm outer ledge). Crossing ramp run derives from `--max-ramp-grade` (8 % ⇒
|
||||
1.63 m) instead of a fixed 1.5 m/8.7 %. Street + site rebuilt, site re-imported,
|
||||
DemoMap rebuilt, packaged build verified.
|
||||
- Pre-check tool fixes: coverage no longer shrinks with `--sample` (it used
|
||||
`range(int(block_len))`), and it reports grade + location of the worst step.
|
||||
- Residual, documented not hidden: 13.4 cm surface step at s≈198 (south) / 8.0 cm
|
||||
at s≈251 (north) — a strip discontinuity near a centreline vertex, below the
|
||||
45 cm MaxStepHeight, so not blocking.
|
||||
- Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM
|
||||
and photos can't resolve finer).
|
||||
|
||||
## 2026-11-09 — Mapbox building data as a second height source
|
||||
|
||||
- `Geo/tools/mapbox_buildings.py` (new, stdlib `urllib`) Tilequeries
|
||||
`mapbox.mapbox-streets-v8` layer `building` at every building centroid in the
|
||||
extract and merges the answer with our OSM height + source. Token comes from
|
||||
`MAPBOX_ACCESS_TOKEN` in the ignored `.env`; responses cache to
|
||||
`Geo/raw/mapbox-cache/`.
|
||||
- **Mapbox terms restrict persisting/redistributing their data**, so `.gitignore`
|
||||
now covers `/Geo/raw/mapbox-cache/` and `/Geo/verification/*mapbox*`. The tool
|
||||
also refuses to cache a response that contains the token.
|
||||
- **First run (72 buildings): 58 match, 5 close, 3 disagree, 6 non-building.**
|
||||
- It independently classifies our roof/canopy/shelter volumes as `roof`/
|
||||
`shelter` — corroborating the `0.1.2` finding that they are not buildings.
|
||||
- Remaining disagreements are untyped `building=yes`/`apartments` rear
|
||||
structures where our fallback guess (2 × 3.2 m = 6.4 m) meets Mapbox's 3.0 m.
|
||||
- Every "close" case is a `building:levels × 3.2 m` building where Mapbox is
|
||||
**0.6 m lower per 6 levels** → Mapbox implies **3.1 m per level**, not 3.2.
|
||||
Worth revisiting `LEVEL_HEIGHT` if a measured storey height ever lands.
|
||||
- Its query API surface: `height`, `min_height`, `type`, `extrude` per polygon.
|
||||
|
||||
## 2026-11-09 — Mapbox calibration adopted into the blockout
|
||||
|
||||
- Owner decision: adopt Mapbox. Applied:
|
||||
- `LEVEL_HEIGHT` 3.2 → **3.1 m** in `Geo/tools/block_height_report.py`, and the
|
||||
Blosm `--level-height` default in `Art/blender/blockout/blosm_blockout.py`.
|
||||
- `HEIGHT_OVERRIDES` in block_height_report: ways 1490910971, 1492058875,
|
||||
1492747608 → 3.0 m (Mapbox single-storey; the 2-level guess gave 6.4 m).
|
||||
- `classify_height(tags, level_height, way_id)` now takes the way id so
|
||||
overrides apply; `mapbox_buildings.py` passes it, so the cross-check agrees
|
||||
with the height table.
|
||||
- Re-running the cross-check: **66 match, 6 non-building, 0 close/disagree**.
|
||||
- Geometry: blockout regenerated at 3.1 (object names preserved — the frontage
|
||||
exporter maps `element.020`/`element`/`element.019`/named buildings, all still
|
||||
present, 6/6 exported), frontage re-imported, DemoMap rebuilt, packaged build
|
||||
`-qaselftest` all PASS.
|
||||
- **Blosm adds roof mass** on top of `levels × levelHeight`: 6922 39.1 m vs table
|
||||
37.2, El Capitan Theatre 20.5 m vs 18.6 — both ~+1.9 m. That is a roof, not a
|
||||
height error; the table is the tagged height, the mesh is storey + roof.
|
||||
- Gotcha: regenerating the blockout is safe for names, but any future change to
|
||||
the import must re-verify the 6/6 frontage export and the `element.*` mapping.
|
||||
@@ -16,7 +16,7 @@ Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs t
|
||||
|
||||
All shots ☑ approved 2026-11-07, including the block frontage pass that added the neighbouring buildings and corrected the hero and north-building placement.
|
||||
|
||||
**2026-11-08 — regenerated set.** Re-rendered via `Tools/render_screenshots.sh` after the placement release check. The `demo-north-buildings` camera was re-pointed (it previously sat 9.5 km west and framed 85 % sky); that shot now shows both north buildings, and all maps carry the re-staged shot cameras. **Approved 2026-11-08** (owner review of the regenerated set).
|
||||
**2026-11-08 — regenerated set (v0.1.1 geometry).** Re-rendered again after the `0.1.1` street change (sidewalk upstand tapered to the building edge, crossing ramps graded to ≤8 %). Re-rendered via `Tools/render_screenshots.sh` after the placement release check. The `demo-north-buildings` camera was re-pointed (it previously sat 9.5 km west and framed 85 % sky); that shot now shows both north buildings, and all maps carry the re-staged shot cameras. **Approved 2026-11-08** (owner review of the regenerated set).
|
||||
|
||||
| # | File | Preview | Map | View | What it shows |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,31 +9,16 @@ Actionable task list for this project. This file is the single, central place fo
|
||||
- When all steps in a version section are done: write a `CHANGELOG.md` entry, then remove the version section from this file.
|
||||
- Release gate: a version is tagged only on the owner's explicit go-ahead once its release checks pass, not automatically when the checkboxes close.
|
||||
|
||||
## 0.1.1 — Collision fixes (patch)
|
||||
|
||||
Suggested blender skills: **collision-proxy** (UCX/UHX naming, convex hulls, simple collider meshes — the core of the authoring pass), **qa-review** (validation checklists, viewport screenshots, ship/no-ship verdicts for the audit), **asset-optimization** (pre-export validation of every collider). Note: these skills assume MCP-driven Blender; use them as method guides for the headless scripts.
|
||||
|
||||
### Patch tasks
|
||||
|
||||
- [ ] **Sidewalk outer edge against terrain.** The pre-check measured a **15.8 cm** step where a walk sample crosses from `Terrain_DEM` onto `Sidewalk_South` (`Art/blender/README.md`, s≈207 m). Mostly hidden by buildings; visible wherever the frontage is missing. Fix by extending the sidewalk mesh to meet the terrain, or by lowering the outer lip.
|
||||
- [ ] **Crossing ramp gradient.** The kerb-flush transitions measure **10–13 cm over 1.5 m ≈ 10 %**, steeper than the 8 % a real ramp uses (`Geo/tools/street_section.py` output, s≈33, 39, 126 m). Grade the three ramp openings properly.
|
||||
- [x] **Collision authoring pass.** Terrain and site meshes use complex-as-simple / `BlockAll` (forced by `UGigLineAssetTools`, verified 2026-11-08 with 84-sample transect). Remaining: simple convex hulls per building (currently using default collision). Record the final settings in `Docs/build.md`.
|
||||
- [ ] **Kerb height is an assumption.** 0.15 m modelled, 0.13 m as built, and the 3DEP DEM cannot resolve a kerb at all. Check against the photographs and record the final figure.
|
||||
- [ ] Audit and fix any collision gaps or fall-through geometry found in `0.1.0` in the engine.
|
||||
- [ ] Regression-test traversal on both sides of the demo.
|
||||
- [ ] Verify no new packaging defects.
|
||||
- [ ] Tag `v0.1.1` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.1.2 — Scale and façade corrections (patch)
|
||||
|
||||
Suggested blender skills: **references** (reference-image-match and visual-match-checklist templates — measure the guessed heights and colonnade proportions against the photographs), **archviz** (real-world scale and façade composition discipline), **camera-cinematography** (reproduce the reference camera angles when comparing renders to photos).
|
||||
|
||||
### Patch tasks
|
||||
### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`)
|
||||
|
||||
- [ ] Correct building scale and street proportions flagged against references.
|
||||
- [x] Correct building scale and street proportions flagged against references. **Mapbox adopted 2026-11-09.** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints; after adopting its two findings the cross-check reports **66 match, 6 non-building, 0 disagreements**. Adopted: **`LEVEL_HEIGHT` 3.2 → 3.1 m** (`block_height_report.py`, and the Blosm `--level-height` default), plus explicit `HEIGHT_OVERRIDES` (3.0 m) for the three rear `building=yes`/`apartments` structures Mapbox gives as single-storey. Blockout regenerated, frontage re-exported/re-imported, DemoMap rebuilt; packaged `-qaselftest` all PASS. Measured effect: 6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m (both now ~1.9 m above their table height = roof mass).
|
||||
- [ ] Refine hero façade details where reference photography shows discrepancies.
|
||||
- [ ] **Replace the placeholder heights.** Three of the fifteen block buildings carry no OSM height and render at a guessed 6.4 m (`Geo/tools/block_height_report.py`). Measure them off the photographs and correct the blockout.
|
||||
- [ ] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions.
|
||||
- [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected.
|
||||
- [ ] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions. **Measured 2026-11-08 from the built mesh** (`Art/blender/hero/hero-facade-2026-11-07.blend`): envelope 0–14.50 m; stylobate 0–2.00; columns 2.00–7.95; capitals 7.95–8.90; entablature 8.90–12.30; parapet 12.30–14.50. So the colonnade (columns + capitals) is **6.90 m = 47.6 %** of the height — inside the range only at the low end. Bringing it to ~55 % needs ~+1.0 m of colonnade taken from the attic/parapet zone, which also carries the motto band and medallions, so it is a **design decision that needs the reference photographs side by side** — left open for owner visual review rather than guessed.
|
||||
- [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.1.3 — Packaging and setup fixes (patch)
|
||||
|
||||
+18
-1
@@ -57,4 +57,21 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `UnrealEditor <uproject> /Game/Hollywood/DemoMap -game -qaselftest` | Headless behaviour check: drives forward input for 2 s, then resets and pauses. Logs `GigLine QA: ... PASS/FAIL` lines and a final `RESULT`. Runs only with `-qaselftest`, so normal play is unaffected. |
|
||||
| `GigLine SIM` / `GigLine GROUND` log lines | Emitted every second by `AGigLineCharacter` (TEMP QA): position, fall state, movement mode, and what the ground probe hits underneath. Remove before release. |
|
||||
| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. |
|
||||
| `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. |
|
||||
|
||||
## Input injection
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `input_emulation.py` | Pure-stdlib `ctypes`/`fcntl` driver that creates a virtual keyboard+mouse on `/dev/uinput`. The desktop treats it as real hardware, so it proves whether OS input actually reaches the game. Needs `/dev/uinput` writable (ACL on this host). |
|
||||
| `input_test.py` | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r requirements-tools.txt
|
||||
python3 -m unittest discover -s Tools/tests -t Tools -p 'test_*.py'
|
||||
```
|
||||
|
||||
Stdlib `unittest`, no Blender/Unreal process. See [`tests/README.md`](tests/README.md): test the pure parts (encoding, classification, maths, parsing); if logic is buried in `main()`, extract it into a function so it can be tested.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Tooling tests
|
||||
|
||||
Standard-library `unittest` tests for the host-side tools in `Tools/`. They never start Blender, Unreal, or the game; anything that needs those processes is verified by the tool itself and recorded in `Docs/release-checks.md` instead.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r requirements-tools.txt
|
||||
python3 -m unittest discover -s Tools/tests -t Tools -p 'test_*.py'
|
||||
```
|
||||
|
||||
The `-t Tools` top-level directory is required: the tests import the tool modules by their plain names (`import input_emulation`), which is why `Tools/tests/` carries an `__init__.py`.
|
||||
|
||||
Individual test:
|
||||
|
||||
```bash
|
||||
python3 -m unittest Tools.tests.test_input_emulation -v
|
||||
```
|
||||
|
||||
## What belongs here
|
||||
|
||||
Test the **pure** parts — argument parsing, key/coordinate encoding, geometry maths, licence gating, report formatting, decision classification. Network, Blender and Unreal calls stay out: a test that needs a paid API, a display, or an engine process is a manual check, not a unit test.
|
||||
|
||||
When a tool's logic is buried in `main()`, extract it into a function so it can be tested directly. That is a tooling improvement, not test scaffolding.
|
||||
|
||||
Geo (`Geo/tools/`) and Blender (`Art/blender/*/`) builders are tested the same way once their pure parts are extracted; note the module path in the test.
|
||||
@@ -0,0 +1 @@
|
||||
"""Package marker so `python3 -m unittest Tools.tests.<module>` resolves."""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for Geo/tools/block_height_report.py::classify_height.
|
||||
|
||||
The Geo tools live outside `Tools/`, so the test adds `Geo/tools` to `sys.path`
|
||||
before importing the module by name.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "Geo", "tools"))
|
||||
|
||||
import block_height_report as bhr # noqa: E402
|
||||
|
||||
|
||||
class ClassifyHeightTest(unittest.TestCase):
|
||||
def test_height_tag_wins(self):
|
||||
height, source = bhr.classify_height({"building": "yes", "height": "18.1"})
|
||||
self.assertEqual(height, 18.1)
|
||||
self.assertEqual(source, "height tag")
|
||||
|
||||
def test_height_tag_beats_levels(self):
|
||||
height, _ = bhr.classify_height(
|
||||
{"building": "yes", "height": "12.5", "building:levels": "4"})
|
||||
self.assertEqual(height, 12.5)
|
||||
|
||||
def test_levels_derive_height(self):
|
||||
height, source = bhr.classify_height(
|
||||
{"building": "yes", "building:levels": "4"}, level_height=3.2)
|
||||
self.assertEqual(height, 12.8)
|
||||
self.assertIn("levels x", source)
|
||||
|
||||
def test_roof_is_not_a_building_volume(self):
|
||||
# building=roof at layer=1 is an elevated canopy, not a two-storey block.
|
||||
height, source = bhr.classify_height({"building": "roof", "layer": "1"})
|
||||
self.assertEqual(height, 0.0)
|
||||
self.assertIn("not a building volume (roof)", source)
|
||||
|
||||
def test_canopy_is_not_a_building_volume(self):
|
||||
# building=canopy at layer=1, found by the Mapbox cross-check.
|
||||
height, source = bhr.classify_height({"building": "canopy", "layer": "1"})
|
||||
self.assertEqual(height, 0.0)
|
||||
self.assertIn("not a building volume (canopy)", source)
|
||||
|
||||
def test_transit_shelter_is_not_a_building_volume(self):
|
||||
height, source = bhr.classify_height(
|
||||
{"building": "yes", "amenity": "shelter", "shelter_type": "public_transport"})
|
||||
self.assertEqual(height, 0.0)
|
||||
self.assertIn("not a building volume (shelter)", source)
|
||||
|
||||
def test_missing_data_falls_back_to_a_flagged_guess(self):
|
||||
height, source = bhr.classify_height({"building": "yes"})
|
||||
self.assertEqual(height, 2 * bhr.LEVEL_HEIGHT)
|
||||
self.assertTrue(source.startswith("GUESS"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for Tools/input_emulation.py — the pure ioctl/key-code parts.
|
||||
|
||||
The device path itself needs /dev/uinput, so only the encoding helpers are
|
||||
covered here; the end-to-end injection is verified by running the tool (see
|
||||
Docs/release-checks.md and MEMORY.md).
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import input_emulation as ie
|
||||
|
||||
|
||||
class IocEncodingTest(unittest.TestCase):
|
||||
"""_IOC must match the Linux ioctl bit layout."""
|
||||
|
||||
def test_layout_constants(self):
|
||||
self.assertEqual(ie._IOC_NRSHIFT, 0)
|
||||
self.assertEqual(ie._IOC_TYPESHIFT, 8)
|
||||
self.assertEqual(ie._IOC_SIZESHIFT, 16)
|
||||
self.assertEqual(ie._IOC_DIRSHIFT, 30)
|
||||
|
||||
def test_known_values(self):
|
||||
# UI_SET_EVBIT = _IOW('U', 100, int) -> 0x40045564 on Linux.
|
||||
self.assertEqual(ie.UI_SET_EVBIT, 0x40045564)
|
||||
# UI_DEV_CREATE = _IO('U', 1) -> 0x5501.
|
||||
self.assertEqual(ie.UI_DEV_CREATE, 0x5501)
|
||||
|
||||
def test_type_accepts_char_or_int(self):
|
||||
self.assertEqual(ie._ioc(1, 4, "U", 100), ie._ioc(1, 4, ord("U"), 100))
|
||||
|
||||
|
||||
class KeyCodeTest(unittest.TestCase):
|
||||
"""KEYCODES must hold the Linux input-event codes the demo uses."""
|
||||
|
||||
EXPECTED = {
|
||||
"w": 17, "s": 31, "a": 30, "d": 32,
|
||||
"space": 57, "r": 19, "esc": 1,
|
||||
}
|
||||
|
||||
def test_demo_controls(self):
|
||||
for name, code in self.EXPECTED.items():
|
||||
self.assertEqual(ie.KEYCODES[name], code, f"{name} code")
|
||||
|
||||
def test_lookup_is_case_insensitive(self):
|
||||
dev = ie.InputDevice.__new__(ie.InputDevice) # no device opened
|
||||
self.assertEqual(dev._key_code("W"), 17)
|
||||
self.assertEqual(dev._key_code("SPACE"), 57)
|
||||
self.assertEqual(dev._key_code(19), 19) # a raw code passes through
|
||||
|
||||
def test_unknown_key_raises(self):
|
||||
dev = ie.InputDevice.__new__(ie.InputDevice)
|
||||
with self.assertRaises(ie.UInputError):
|
||||
dev._key_code("not-a-key")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for Geo/tools/mapbox_buildings.py — the pure merge logic.
|
||||
|
||||
No network calls here: Tilequery responses are represented as the small dicts
|
||||
the API returns, and only the selection/comparison/formatting helpers are
|
||||
exercised. The live call is verified by running the tool.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(ROOT, "Geo", "tools"))
|
||||
|
||||
import mapbox_buildings as mb # noqa: E402
|
||||
|
||||
|
||||
def feature(distance, height=None, min_height=0, type_="building", layer="building"):
|
||||
props = {"tilequery": {"distance": distance, "layer": layer}}
|
||||
if height is not None:
|
||||
props["height"] = height
|
||||
props["min_height"] = min_height
|
||||
props["type"] = type_
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
class NearestBuildingTest(unittest.TestCase):
|
||||
def test_picks_the_closest_building(self):
|
||||
payload = {"features": [feature(6.0, 10.0), feature(1.0, 20.0)]}
|
||||
best = mb.nearest_building(payload, max_distance=8.0)
|
||||
self.assertEqual(best["height"], 20.0)
|
||||
|
||||
def test_ignores_hits_beyond_max_distance(self):
|
||||
payload = {"features": [feature(20.0, 10.0)]}
|
||||
self.assertIsNone(mb.nearest_building(payload, max_distance=8.0))
|
||||
|
||||
def test_ignores_other_layers(self):
|
||||
payload = {"features": [feature(0.0, 10.0, layer="road")]}
|
||||
self.assertIsNone(mb.nearest_building(payload, max_distance=8.0))
|
||||
|
||||
def test_no_features(self):
|
||||
self.assertIsNone(mb.nearest_building({"features": []}, max_distance=8.0))
|
||||
|
||||
|
||||
class CompareTest(unittest.TestCase):
|
||||
def test_match_within_half_a_metre(self):
|
||||
self.assertEqual(mb.compare(14.5, "height tag", 14.2)[0], "match")
|
||||
|
||||
def test_close_within_two_metres(self):
|
||||
verdict, delta = mb.compare(38.4, "levels x 3.2 m", 37.2)
|
||||
self.assertEqual(verdict, "close")
|
||||
self.assertAlmostEqual(delta, -1.2)
|
||||
|
||||
def test_disagree_beyond_two_metres(self):
|
||||
verdict, delta = mb.compare(6.4, "GUESS: default 2 levels x 3.2 m", 3.0)
|
||||
self.assertEqual(verdict, "disagree")
|
||||
self.assertAlmostEqual(delta, -3.4)
|
||||
|
||||
def test_mapbox_only_when_we_have_nothing(self):
|
||||
self.assertEqual(mb.compare(0.0, "height tag", 12.0)[0], "mapbox-only")
|
||||
|
||||
def test_ours_only_when_mapbox_returns_nothing(self):
|
||||
self.assertEqual(mb.compare(12.0, "height tag", None)[0], "ours-only")
|
||||
|
||||
def test_non_building_is_excluded_from_comparison(self):
|
||||
verdict, delta = mb.compare(0.0, "not a building volume (roof)", 3.0)
|
||||
self.assertEqual(verdict, "non-building")
|
||||
self.assertIsNone(delta)
|
||||
|
||||
def test_canopy_is_non_building(self):
|
||||
verdict, _ = mb.compare(0.0, "not a building volume (canopy)", 3.0)
|
||||
self.assertEqual(verdict, "non-building")
|
||||
|
||||
|
||||
class CentroidTest(unittest.TestCase):
|
||||
def test_centroid_of_a_square(self):
|
||||
lat, lon = mb.centroid_lat_lon(
|
||||
[(34.0, -118.0), (34.0, -117.0), (35.0, -117.0), (35.0, -118.0)])
|
||||
self.assertAlmostEqual(lat, 34.5)
|
||||
self.assertAlmostEqual(lon, -117.5)
|
||||
|
||||
|
||||
class RenderTest(unittest.TestCase):
|
||||
ARGS = type("A", (), {"radius": 25, "max_distance": 8.0, "only_diffs": False})()
|
||||
|
||||
def test_disagreements_sort_first(self):
|
||||
rows = [
|
||||
{"verdict": "match", "delta_m": 0.1, "way": 1, "name": "", "kind": "yes",
|
||||
"osm_height_m": 10.0, "osm_source": "height tag", "mapbox_height_m": 10.1,
|
||||
"mapbox_min_height_m": 0, "mapbox_type": "building",
|
||||
"mapbox_distance_m": 0.0, "cached": True, "lat": 0, "lon": 0},
|
||||
{"verdict": "disagree", "delta_m": -3.4, "way": 2, "name": "", "kind": "yes",
|
||||
"osm_height_m": 6.4, "osm_source": "GUESS", "mapbox_height_m": 3.0,
|
||||
"mapbox_min_height_m": 0, "mapbox_type": "building",
|
||||
"mapbox_distance_m": 0.0, "cached": True, "lat": 0, "lon": 0},
|
||||
]
|
||||
text = mb.render_markdown(rows, self.ARGS)
|
||||
self.assertLess(text.index("way 2"), text.index("way 1"))
|
||||
|
||||
def test_never_contains_a_token(self):
|
||||
rows = []
|
||||
text = mb.render_markdown(rows, self.ARGS)
|
||||
self.assertNotIn("access_token", text)
|
||||
self.assertNotIn("sk.", text)
|
||||
|
||||
|
||||
class TokenTest(unittest.TestCase):
|
||||
def test_explicit_token_wins(self):
|
||||
self.assertEqual(mb.load_token("abc123"), "abc123")
|
||||
|
||||
def test_token_is_stripped(self):
|
||||
self.assertEqual(mb.load_token(" abc123\n"), "abc123")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.28
|
||||
Reference in new issue
Block a user