Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba68dcfdbc | ||
|
|
640bff9631 | ||
|
|
5ffe62f6aa | ||
|
|
81f7cd3991 | ||
|
|
68d8745534 | ||
|
|
420f863999 |
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.
|
||||
|
||||
+13
-26
@@ -80,10 +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. 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).
|
||||
- `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.
|
||||
@@ -106,32 +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.48 … +3.06 | 13.4 cm (s=198) |
|
||||
| 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) |
|
||||
| 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`.
|
||||
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, 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.
|
||||
- ~~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.
|
||||
**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.
|
||||
|
||||
@@ -146,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.
@@ -3,18 +3,18 @@
|
||||
"frame": "project frame, metres, X east Y north Z up",
|
||||
"bounds_m": {
|
||||
"min": [
|
||||
-13.02,
|
||||
-13.08,
|
||||
-12.5,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
13.02,
|
||||
0.07,
|
||||
13.08,
|
||||
0.13,
|
||||
14.5
|
||||
],
|
||||
"size": [
|
||||
26.04,
|
||||
12.57,
|
||||
26.16,
|
||||
12.63,
|
||||
14.5
|
||||
]
|
||||
},
|
||||
@@ -33,10 +33,28 @@
|
||||
"dims_m": [
|
||||
0.95,
|
||||
0.95,
|
||||
7.45
|
||||
8.25
|
||||
],
|
||||
"evidence": "6 columns at 4.96 m centres (sibling notes x measured frontage)"
|
||||
},
|
||||
{
|
||||
"object": "Hero_ColumnBase_x6",
|
||||
"dims_m": [
|
||||
1.36,
|
||||
1.36,
|
||||
0.22
|
||||
],
|
||||
"evidence": "assumption: Ionic base moulding; photographs show a wider base at the shaft foot"
|
||||
},
|
||||
{
|
||||
"object": "Hero_Abacus_x6",
|
||||
"dims_m": [
|
||||
1.3,
|
||||
1.3,
|
||||
0.14
|
||||
],
|
||||
"evidence": "assumption: square abacus under the architrave, as Ionic capitals carry"
|
||||
},
|
||||
{
|
||||
"object": "Hero_Architrave",
|
||||
"dims_m": [
|
||||
@@ -55,12 +73,21 @@
|
||||
],
|
||||
"evidence": "assumption: projecting cornice line"
|
||||
},
|
||||
{
|
||||
"object": "Hero_Dentil_xN",
|
||||
"dims_m": [
|
||||
0.18,
|
||||
0.3,
|
||||
0.22
|
||||
],
|
||||
"evidence": "52 dentils at 0.45 m centres under the corona; assumption: photographed cornice reads as a dentilled course"
|
||||
},
|
||||
{
|
||||
"object": "Hero_Entablature",
|
||||
"dims_m": [
|
||||
25.75,
|
||||
1.5,
|
||||
3.4
|
||||
3.0
|
||||
],
|
||||
"evidence": "assumption: entablature depth"
|
||||
},
|
||||
@@ -69,7 +96,7 @@
|
||||
"dims_m": [
|
||||
25.75,
|
||||
1.2,
|
||||
2.2
|
||||
1.8
|
||||
],
|
||||
"evidence": "assumption: parapet carries the motto band"
|
||||
},
|
||||
@@ -86,10 +113,10 @@
|
||||
"object": "Hero_Medallion_x2",
|
||||
"dims_m": [
|
||||
1.1,
|
||||
1.1,
|
||||
0.02
|
||||
0.04,
|
||||
1.1
|
||||
],
|
||||
"evidence": "photographs: two stone discs beside the motto"
|
||||
"evidence": "photographs: two stone discs beside the motto, mounted on the parapet face"
|
||||
},
|
||||
{
|
||||
"object": "Hero_Stylobate",
|
||||
|
||||
@@ -15,6 +15,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.join(os.path.join(os.path.dirname(__file__), "..", "..", ".."), "Tools"))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
|
||||
"Kerb_South", "Kerb_North", "Crossing_Markings")
|
||||
@@ -60,20 +63,7 @@ def main():
|
||||
_ = bpy.context.view_layer
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
path = os.path.join(args.out, name + ".fbx")
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=True,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_static(path, selection=True)
|
||||
if mirrored:
|
||||
# The flip is an export correction, not a change to the source: undo it.
|
||||
obj.scale.y *= -1.0
|
||||
|
||||
+72
-90
@@ -8,127 +8,109 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [0.1.1] — 2026-11-08
|
||||
## [0.1.2] — 2026-11-09
|
||||
|
||||
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.
|
||||
Scale and façade correction patch. Buildings and the hero elevation now match the reference photography and the cross-checked height data; no new gameplay scope.
|
||||
|
||||
### 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.
|
||||
- **`Tools/facade_detail_concepts.py`** — generates frontal building plates (whole elevation, entrance in its façade, building with neighbours) from a local reference photograph through OpenRouter image models. Spec-driven, never overwrites, writes a provenance sidecar per image, output stays in the gitignored `Art/reference/` tree. Local working material only.
|
||||
- **`Tools/fbx_export_preset.py`** — the single FBX export preset (`ue_metric_v1` / `ue_character_v1`). Replaces the argument block that had been copied into six exporters.
|
||||
- **Façade occlusion probe** in `-qaselftest`: 40 m traces sideways from the road centre at walking height, required wherever a building is staged.
|
||||
- **Ionic detail on the hero**: base moulding at every shaft foot, a square abacus over every capital, and a 52-dentil cornice course.
|
||||
- **Tool tests**: `Tools/tests/` now covers the input driver, height classification, the Mapbox cross-check, the concept generator and the FBX preset (49 tests).
|
||||
|
||||
### 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.
|
||||
- **Storey height 3.2 → 3.1 m**, calibrated against Mapbox building data, with explicit overrides for three rear outbuildings; the block cross-check now reports 66 matches and no disagreements.
|
||||
- **Hero colonnade 47.6 % → 53.1 %** of the 14.50 m elevation, inside the photographed 50–60 % band (thinner entablature and parapet).
|
||||
- Blockout regenerated at the new storey height; frontage re-exported and re-imported (6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m).
|
||||
- FBX exports now carry smoothing groups (`FACE` instead of `OFF`), so round geometry is no longer faceted and imports stop warning.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows floated 1.44 m in front of the wall**, ahead of the colonnade; they now sit on the wall plane behind the columns.
|
||||
- **Parapet medallions were flat discs lying on the coping** instead of discs on the parapet face.
|
||||
- **The hero façade had no usable collision** — the player could walk through it. It is now complex-as-simple; auto-generated convex hulls were tried and rejected because they bulged into the street.
|
||||
- **The three "guessed" block heights were never buildings**: two `building=roof` canopies and a transit shelter. They are classified as non-building volumes and the block now reports zero guessed heights.
|
||||
|
||||
### 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.
|
||||
- Packaged build `-qaselftest`: walk, reset and pause pass; seam transect 1456 samples / 0 holes; wall probe 0/35; façade probe 0/9.
|
||||
- Screenshots re-rendered for every change and approved by the owner.
|
||||
|
||||
### 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`).
|
||||
- Cornice dentil spacing, the base and abacus dimensions are recorded assumptions: the reference photography does not resolve them at our scale.
|
||||
- Façade surfaces remain flat blockout materials; the textured pass is `0.1.4`.
|
||||
|
||||
## [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**
|
||||
(El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap
|
||||
layout data and USGS elevation, with the hero façade and the verified block
|
||||
frontage staged on a walkable street.
|
||||
The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd** (El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap layout data and USGS elevation, with the hero façade and the verified block frontage staged on a walkable street.
|
||||
|
||||
### Added
|
||||
|
||||
- **Unreal project** (`Game/GigLine`) — C++ game module, Enhanced Input, packaged
|
||||
Linux build, and a `DemoMap` that a plain launch opens directly.
|
||||
- **Walkable demo level** — terrain from the 3DEP DEM, Hollywood Blvd carriageway,
|
||||
both sidewalks and kerbs with collision, the designated mid-block crossing, and
|
||||
fixed daytime lighting (sun, sky light, sky atmosphere, height fog, pinned
|
||||
exposure).
|
||||
- **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with
|
||||
three genuine portal recesses, entablature, parapet and motto band
|
||||
(`Tools/hero_facade_build.py`, `Tools/ue_hero_facade_import.py`).
|
||||
- **Block frontage** — the five verified south-side neighbours (6922, 6902, 6838
|
||||
El Capitan Theatre, 6822 Budget, 6806–6822, 6800 Lee Drug) and the two north-side
|
||||
buildings (TCL Chinese Theatre, Madame Tussaud's) placed at their OSM positions.
|
||||
- **First-person player** — `AGigLineCharacter`: 1.75 m capsule, 1.60 m eye camera,
|
||||
4 m/s walk, mouse look, jump, reset (`R`) and pause (`Escape`); controls
|
||||
documented in `Docs/controls.md` and shown in-game.
|
||||
- **In-game ODbL attribution** — `AGigLineHUD` draws the OpenStreetMap credit and
|
||||
licence line every frame.
|
||||
- **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall
|
||||
probe, frame-time capture) and `Tools/input_test.py` / `Tools/input_emulation.py`
|
||||
for OS-level virtual keyboard/mouse input.
|
||||
- **Documentation** — setup, build, toolchain, licensing, ODbL review, release
|
||||
checks, and arc42 architecture docs.
|
||||
- **Unreal project** (`Game/GigLine`) — C++ game module, Enhanced Input, packaged Linux build, and a `DemoMap` that a plain launch opens directly.
|
||||
- **Walkable demo level** — terrain from the 3DEP DEM, Hollywood Blvd carriageway, both sidewalks and kerbs with collision, the designated mid-block crossing, and fixed daytime lighting (sun, sky light, sky atmosphere, height fog, pinned exposure).
|
||||
- **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with three genuine portal recesses, entablature, parapet and motto band (`Tools/hero_facade_build.py`, `Tools/ue_hero_facade_import.py`).
|
||||
- **Block frontage** — the five verified south-side neighbours (6922, 6902, 6838 El Capitan Theatre, 6822 Budget, 6806–6822, 6800 Lee Drug) and the two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) placed at their OSM positions.
|
||||
- **First-person player** — `AGigLineCharacter`: 1.75 m capsule, 1.60 m eye camera, 4 m/s walk, mouse look, jump, reset (`R`) and pause (`Escape`); controls documented in `Docs/controls.md` and shown in-game.
|
||||
- **In-game ODbL attribution** — `AGigLineHUD` draws the OpenStreetMap credit and licence line every frame.
|
||||
- **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall probe, frame-time capture) and `Tools/input_test.py` / `Tools/input_emulation.py` for OS-level virtual keyboard/mouse input.
|
||||
- **Documentation** — setup, build, toolchain, licensing, ODbL review, release checks, and arc42 architecture docs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **All controls were dead in packaged and PIE builds.** The mapping-context
|
||||
generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads
|
||||
`DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and
|
||||
every key resolved to nothing. `Tools/ue_create_input_assets.py` now populates
|
||||
the current field.
|
||||
- **"Fall-through" was an 8 m invisible box.** `Terrain_DEM` shipped only a
|
||||
bounding-box collision proxy; forcing complex-as-simple collision
|
||||
(`UGigLineAssetTools`) put the player on the actual street.
|
||||
- **Reset returned the player to mid-air** — the start position is now captured on
|
||||
the first grounded frame.
|
||||
- **Plain launch opened the engine template** — the default map now lives in
|
||||
`DefaultEngine.ini` and points at `DemoMap`.
|
||||
- **Game target would not compile** — removed editor-only API use
|
||||
(`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`).
|
||||
- **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the
|
||||
buildings); re-pointed to the building mass.
|
||||
- **All controls were dead in packaged and PIE builds.** The mapping-context generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads `DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and every key resolved to nothing. `Tools/ue_create_input_assets.py` now populates the current field.
|
||||
- **"Fall-through" was an 8 m invisible box.** `Terrain_DEM` shipped only a bounding-box collision proxy; forcing complex-as-simple collision (`UGigLineAssetTools`) put the player on the actual street.
|
||||
- **Reset returned the player to mid-air** — the start position is now captured on the first grounded frame.
|
||||
- **Plain launch opened the engine template** — the default map now lives in `DefaultEngine.ini` and points at `DemoMap`.
|
||||
- **Game target would not compile** — removed editor-only API use (`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`).
|
||||
- **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the buildings); re-pointed to the building mass.
|
||||
|
||||
### Verified
|
||||
|
||||
- Both sides of the demo are traversable: 1456-sample seam transect, 0 holes,
|
||||
no blocking collision or invisible walls (`-qaselftest` on the packaged build).
|
||||
- Placement and street scale match the reference photography and Geo
|
||||
measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m).
|
||||
- **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no
|
||||
frame generation) — far above the 60 fps target.
|
||||
- Fresh checkout builds and packages with the documented external engine
|
||||
(UE 5.8.4).
|
||||
- Both sides of the demo are traversable: 1456-sample seam transect, 0 holes, no blocking collision or invisible walls (`-qaselftest` on the packaged build).
|
||||
- Placement and street scale match the reference photography and Geo measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m).
|
||||
- **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no frame generation) — far above the 60 fps target.
|
||||
- Fresh checkout builds and packages with the documented external engine (UE 5.8.4).
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Buildings other than the hero are flat blockout volumes; façade detail,
|
||||
materials and Blosm style scripts are planned for `0.1.4`.
|
||||
- Kerb height (0.13 m as built vs 0.15 m assumed), kerb radii, driveway aprons and
|
||||
graded crossing ramps are deferred to `0.1.1`; crossing ramps are kerb flushes.
|
||||
- Buildings other than the hero are flat blockout volumes; façade detail, materials and Blosm style scripts are planned for `0.1.4`.
|
||||
- Kerb height (0.13 m as built vs 0.15 m assumed), kerb radii, driveway aprons and 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.1...main
|
||||
[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.2...main
|
||||
[0.1.2]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.2
|
||||
[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
|
||||
|
||||
|
||||
@@ -199,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).
|
||||
|
||||
|
||||
@@ -95,6 +95,15 @@ Run on the packaged build (`GigLineQA`, Development, Vulkan SM5) with `-qaselfte
|
||||
- **Invisible-wall probe:** horizontal traces 30 m long, grounded at 160 cm above
|
||||
the local ground, along the road and both seam rows. Result **0/35 blocking hits
|
||||
inside 21 m** — no invisible walls or misplaced blockers in the street envelope.
|
||||
- **Facade occlusion probe:** from the road centre, 40 m traces sideways at walking
|
||||
height toward each frontage, required only where the map stages a building (the
|
||||
spans are listed in `AGigLineCharacter` and mirror `Tools/ue_build_demo_map.py`).
|
||||
Result **0/9 directions without a blocking wall** — the player cannot walk
|
||||
through the hero or the frontage. This probe found the hero façade had **no
|
||||
simple collision** at all (complex-only, so traces and capsule sweeps passed
|
||||
through it); it is now `UseComplexAsSimple`. Auto-generated convex hulls were
|
||||
tried first and rejected: on a 95-part, 26 m façade they bulged into the street
|
||||
(the wall probe went 0/35 → 2/35).
|
||||
(First pass used a fixed Z 160 start; the street is at Z ~366, so the probe
|
||||
started inside the earth and reported every row as a dist-0 blocker. Grounding
|
||||
the probe fixed that measurement bug.)
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -416,6 +416,81 @@ void AGigLineCharacter::StartQASelfTest()
|
||||
TEXT("GigLine QA: wall probe %d/%d traces with blocking hit inside 21 m -> %s"),
|
||||
Walls, WallSamples,
|
||||
Walls == 0 ? TEXT("PASS") : TEXT("FAIL"));
|
||||
|
||||
// Facade occlusion probe: walkable street is only half the story —
|
||||
// the player must also NOT be able to walk through the buildings.
|
||||
// From the road centre, fire 40 m sideways at walking height toward
|
||||
// each frontage and require a blocking hit. A miss means the
|
||||
// building has no collision (or only non-blocking collision) and the
|
||||
// player would pass through the wall.
|
||||
// Expect a wall only where the map actually stages a building.
|
||||
// Spans below mirror the actors placed by Tools/ue_build_demo_map.py
|
||||
// (frontage + hero) and the two north buildings; update them if
|
||||
// that list changes. Gaps between buildings are legitimate misses.
|
||||
struct FSpan { float Min, Max; };
|
||||
static const FSpan SouthSpans[] = {
|
||||
{-24276.f, -17700.f}, // 6922
|
||||
{-17749.f, -14591.f}, // 6902
|
||||
{-14638.f, -12034.f}, // 6840 hero
|
||||
{-12082.f, -8210.f}, // 6838 El Capitan Theatre
|
||||
{-7854.f, -7252.f}, // 6822 Budget
|
||||
{-6608.f, -3307.f}, // 6806-6820
|
||||
{-3320.f, -1437.f}, // 6800 Lee Drug
|
||||
};
|
||||
static const FSpan NorthSpans[] = {
|
||||
{-27071.f, -22799.f}, // Madame Tussaud's
|
||||
{-22889.f, -18442.f}, // TCL Chinese Theatre
|
||||
};
|
||||
auto InSpans = [](const FSpan* Spans, int32 Count, float X)
|
||||
{
|
||||
for (int32 i = 0; i < Count; ++i)
|
||||
{
|
||||
if (X >= Spans[i].Min && X <= Spans[i].Max) { return true; }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
int32 Facades = 0, FacadeSamples = 0;
|
||||
for (float X0 = -19000.0f; X0 <= -3000.0f; X0 += 2000.0f)
|
||||
{
|
||||
for (float Dir : {1.0f, -1.0f}) // +Y south frontage, -Y north side
|
||||
{
|
||||
const bool bExpectWall = (Dir > 0.0f)
|
||||
? InSpans(SouthSpans, UE_ARRAY_COUNT(SouthSpans), X0)
|
||||
: InSpans(NorthSpans, UE_ARRAY_COUNT(NorthSpans), X0);
|
||||
if (!bExpectWall)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
FHitResult GroundHit2;
|
||||
FCollisionQueryParams GQ2(TEXT("GigLineFacadeGround"), false, this);
|
||||
if (!W->LineTraceSingleByChannel(GroundHit2,
|
||||
FVector(X0, 0.0f, 3000.f), FVector(X0, 0.0f, -1000.f),
|
||||
ECC_Visibility, GQ2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const float ProbeZ2 = GroundHit2.ImpactPoint.Z + 160.f;
|
||||
FHitResult Hit2;
|
||||
FCollisionQueryParams Q2(TEXT("GigLineFacadeProbe"), false, this);
|
||||
const bool bHit2 = W->LineTraceSingleByChannel(Hit2,
|
||||
FVector(X0, 0.0f, ProbeZ2),
|
||||
FVector(X0, Dir * 4000.f, ProbeZ2),
|
||||
ECC_Visibility, Q2);
|
||||
++FacadeSamples;
|
||||
const bool bBlocking = bHit2 && Hit2.GetActor() != nullptr;
|
||||
if (!bBlocking)
|
||||
{
|
||||
++Facades;
|
||||
UE_LOG(LogTemp, Log,
|
||||
TEXT("GigLine QA: facade probe miss at X %.0f dir %.0f (no blocking hit in 40 m)"),
|
||||
X0, Dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
UE_LOG(LogTemp, Log,
|
||||
TEXT("GigLine QA: facade probe %d/%d directions without a blocking wall -> %s"),
|
||||
Facades, FacadeSamples,
|
||||
Facades == 0 ? TEXT("PASS") : TEXT("FAIL"));
|
||||
}
|
||||
|
||||
// 7.0: verdict, then stop ticking.
|
||||
|
||||
@@ -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())
|
||||
@@ -359,3 +359,151 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
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.
|
||||
|
||||
## 2026-11-09 — hero colonnade proportion fixed (47.6 % → 53.1 %)
|
||||
|
||||
- The hero's colonnade (columns 2.00–7.95 + capitals 7.95–8.90) was 6.90 m of the
|
||||
14.50 m elevation = **47.6 %**, below the photographed 50–60 %, because the
|
||||
entablature (3.40 m) and parapet (2.20 m) used 5.60 m of the height.
|
||||
- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in
|
||||
`Tools/hero_facade_build.py`; the colonnade grows to **7.70 m = 53.1 %** while the
|
||||
envelope, motto band and medallions keep their relationships (all positioned
|
||||
relative to those two constants).
|
||||
- **Gotcha (cost a bad build):** `hero_facade_build.py` takes `--out` for the
|
||||
**FBX** and `--save-blend` for the `.blend`. Passing `--out <file>.blend` writes
|
||||
FBX bytes over the blend and Blender then reports "File format is not supported".
|
||||
Correct invocation: `--out /tmp/gigline/hero-facade.fbx --save-blend Art/...blend
|
||||
--report Art/...json`.
|
||||
- Rebuilt hero → re-imported (`SM_HeroFacade`, bounds 2604 × 1257 × 1450 cm) →
|
||||
DemoMap rebuilt → screenshots re-rendered. Note `ue_hero_facade_import.py` saves
|
||||
the map, so `chmod u+w` the `.umap` first or the import silently does not persist.
|
||||
|
||||
## 2026-11-09 — AI detail plates from reference photos; floating windows fixed
|
||||
|
||||
- `Tools/facade_detail_concepts.py` generates architectural detail plates from one
|
||||
local reference photograph via OpenRouter image models (default
|
||||
`google/gemini-2.5-flash-image`; the sibling project uses the same route in
|
||||
`tools/generate_textures.py` — chat/completions with a text + image_url content
|
||||
part, image returned as `message.images[].image_url.url`). Eight studies,
|
||||
`--list` free, `--all` generates, `-vN` never overwrites, `.refgen.json` sidecar
|
||||
per image, all output under gitignored `Art/reference/facade-details/`.
|
||||
- **Prompt gotcha:** the model refuses when the prompt describes features the
|
||||
reference photograph does not show ("the image you sent appears to be a
|
||||
decorative architectural detail, not an entrance with steps"). Prompts must
|
||||
describe the *reference subject* and only restage light/people/surface.
|
||||
- **Output is real:** 1184×864 to 1344×768 PNGs, ~2 MB each.
|
||||
- **Measured defect found and fixed:** hero window panes sat at Y −1.98 while the
|
||||
mass front is Y −0.50 and the columns reach −1.02 — i.e. glazing floating 1.44 m
|
||||
in front of the wall and ahead of the colonnade. Now `WINDOW_Y = −0.56`
|
||||
(6 cm proud of the wall, behind the columns). Visible as dark openings in
|
||||
`review-hero-facade-front`.
|
||||
- Reminder: `.blend` is LFS read-only on checkout — `chmod u+w` or Blender fails
|
||||
with "Cannot save blend file … not writable" (this bit again here).
|
||||
|
||||
## 2026-11-09 — UE export hygiene: smoothing groups + facade collision
|
||||
|
||||
- **All our FBX exporters used `mesh_smooth_type="OFF"`**, which is why every Unreal
|
||||
import logged "No smoothing group information was found for this mesh". The hero
|
||||
now exports `"FACE"` and its round parts are shaded with
|
||||
`shade_smooth_by_angle(35°)` before export: cylinders read as round instead of
|
||||
faceted, and the import warning is gone (6 → 0).
|
||||
- **The hero façade had no simple collision** (complex-only), so line traces and
|
||||
capsule sweeps passed straight through it — the player could walk through the
|
||||
wall of the demo. Caught by a new `-qaselftest` **facade occlusion probe** (40 m
|
||||
sideways traces from the road centre at walking height).
|
||||
- **Auto-generated convex collision on a 95-part 26 m façade is unusable**: it
|
||||
produced coarse hulls that bulged into the street and the wall probe went
|
||||
0/35 → 2/35. Rejected in favour of `UseComplexAsSimple` via
|
||||
`UGigLineAssetTools.set_complex_collision_as_simple` (same reason the terrain
|
||||
uses it), saved on the asset.
|
||||
- Probe expectations must mirror the staged buildings: the facade probe now carries
|
||||
explicit X spans per frontage (and the TCL/Tussaud's spans on the north side) so
|
||||
a gap between buildings is not reported as a defect.
|
||||
|
||||
## 2026-11-09 — one FBX export preset instead of six copies
|
||||
|
||||
- The `bpy.ops.export_scene.fbx(...)` argument block was duplicated in six scripts
|
||||
(hero, block frontage, north buildings, site, scale test, MPFB character), and
|
||||
all six carried `mesh_smooth_type="OFF"` — hence the "No smoothing group
|
||||
information was found" warning on every import.
|
||||
- Now `Tools/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`:
|
||||
`preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` /
|
||||
`export_character()` wrappers that import `bpy` lazily. Every caller imports it
|
||||
and passes `selection=True/False`; the seven duplicated blocks are gone.
|
||||
- Callers in `Tools/` resolve the module with
|
||||
`sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`; the Blender-side
|
||||
`Art/blender/site/export_site_fbx.py` computes the repo root
|
||||
(`..`, `..`, `..`) and joins `Tools`. Getting that wrong silently inserts
|
||||
`Tools/Tools` — check the import path when adding a caller.
|
||||
- Verified end to end: scale test, hero, frontage (6/6), site (7/7) and north
|
||||
buildings all re-exported through the preset, re-imported with **0 smoothing
|
||||
warnings**, DemoMap rebuilt and `-qaselftest` all PASS (walk, transect 0 holes,
|
||||
wall probe 0/35, facade probe 0/9).
|
||||
- `mpfb_character_export.py` was not re-run: it needs the MPFB add-on on
|
||||
`BLENDER_USER_SCRIPTS` ("Add-on not loaded: bl_ext.blender_org.mpfb"), which is
|
||||
pre-existing and unrelated; the character is not in `0.1.x` gameplay.
|
||||
|
||||
## 2026-11-09 — v0.1.2 released
|
||||
|
||||
- Cornice finished: projecting corona over a **52-dentil course** (0.18 × 0.30 ×
|
||||
0.22 m at 0.45 m centres, z 12.12–12.34, 0.25 m clear of the corona). Hero is now
|
||||
3 044 tris across 148 parts, still 14.50 m. Verified: packaged `-qaselftest` all
|
||||
PASS (transect 0 holes, wall probe 0/35, facade probe 0/9).
|
||||
- `CHANGELOG.md` gets the `0.1.2` entry (added/changed/fixed/verified/limitations),
|
||||
the `0.1.2` section is removed from `TODO.md`, `ROADMAP.md` status updated, tag
|
||||
`v0.1.2` cut after the merge.
|
||||
- Assumptions recorded rather than hidden: dentil spacing, base and abacus
|
||||
dimensions are not resolvable in our reference photography.
|
||||
|
||||
## 2026-11-09 — elevation cameras levelled (straight verticals)
|
||||
|
||||
- archviz MUST: an elevation must not lean. `hero-facade-front` looked up ~4° from
|
||||
4.2 m at a 7.6 m target; `demo-spawn-view` and `demo-street-level` were pitched
|
||||
too. All three now put the camera at the target's height, so
|
||||
`FindLookAtRotation` yields pitch 0 and roll 0 and verticals are parallel by
|
||||
construction.
|
||||
- Do not try to *measure* verticality from the rendered PNG with a naive edge
|
||||
scan: banners, windows and the parapet produce different edge sets at different
|
||||
heights and the pairwise match is meaningless (it reported a 418 px "drift").
|
||||
Verify it geometrically in the camera list instead.
|
||||
+25
-19
@@ -14,26 +14,30 @@ Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs t
|
||||
|
||||
## Shot list
|
||||
|
||||
All shots ☑ approved 2026-11-07, including the block frontage pass that added the neighbouring buildings and corrected the hero and north-building placement.
|
||||
**Status: whole set ☑ approved 2026-11-09** (owner review of PR #27). Every shot was re-rendered from current `main` content after the building-height calibration was adopted (`v0.1.2`): Mapbox-derived **3.1 m per level** (`Geo/tools/block_height_report.py`), explicit overrides for three rear outbuildings, and the regenerated blockout and frontage that follow — 6922 is now 39.1 m (was 42.1 m) and the El Capitan Theatre 20.5 m (was 20.0 m).
|
||||
|
||||
**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).
|
||||
| Round | What changed |
|
||||
| --- | --- |
|
||||
| 2026-11-07 | First approved set; block frontage pass corrected hero and north-building placement |
|
||||
| 2026-11-08 | Re-rendered after `0.1.1` street geometry (sidewalk upstand flush at the building edge, crossing ramps graded ≤ 8 %); `demo-north-buildings` camera re-pointed (was 9.5 km west, 85 % sky) |
|
||||
| 2026-11-09 | Re-rendered after the `0.1.2` building-height calibration (3.1 m per level). 12 of 14 PNGs changed; `review-demo-spawn-view` and `review-demo-street-level` are byte-identical to the `0.1.1` round, so the height change reads mostly on the frontage silhouettes |
|
||||
|
||||
| # | File | Preview | Map | View | What it shows |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | `review-overview.png` | [<img src="review-overview.png" alt="overview" width="180">](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor |
|
||||
| 2 | `review-character.png` | [<img src="review-character.png" alt="character" width="180">](review-character.png) | Review | eye height, south | MPFB mannequin, idle pose + warm skin; feet meet the plane |
|
||||
| 3 | `review-scale-cubes.png` | [<img src="review-scale-cubes.png" alt="scale cubes" width="180">](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow |
|
||||
| 4 | `review-player-placement.png` | [<img src="review-player-placement.png" alt="player placement" width="180">](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing |
|
||||
| 5 | `review-npc-prefab.png` | [<img src="review-npc-prefab.png" alt="npc prefab" width="180">](review-npc-prefab.png) | Review | mid, west | The `AGigLineNPCPerson` prefab: collision capsule + visible MPFB body, feet on Z 0 |
|
||||
| 6 | `review-ground-contact.png` | [<img src="review-ground-contact.png" alt="ground contact" width="180">](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here |
|
||||
| 7 | `review-shadow-compare.png` | [<img src="review-shadow-compare.png" alt="shadow compare" width="180">](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows |
|
||||
| 8 | `review-hero-portals.png` | [<img src="review-hero-portals.png" alt="hero portals" width="180">](review-hero-portals.png) | Review | 8 m, eye height | Three genuine 1.2 m portal recesses with flights and plinth |
|
||||
| 9 | `review-hero-facade-front.png` | [<img src="review-hero-facade-front.png" alt="hero facade front" width="180">](review-hero-facade-front.png) | Review | road side, straight-on | Hero façade elevation and column rhythm |
|
||||
| 10 | `review-hero-facade-oblique.png` | [<img src="review-hero-facade-oblique.png" alt="hero facade oblique" width="180">](review-hero-facade-oblique.png) | Review | north-west | Body depth and column line standing proud of the mass |
|
||||
| 11 | `demo/review-demo-spawn-view.png` | [<img src="demo/review-demo-spawn-view.png" alt="spawn view" width="180">](demo/review-demo-spawn-view.png) | Demo | north sidewalk | The whole block: hero façade between its neighbours, both sidewalks and the road |
|
||||
| 12 | `demo/review-demo-street-level.png` | [<img src="demo/review-demo-street-level.png" alt="street level" width="180">](demo/review-demo-street-level.png) | Demo | eye height, north side | The hero colonnade with the adjacent frontage buildings either side |
|
||||
| 13 | `demo/review-demo-crossing.png` | [<img src="demo/review-demo-crossing.png" alt="crossing" width="180">](demo/review-demo-crossing.png) | Demo | along the crosswalk | The designated mid-block crossing at s=130 m (UE X -14956): three zebra bars on the asphalt, with the south frontage and the hero colonnade behind |
|
||||
| 14 | `demo/review-demo-north-buildings.png` | [<img src="demo/review-demo-north-buildings.png" alt="north buildings" width="180">](demo/review-demo-north-buildings.png) | Demo | across the Blvd | North-side buildings (TCL, Tussaud's) seen from the south side across the road. **2026-11-08:** camera re-pointed to the buildings' mass centre (was 9.5 km west → 85% sky); now frames both north buildings. Flat surfaces = `0.1.4` façade scope. |
|
||||
| # | File | Preview | Map | View | What it shows | ☑ |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | `review-overview.png` | [<img src="review-overview.png" alt="overview" width="180">](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor | ☑ 2026-11-09 |
|
||||
| 2 | `review-character.png` | [<img src="review-character.png" alt="character" width="180">](review-character.png) | Review | eye height, south | MPFB mannequin, idle pose + warm skin; feet meet the plane | ☑ 2026-11-09 |
|
||||
| 3 | `review-scale-cubes.png` | [<img src="review-scale-cubes.png" alt="scale cubes" width="180">](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow | ☑ 2026-11-09 |
|
||||
| 4 | `review-player-placement.png` | [<img src="review-player-placement.png" alt="player placement" width="180">](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing | ☑ 2026-11-09 |
|
||||
| 5 | `review-npc-prefab.png` | [<img src="review-npc-prefab.png" alt="npc prefab" width="180">](review-npc-prefab.png) | Review | mid, west | The `AGigLineNPCPerson` prefab: collision capsule + visible MPFB body, feet on Z 0 | ☑ 2026-11-09 |
|
||||
| 6 | `review-ground-contact.png` | [<img src="review-ground-contact.png" alt="ground contact" width="180">](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here | ☑ 2026-11-09 |
|
||||
| 7 | `review-shadow-compare.png` | [<img src="review-shadow-compare.png" alt="shadow compare" width="180">](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows | ☑ 2026-11-09 |
|
||||
| 8 | `review-hero-portals.png` | [<img src="review-hero-portals.png" alt="hero portals" width="180">](review-hero-portals.png) | Review | 8 m, eye height | Three genuine 1.2 m portal recesses with flights and plinth | ☑ 2026-11-09 |
|
||||
| 9 | `review-hero-facade-front.png` | [<img src="review-hero-facade-front.png" alt="hero facade front" width="180">](review-hero-facade-front.png) | Review | road side, straight-on | Hero façade elevation and column rhythm | ☑ 2026-11-09 |
|
||||
| 10 | `review-hero-facade-oblique.png` | [<img src="review-hero-facade-oblique.png" alt="hero facade oblique" width="180">](review-hero-facade-oblique.png) | Review | north-west | Body depth and column line standing proud of the mass | ☑ 2026-11-09 |
|
||||
| 11 | `demo/review-demo-spawn-view.png` | [<img src="demo/review-demo-spawn-view.png" alt="spawn view" width="180">](demo/review-demo-spawn-view.png) | Demo | north sidewalk | The whole block: hero façade between its neighbours, both sidewalks and the road | ☑ 2026-11-09 |
|
||||
| 12 | `demo/review-demo-street-level.png` | [<img src="demo/review-demo-street-level.png" alt="street level" width="180">](demo/review-demo-street-level.png) | Demo | eye height, north side | The hero colonnade with the adjacent frontage buildings either side | ☑ 2026-11-09 |
|
||||
| 13 | `demo/review-demo-crossing.png` | [<img src="demo/review-demo-crossing.png" alt="crossing" width="180">](demo/review-demo-crossing.png) | Demo | along the crosswalk | The designated mid-block crossing at s=130 m (UE X -14956): three zebra bars on the asphalt, with the south frontage and the hero colonnade behind | ☑ 2026-11-09 |
|
||||
| 14 | `demo/review-demo-north-buildings.png` | [<img src="demo/review-demo-north-buildings.png" alt="north buildings" width="180">](demo/review-demo-north-buildings.png) | Demo | across the Blvd | North-side buildings (TCL, Tussaud's) seen from the south side across the road. **2026-11-08:** camera re-pointed to the buildings' mass centre (was 9.5 km west → 85% sky); now frames both north buildings. Flat surfaces = `0.1.4` façade scope. | ☑ 2026-11-09 |
|
||||
|
||||
## Scene conventions
|
||||
|
||||
@@ -48,10 +52,12 @@ All shots ☑ approved 2026-11-07, including the block frontage pass that added
|
||||
- **Ground contact**: feet meet the plane with no gap and no shadow seam.
|
||||
- **Scale**: everything checks against the 1 m cubes.
|
||||
- **Façade rhythm**: six columns at 4.96 m across 25.75 m should read as the photographs in silhouette.
|
||||
- **Frontage heights (new in this round)**: 6922 and the El Capitan Theatre were re-cut to the 3.1 m storey height. Check they still read as the taller west end and the lower cinema respectively against `Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg`, and that the ordering of the six frontage buildings is unchanged.
|
||||
|
||||
## Known cosmetic issues
|
||||
|
||||
- Floor speckle from the `Constant3Vector` wiring in `Tools/ue_build_review_map.py`.
|
||||
- Hero façade renders pale — Blender flat colours with no normal/roughness, so it reads as untextured stone (the `0.1.4` façade patch, not a lighting bug).
|
||||
- Banners and window panels float detached from the colonnade wall (next modelling pass).
|
||||
- Banners sit on the column line rather than the wall (acceptable: they hang on the columns in the photographs).
|
||||
- ~~Banners and window panels float detached from the colonnade wall~~ — **window panes fixed 2026-11-09**: they sat at Y −1.98, 1.44 m in _front_ of the wall and ahead of the column line; they now sit on the wall plane at Y −0.56, behind the columns, and read as dark openings (see `review-hero-facade-front`).
|
||||
- Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement.
|
||||
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,18 +9,6 @@ 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.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
|
||||
|
||||
- [ ] Correct building scale and street proportions flagged against references.
|
||||
- [ ] 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.
|
||||
- [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.1.3 — Packaging and setup fixes (patch)
|
||||
|
||||
Suggested blender skills: **export-pipeline** and **unreal-export** (scale/pivot/normals/materials/collision verification) if any packaging defect traces back to the Blender export presets.
|
||||
|
||||
+26
-1
@@ -16,6 +16,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
|
||||
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. |
|
||||
| `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
|
||||
| `scale_test_export.py` / `ue_scale_test_import.py` | The 1 m = 100 uu scale test; the named presets `ue_metric_v1` live in these two files |
|
||||
|
||||
@@ -57,4 +58,28 @@ 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), a ground-referenced wall probe (0/35) and a **facade occlusion probe** that fires sideways from the road centre and requires a blocking wall wherever a building is staged (0/9), 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`. |
|
||||
|
||||
## Reference and concept generation
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `facade_detail_concepts.py` | Generates **frontal building plates** (whole elevation, entrance in its facade, or building with neighbours) from a local reference photograph with an image model (OpenRouter); close-up detail crops are deliberately not requested because they cannot be measured for proportion. `--list` is free and shows which studies have their reference present; `--all`/`--only <name>` generates. Never overwrites (writes `-v2`, `-v3`), writes a `.refgen.json` sidecar per image (model, prompt, source photo, SHA-256), and puts everything under `Art/reference/facade-details/` — gitignored, local-only, never shipped. Costs money per image, so it is never automatic. |
|
||||
| `judge_screenshot.py` | (see Visual review) |
|
||||
|
||||
## 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.
|
||||
@@ -19,6 +19,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
# blockout object name -> (asset name, note). Addresses from the verification doc.
|
||||
BUILDINGS = {
|
||||
@@ -69,20 +72,7 @@ def main():
|
||||
bpy.context.view_layer.objects.active = new_obj
|
||||
|
||||
path = os.path.join(args.out, asset_name + ".fbx")
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=True,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_static(path, selection=True)
|
||||
world = new_obj.matrix_world
|
||||
zs = [(world @ v.co).z for v in new_mesh.vertices]
|
||||
ys = [(world @ v.co).y for v in new_mesh.vertices]
|
||||
|
||||
@@ -13,6 +13,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's")
|
||||
|
||||
@@ -50,20 +53,7 @@ def main():
|
||||
bpy.context.view_layer.objects.active = new_obj
|
||||
|
||||
path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx")
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=True,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_static(path, selection=True)
|
||||
world = new_obj.matrix_world
|
||||
zs = [(world @ v.co).z for v in new_mesh.vertices]
|
||||
print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m "
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate frontal building plates from the local reference photography.
|
||||
|
||||
Each study takes one photograph we already hold (`Art/reference/`, gitignored) as
|
||||
the geometric anchor and asks an image model to re-stage it as a clean **frontal
|
||||
view of the building** — the whole elevation, the entrance in its facade, or the
|
||||
building with its neighbours — evenly lit, no crowd, no staging, so the modelling
|
||||
pass has something measurable instead of a tourist photo.
|
||||
|
||||
Deliberately **not** close-up detail crops: a doorway or cornice crop cannot be
|
||||
measured for proportion, which is what the modelling pass actually needs.
|
||||
|
||||
python3 Tools/facade_detail_concepts.py --list
|
||||
python3 Tools/facade_detail_concepts.py --only door-recess,parapet-motto
|
||||
python3 Tools/facade_detail_concepts.py --all
|
||||
python3 Tools/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image
|
||||
|
||||
Output: `Art/reference/facade-details/<study>.png` plus a `<study>.refgen.json`
|
||||
sidecar recording the model, the source photograph, the prompt and the SHA-256 of
|
||||
the result. `Art/reference/` is gitignored, so **neither the images nor the
|
||||
sidecars are committed** — they are local working material. The study list and
|
||||
the tool are the committed part.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* Standard library only: the OpenRouter chat-completions call is `urllib`, and
|
||||
the returned `data:` URL is decoded with `base64` and written straight to disk.
|
||||
* An existing output is never overwritten; `-v2`, `-v3` … are used instead, so
|
||||
re-running after a prompt edit keeps both studies comparable.
|
||||
* Generation costs money, so it is never automatic: `--list` is free, and every
|
||||
generating run prints the model and the study count first.
|
||||
* The token is read from `OPENROUTER_API_KEY` or the ignored `.env` and is never
|
||||
written to a sidecar or a log line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
REFERENCE_DIR = os.path.join(ROOT, "Art", "reference")
|
||||
OUT_DIR = os.path.join(REFERENCE_DIR, "facade-details")
|
||||
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
DEFAULT_MODEL = "google/gemini-2.5-flash-image"
|
||||
|
||||
# The instruction appended to every study. Kept identical to the sibling
|
||||
# project's convention so the two sets stay comparable: the photograph fixes the
|
||||
# geometry, the prompt only restages light, weather, people and surface.
|
||||
ANCHOR_SUFFIX = (
|
||||
"Use the attached reference photograph as the source of the architecture: keep "
|
||||
"the building's proportions, storey count, column rhythm, band heights and "
|
||||
"signage placement exactly as photographed. You may REFRAME — step back across "
|
||||
"the street, zoom out or widen the field of view — so that the whole building "
|
||||
"fits in the frame from the pavement to the parapet; the reference is often "
|
||||
"cropped and the plate must not be. Keep the view exterior and frontal (or "
|
||||
"three-quarter where asked). Restage only lighting, weather, people and surface "
|
||||
"condition. Do not invent extra storeys, wings or ornaments, and do not change "
|
||||
"the architecture to fit the frame."
|
||||
)
|
||||
|
||||
# name -> (reference photo relative to Art/reference/, what the plate must show)
|
||||
#
|
||||
# Verified framing (vision pre-check, 2026-11-09): the model follows the reference
|
||||
# crop closely, so a plate is only as wide as its source photo. `building-entrance`
|
||||
# and `building-street-front` come back as full-elevation frontal views; the studies
|
||||
# anchored on the tightly cropped 2009 facade photo stay cropped, because the model
|
||||
# will not invent the parapet and sky that the photograph does not contain. A full
|
||||
# building plate for 6840 needs a full-elevation photograph, which the local set
|
||||
# does not have (see Docs/reference-notes-hollywood.md).
|
||||
#
|
||||
# Every study asks for a *frontal view of a building*, not a crop of one detail:
|
||||
# the whole elevation, the entrance in its facade, or the building together with
|
||||
# its neighbours. Close-up framing is deliberately absent — a detail crop cannot
|
||||
# be measured for proportion, which is what the modelling pass needs.
|
||||
STUDIES: dict[str, tuple[str, str]] = {
|
||||
"building-front": (
|
||||
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
|
||||
"A frontal view of the complete building shown in the reference "
|
||||
"photograph: the whole elevation from the pavement to the parapet fits in "
|
||||
"the frame with clear sky above the roofline and pavement below the ground "
|
||||
"floor, shot straight on and centred. Nothing may be cut off at the top or "
|
||||
"bottom. Even daylight, no crowd, no vehicles.",
|
||||
),
|
||||
"building-entrance": (
|
||||
"el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg",
|
||||
"A frontal view of the building in the reference photograph, framed so its "
|
||||
"street entrance is clearly visible as part of the facade — the doors and "
|
||||
"the wall around them in proportion, not a close-up of the doorway. "
|
||||
"Include the pavement in front. Even daylight, no crowd.",
|
||||
),
|
||||
"building-with-neighbours": (
|
||||
"hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg",
|
||||
"A straight-on view of the street wall in the reference photograph: the "
|
||||
"building and its neighbouring buildings either side, each at full height "
|
||||
"from pavement to parapet with sky above, the joins between them visible, "
|
||||
"nothing cut off. Shot from across the road, parallel to the frontage. "
|
||||
"Even daylight, no crowd.",
|
||||
),
|
||||
"building-street-front": (
|
||||
"hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg",
|
||||
"A frontal view along the pavement of the building in the reference "
|
||||
"photograph: the building's frontage seen from street level with its "
|
||||
"neighbours continuing either side and the pavement in the foreground. "
|
||||
"Straight on, even daylight, no crowd.",
|
||||
),
|
||||
"building-front-oblique": (
|
||||
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
|
||||
"A three-quarter view of the complete building in the reference "
|
||||
"photograph: the frontal elevation and one side visible together so the "
|
||||
"building's depth and mass read, with its neighbouring building at one "
|
||||
"edge. The whole building stays in frame. Even daylight, no crowd.",
|
||||
),
|
||||
"theatre-front-with-neighbours": (
|
||||
"el-capitan-theater-detail/01-el-capitan-marquee.jpg",
|
||||
"A frontal view of the theatre in the reference photograph together with "
|
||||
"the buildings either side of it, the whole frontage and its full height "
|
||||
"in frame, shot from across the road. Even daylight, no crowd, marquee "
|
||||
"lettering blocked out as unreadable texture.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
token = os.environ.get("OPENROUTER_API_KEY")
|
||||
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("OPENROUTER_API_KEY="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
raise SystemExit("no OpenRouter key: set OPENROUTER_API_KEY or add it to .env")
|
||||
|
||||
|
||||
def build_prompt(study: str) -> str:
|
||||
"""The full prompt for a study: its request plus the fixed anchor suffix."""
|
||||
if study not in STUDIES:
|
||||
raise KeyError(f"unknown study {study!r}; known: {sorted(STUDIES)}")
|
||||
return f"{STUDIES[study][1]}\n\n{ANCHOR_SUFFIX}"
|
||||
|
||||
|
||||
def reference_path(study: str) -> str:
|
||||
return os.path.join(REFERENCE_DIR, STUDIES[study][0])
|
||||
|
||||
|
||||
def missing_references() -> list[str]:
|
||||
"""Studies whose source photograph is not present locally."""
|
||||
return [name for name in STUDIES if not os.path.isfile(reference_path(name))]
|
||||
|
||||
|
||||
def next_free_path(study: str, directory: str = OUT_DIR) -> str:
|
||||
"""`<study>.png`, or `<study>-v2.png`, `-v3` … if the name is taken."""
|
||||
base = os.path.join(directory, f"{study}.png")
|
||||
if not os.path.exists(base):
|
||||
return base
|
||||
index = 2
|
||||
while True:
|
||||
candidate = os.path.join(directory, f"{study}-v{index}.png")
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def data_url(path: str) -> str:
|
||||
suffix = os.path.splitext(path)[1].lower()
|
||||
mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(suffix)
|
||||
if not mime:
|
||||
raise SystemExit(f"unsupported reference type: {path}")
|
||||
with open(path, "rb") as handle:
|
||||
return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}"
|
||||
|
||||
|
||||
def decode_image(payload: dict) -> bytes:
|
||||
"""Extract the returned image bytes from a chat-completions payload."""
|
||||
message = (payload.get("choices") or [{}])[0].get("message", {})
|
||||
urls = [item.get("image_url", {}).get("url", "")
|
||||
for item in (message.get("images") or []) if isinstance(item, dict)]
|
||||
if not urls:
|
||||
match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+",
|
||||
str(message.get("content") or ""))
|
||||
if match:
|
||||
urls.append(match.group(0))
|
||||
if not urls or not urls[0]:
|
||||
raise SystemExit(f"model returned no image data: {json.dumps(payload)[:400]}")
|
||||
url = urls[0]
|
||||
if url.startswith("data:"):
|
||||
return base64.b64decode(url.split(",", 1)[1])
|
||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def generate(study: str, token: str, model: str, force: bool = False) -> str:
|
||||
"""Generate one study; returns the written path. Never overwrites by default."""
|
||||
path = next_free_path(study)
|
||||
if force and os.path.exists(os.path.join(OUT_DIR, f"{study}.png")):
|
||||
path = os.path.join(OUT_DIR, f"{study}.png")
|
||||
# Write to a temp name then replace, so a failed call cannot destroy the
|
||||
# previous result.
|
||||
target = path + ".new"
|
||||
else:
|
||||
target = path
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": build_prompt(study)},
|
||||
{"type": "image_url", "image_url": {"url": data_url(reference_path(study))}},
|
||||
],
|
||||
}],
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
API_URL,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/gig-line",
|
||||
"X-Title": "gig-line facade details",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
payload = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
payload = json.load(response)
|
||||
break
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 429 or exc.code >= 500:
|
||||
wait = 5 * (attempt + 1)
|
||||
sys.stderr.write(f" HTTP {exc.code}; retrying in {wait}s\n")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||
raise SystemExit(f"{study}: HTTP {exc.code} — {detail}")
|
||||
except urllib.error.URLError as exc:
|
||||
sys.stderr.write(f" network error ({exc.reason}); retrying\n")
|
||||
time.sleep(5 * (attempt + 1))
|
||||
if payload is None:
|
||||
raise SystemExit(f"{study}: no response after retries")
|
||||
|
||||
image = decode_image(payload)
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(image)
|
||||
if target != path:
|
||||
os.replace(target, path)
|
||||
|
||||
import hashlib
|
||||
sidecar = {
|
||||
"study": study,
|
||||
"generated": os.path.basename(path),
|
||||
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"model": model,
|
||||
"prompt": build_prompt(study),
|
||||
"reference": {
|
||||
"file": STUDIES[study][0],
|
||||
"note": "local reference photography; Art/reference is gitignored and never shipped",
|
||||
},
|
||||
"sha256": hashlib.sha256(image).hexdigest(),
|
||||
"bytes": len(image),
|
||||
"redistribution": "local development reference only; not committed, not shipped",
|
||||
}
|
||||
sidecar_path = os.path.splitext(path)[0] + ".refgen.json"
|
||||
with open(sidecar_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(sidecar, handle, indent=2)
|
||||
if token in json.dumps(sidecar):
|
||||
raise SystemExit("refusing to write a sidecar containing the token")
|
||||
return path
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--list", action="store_true", help="list studies and exit (free)")
|
||||
ap.add_argument("--all", action="store_true", help="generate every study")
|
||||
ap.add_argument("--only", help="comma-separated study names")
|
||||
ap.add_argument("--model", default=DEFAULT_MODEL)
|
||||
ap.add_argument("--force", action="store_true",
|
||||
help="replace the existing <study>.png instead of writing a -vN")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.list or not (args.all or args.only):
|
||||
missing = set(missing_references())
|
||||
print(f"{len(STUDIES)} studies; model {args.model}")
|
||||
for name, (ref, _prompt) in STUDIES.items():
|
||||
state = "MISSING reference" if name in missing else "ready"
|
||||
print(f" {name:22} {state:18} <- {ref}")
|
||||
if missing:
|
||||
print(f"\n{len(missing)} study(ies) cannot run without their reference photograph.")
|
||||
return 0
|
||||
|
||||
names = list(STUDIES) if args.all else [s.strip() for s in args.only.split(",") if s.strip()]
|
||||
unknown = [n for n in names if n not in STUDIES]
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown study(ies): {unknown}; known: {sorted(STUDIES)}")
|
||||
blocked = [n for n in names if n in set(missing_references())]
|
||||
if blocked:
|
||||
raise SystemExit(f"missing reference photograph for: {blocked}")
|
||||
token = load_token()
|
||||
|
||||
print(f"generating {len(names)} study(ies) with {args.model} -> {OUT_DIR}")
|
||||
for index, name in enumerate(names, 1):
|
||||
print(f"[{index}/{len(names)}] {name}")
|
||||
try:
|
||||
path = generate(name, token, args.model, force=args.force)
|
||||
except SystemExit as exc:
|
||||
sys.stderr.write(f" FAILED: {exc}\n")
|
||||
continue
|
||||
print(f" wrote {os.path.relpath(path, ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The one FBX export preset for this project ("ue_metric_v1"/"ue_character_v1").
|
||||
|
||||
Before this module the same `bpy.ops.export_scene.fbx(...)` argument block was
|
||||
copied into six scripts (hero, frontage, north buildings, site, scale test, MPFB
|
||||
character). They had already drifted only by accident, and every one of them
|
||||
carried `mesh_smooth_type="OFF"` — which is why Unreal logged "No smoothing group
|
||||
information was found for this mesh" on every import and round parts rendered
|
||||
faceted. One preset, one place to fix that.
|
||||
|
||||
Use from inside Blender:
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "Tools"))
|
||||
import fbx_export_preset as preset
|
||||
|
||||
preset.export_static(path) # whole scene, MESH only
|
||||
preset.export_static(path, selection=True) # selected objects only
|
||||
preset.export_character(path) # armature + mesh, character flags
|
||||
|
||||
`preset_kwargs()` is pure (no `bpy`), so the argument set can be unit-tested
|
||||
without Blender — see Tools/tests/test_fbx_export_preset.py.
|
||||
|
||||
Why these values:
|
||||
* `FBX_SCALE_NONE` + `apply_unit_scale` keeps Blender metres → Unreal centimetres
|
||||
at exactly ×100, verified by the three-cube scale test (1 m → 100.00 uu).
|
||||
* `axis_forward="-Y"`, `axis_up="Z"` is the project's recorded mapping: east → +X,
|
||||
north → −Y, up → +Z.
|
||||
* `mesh_smooth_type="FACE"` exports smoothing groups. `"OFF"` (the old value)
|
||||
drops them, so Unreal rebuilds flat normals and every cylinder is faceted.
|
||||
* `use_mesh_modifiers=True` bakes the bevel/modifier stack at export; the source
|
||||
`.blend` stays non-destructive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Shared by every mesh export. Values are the recorded ue_metric_v1 preset.
|
||||
_METRIC = {
|
||||
"object_types": {"MESH"},
|
||||
"apply_unit_scale": True,
|
||||
"apply_scale_options": "FBX_SCALE_NONE",
|
||||
"axis_forward": "-Y",
|
||||
"axis_up": "Z",
|
||||
"use_space_transform": True,
|
||||
"use_mesh_modifiers": True,
|
||||
"mesh_smooth_type": "FACE",
|
||||
"path_mode": "AUTO",
|
||||
"bake_space_transform": False,
|
||||
}
|
||||
|
||||
# Character exports differ only in the object set and the armature/path flags.
|
||||
_CHARACTER = dict(
|
||||
_METRIC,
|
||||
object_types={"ARMATURE", "MESH"},
|
||||
add_leaf_bones=False,
|
||||
path_mode="COPY",
|
||||
embed_textures=False,
|
||||
)
|
||||
|
||||
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
|
||||
|
||||
|
||||
def preset_kwargs(preset: str = "ue_metric_v1", selection: bool = False, **overrides) -> dict:
|
||||
"""Return the `bpy.ops.export_scene.fbx` argument dict (no `bpy` needed).
|
||||
|
||||
`preset` selects the base set, `selection` decides whether only selected
|
||||
objects are exported, and `overrides` wins last for the rare special case.
|
||||
"""
|
||||
if preset not in PRESETS:
|
||||
raise KeyError(f"unknown preset {preset!r}; known: {sorted(PRESETS)}")
|
||||
kwargs = dict(PRESETS[preset])
|
||||
kwargs["use_selection"] = bool(selection)
|
||||
kwargs.update(overrides)
|
||||
if "filepath" in kwargs:
|
||||
raise ValueError("pass the path to export_static/export_character, not as an override")
|
||||
return kwargs
|
||||
|
||||
|
||||
def _export(path: str, preset: str, selection: bool, overrides: dict) -> str:
|
||||
import bpy # imported here so `preset_kwargs` stays usable without Blender
|
||||
|
||||
kwargs = preset_kwargs(preset, selection=selection, **overrides)
|
||||
bpy.ops.export_scene.fbx(filepath=path, **kwargs)
|
||||
return path
|
||||
|
||||
|
||||
def export_static(path: str, selection: bool = False, **overrides) -> str:
|
||||
"""Export meshes with the ue_metric_v1 preset. Returns the path."""
|
||||
return _export(path, "ue_metric_v1", selection, overrides)
|
||||
|
||||
|
||||
def export_character(path: str, selection: bool = False, **overrides) -> str:
|
||||
"""Export armature + mesh with the ue_character_v1 preset. Returns the path."""
|
||||
return _export(path, "ue_character_v1", selection, overrides)
|
||||
+91
-29
@@ -33,6 +33,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
# --- measured / derived ------------------------------------------------------
|
||||
FRONTAGE_W = 25.75
|
||||
@@ -50,8 +53,12 @@ FRONTAGE_Y = 0.0 # front plane sits at the building line of the block fra
|
||||
PEDESTAL_H = 1.45 # column pedestal top; entrance platform sits on it
|
||||
COLUMN_D = 0.95 # Ionic shaft diameter at the base
|
||||
CAPITAL_H = 0.95
|
||||
ENTABLATURE_H = 3.40 # band between the capitals and the motto panel
|
||||
PARAPET_H = 2.20 # motto panel and cornice above the entablature
|
||||
ENTABLATURE_H = 3.00 # band between the capitals and the motto panel
|
||||
# 3.40 -> 3.00 on 2026-11-09: the photographs put the
|
||||
# colonnade at 50-60 % of the 14.5 m elevation, and our
|
||||
# 3.40/2.20 entablature+parapet left it at 47.6 %. Thinning
|
||||
# both lifts the colonnade to 7.70 m = 53.1 %, inside the band.
|
||||
PARAPET_H = 1.80 # motto panel and cornice above the entablature (see above)
|
||||
PORTAL_W = 3.10 # each of the three door openings
|
||||
PORTAL_H = 3.40
|
||||
STEP_TREAD = 0.34
|
||||
@@ -70,7 +77,7 @@ def cube(name, x, y, z, dx, dy, dz, material=None):
|
||||
return obj
|
||||
|
||||
|
||||
def cylinder(name, x, y, z0, z1, radius, material, vertices=24):
|
||||
def cylinder(name, x, y, z0, z1, radius, material, vertices=24, smooth=True):
|
||||
bpy.ops.mesh.primitive_cylinder_add(
|
||||
vertices=vertices,
|
||||
radius=radius,
|
||||
@@ -81,6 +88,30 @@ def cylinder(name, x, y, z0, z1, radius, material, vertices=24):
|
||||
obj.name = name
|
||||
if material:
|
||||
obj.data.materials.append(material)
|
||||
if smooth:
|
||||
# Round parts must shade smoothly *around* the axis while keeping the
|
||||
# caps creased. Without this the columns read as faceted prisms, and the
|
||||
# FBX carries no smoothing groups at all (Unreal warns about it).
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
|
||||
return obj
|
||||
|
||||
|
||||
def disc_on_wall(name, x, y, z, radius, thickness, material=None, vertices=24):
|
||||
"""A round disc mounted vertically on a wall face.
|
||||
|
||||
`cylinder()` builds along Z, so a "medallion" made with it is a pancake lying
|
||||
flat on top of the parapet rather than a disc on its face — which is what the
|
||||
first build did (1.1 m across in Y, 0.02 m thick in Z, sitting at the coping).
|
||||
This rotates the cylinder 90 degrees about X so the axis points along Y and
|
||||
the disc faces the street.
|
||||
"""
|
||||
obj = cylinder(name, x, y, z - thickness / 2.0, z + thickness / 2.0,
|
||||
radius, material, vertices=vertices)
|
||||
obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -132,14 +163,32 @@ def build():
|
||||
col_xs.append(x)
|
||||
cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27,
|
||||
1.24, 1.24, 0.54, stone)
|
||||
cylinder(f"Hero_Column_{i + 1}", x, -0.55, PEDESTAL_H + 0.55,
|
||||
PEDESTAL_H + 0.55 + (HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H - 0.55 - CAPITAL_H),
|
||||
# Base moulding: a taller, wider cylinder where the shaft meets the
|
||||
# pedestal. Without it the shaft starts straight off a square plinth,
|
||||
# which is not how an Ionic base reads in silhouette.
|
||||
base_z0 = PEDESTAL_H + 0.55
|
||||
base_h = 0.22
|
||||
cylinder(f"Hero_ColumnBase_{i + 1}", x, -0.55, base_z0, base_z0 + base_h,
|
||||
0.68, stone, vertices=20)
|
||||
shaft_z0 = base_z0 + base_h
|
||||
cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H
|
||||
cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0,
|
||||
COLUMN_D / 2.0, stone)
|
||||
cylinder(f"Hero_Capital_{i + 1}", x, -0.55,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H, 0.62, stone, vertices=16)
|
||||
# Capital block, then the square abacus that carries the architrave.
|
||||
# The abacus takes the top 0.14 m of the capital zone so the colonnade
|
||||
# still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged).
|
||||
abacus_h = 0.14
|
||||
cylinder(f"Hero_Capital_{i + 1}", x, -0.55, cap_z0,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h, 0.62, stone, vertices=16)
|
||||
cube(f"Hero_Abacus_{i + 1}", x, -0.55,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h / 2.0,
|
||||
1.30, 1.30, abacus_h, stone)
|
||||
note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H),
|
||||
f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)")
|
||||
note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22),
|
||||
"assumption: Ionic base moulding; photographs show a wider base at the shaft foot")
|
||||
note("Hero_Abacus_x6", (1.30, 1.30, 0.14),
|
||||
"assumption: square abacus under the architrave, as Ionic capitals carry")
|
||||
|
||||
# --- entablature and parapet ------------------------------------------
|
||||
ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H
|
||||
@@ -147,9 +196,25 @@ def build():
|
||||
# elevation reads as layered stone rather than one flat plane.
|
||||
cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone)
|
||||
note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands")
|
||||
# Cornice under the parapet.
|
||||
cube("Hero_Cornice", 0.0, -1.6, HEIGHT - PARAPET_H - 0.18, FRONTAGE_W, 1.75, 0.36, stone)
|
||||
# Cornice under the parapet: a projecting corona carried on a dentil course,
|
||||
# so the top of the order reads as layered stone with a shadow line rather
|
||||
# than one flat box. Dentils hang under the corona and stand 0.25 m clear of
|
||||
# it, which is what gives the elevation its cornice shadow at street level.
|
||||
cornice_z = HEIGHT - PARAPET_H - 0.18
|
||||
cube("Hero_Cornice", 0.0, -1.6, cornice_z, FRONTAGE_W, 1.75, 0.36, stone)
|
||||
note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line")
|
||||
DENTIL_W, DENTIL_D, DENTIL_H, DENTIL_GAP = 0.18, 0.30, 0.22, 0.27
|
||||
dentil_y = -2.30
|
||||
dentil_z = cornice_z - 0.18 - DENTIL_H / 2.0
|
||||
dentil_span = FRONTAGE_W - 2.0
|
||||
dentil_count = int(dentil_span / (DENTIL_W + DENTIL_GAP))
|
||||
for i in range(dentil_count):
|
||||
dx = -dentil_span / 2.0 + (DENTIL_W + DENTIL_GAP) * i + DENTIL_W / 2.0
|
||||
cube(f"Hero_Dentil_{i + 1}", dx, dentil_y, dentil_z,
|
||||
DENTIL_W, DENTIL_D, DENTIL_H, stone)
|
||||
note("Hero_Dentil_xN", (DENTIL_W, DENTIL_D, DENTIL_H),
|
||||
f"{dentil_count} dentils at {DENTIL_W + DENTIL_GAP:.2f} m centres under the corona; "
|
||||
"assumption: photographed cornice reads as a dentilled course")
|
||||
cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0,
|
||||
FRONTAGE_W, 1.5, ENTABLATURE_H, stone)
|
||||
note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth")
|
||||
@@ -163,11 +228,13 @@ def build():
|
||||
FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark)
|
||||
note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade")
|
||||
|
||||
# Medallions either side of the inscription.
|
||||
# Medallions either side of the inscription: discs on the parapet face, not
|
||||
# pancakes on the coping (see disc_on_wall).
|
||||
for i, x in enumerate((-6.2, 6.2), start=1):
|
||||
cylinder(f"Hero_Medallion_{i}", x, -1.36, HEIGHT - PARAPET_H / 2.0 + 0.42,
|
||||
HEIGHT - PARAPET_H / 2.0 + 0.44, 0.55, stone_dark, vertices=20)
|
||||
note("Hero_Medallion_x2", (1.1, 1.1, 0.02), "photographs: two stone discs beside the motto")
|
||||
disc_on_wall(f"Hero_Medallion_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
|
||||
0.55, 0.04, stone_dark, vertices=20)
|
||||
note("Hero_Medallion_x2", (1.1, 0.04, 1.1),
|
||||
"photographs: two stone discs beside the motto, mounted on the parapet face")
|
||||
|
||||
# --- entrances ---------------------------------------------------------
|
||||
# Three portals between column pairs, each with its own short flight. The
|
||||
@@ -241,32 +308,27 @@ def build():
|
||||
|
||||
# --- windows -----------------------------------------------------------
|
||||
# Two ranks of windows between the columns and behind the colonnade.
|
||||
# Y must sit on the wall plane: the mass front face is at Y -0.50 and the
|
||||
# columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of
|
||||
# the wall and ahead of the column line, so the glazing floated in the
|
||||
# colonnade instead of reading as openings behind it (visible in the
|
||||
# review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall,
|
||||
# safely behind the columns.
|
||||
WINDOW_Y = -0.56
|
||||
for rank, z in enumerate((4.4, 8.2), start=1):
|
||||
for i, x in enumerate(col_xs[:-1], start=1):
|
||||
wx = x + COLUMN_BAY / 2.0
|
||||
cube(f"Hero_Window_{rank}_{i}", wx, -1.98, z, 2.0, 0.08, 1.9, glass)
|
||||
cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
|
||||
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
|
||||
|
||||
return parts, lines
|
||||
|
||||
|
||||
def export_fbx(path):
|
||||
# Preset lives in Tools/fbx_export_preset.py (ue_metric_v1).
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py.
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=False,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_static(path)
|
||||
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import sys
|
||||
import zlib
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
import numpy
|
||||
|
||||
from mathutils import Euler
|
||||
@@ -149,22 +152,7 @@ def make_diffuse(human, size=1024):
|
||||
def export_fbx(human, path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_character_v1" — change only via a new preset name.
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=False,
|
||||
object_types={"ARMATURE", "MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
add_leaf_bones=False,
|
||||
path_mode="COPY",
|
||||
embed_textures=False,
|
||||
mesh_smooth_type="OFF",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_character(path)
|
||||
log(f"export: FBX preset ue_character_v1 -> {path}")
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
|
||||
def build_scene():
|
||||
@@ -44,20 +47,7 @@ def build_scene():
|
||||
def export_fbx(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_metric_v1" - change only via a new preset name.
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=False,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
fbx_preset.export_static(path)
|
||||
print(f"[scale_test] exported FBX preset ue_metric_v1 -> {path}")
|
||||
|
||||
|
||||
|
||||
@@ -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,108 @@
|
||||
"""Tests for Tools/facade_detail_concepts.py — the pure parts.
|
||||
|
||||
No network calls: the OpenRouter request and the image bytes are represented as
|
||||
the small structures the API uses. Generation itself is manual and paid.
|
||||
"""
|
||||
|
||||
import base64
|
||||
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, "Tools"))
|
||||
|
||||
import facade_detail_concepts as fc # noqa: E402
|
||||
|
||||
|
||||
class StudyTableTest(unittest.TestCase):
|
||||
def test_every_study_has_a_reference_and_a_prompt(self):
|
||||
self.assertTrue(fc.STUDIES)
|
||||
for name, (ref, prompt) in fc.STUDIES.items():
|
||||
self.assertTrue(ref, f"{name} has no reference")
|
||||
self.assertTrue(prompt.strip(), f"{name} has no prompt")
|
||||
# References are relative to Art/reference and never escape it.
|
||||
self.assertFalse(os.path.isabs(ref), f"{name} reference is absolute")
|
||||
self.assertNotIn("..", ref, f"{name} reference escapes the directory")
|
||||
|
||||
def test_prompt_always_carries_the_anchor_suffix(self):
|
||||
for name in fc.STUDIES:
|
||||
prompt = fc.build_prompt(name)
|
||||
self.assertIn(fc.ANCHOR_SUFFIX, prompt)
|
||||
self.assertIn(fc.STUDIES[name][1], prompt)
|
||||
|
||||
def test_unknown_study_raises(self):
|
||||
with self.assertRaises(KeyError):
|
||||
fc.build_prompt("no-such-study")
|
||||
|
||||
|
||||
class NextFreePathTest(unittest.TestCase):
|
||||
def test_uses_the_plain_name_when_free(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x.png")
|
||||
|
||||
def test_never_overwrites(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
open(os.path.join(tmp, "x.png"), "w").close()
|
||||
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v2.png")
|
||||
open(os.path.join(tmp, "x-v2.png"), "w").close()
|
||||
self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v3.png")
|
||||
|
||||
|
||||
class DecodeImageTest(unittest.TestCase):
|
||||
PAYLOAD_BYTES = b"\x89PNG\r\n\x1a\nfake"
|
||||
|
||||
def _data_url(self):
|
||||
return "data:image/png;base64," + base64.b64encode(self.PAYLOAD_BYTES).decode()
|
||||
|
||||
def test_reads_message_images(self):
|
||||
payload = {"choices": [{"message": {
|
||||
"images": [{"image_url": {"url": self._data_url()}}]}}]}
|
||||
self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES)
|
||||
|
||||
def test_reads_a_data_url_embedded_in_content(self):
|
||||
payload = {"choices": [{"message": {"content": f"here: {self._data_url()}"}}]}
|
||||
self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES)
|
||||
|
||||
def test_refusal_raises_with_the_text(self):
|
||||
payload = {"choices": [{"message": {"content": "I cannot do that."}}]}
|
||||
with self.assertRaises(SystemExit):
|
||||
fc.decode_image(payload)
|
||||
|
||||
|
||||
class DataUrlTest(unittest.TestCase):
|
||||
def test_encodes_a_jpeg(self):
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as handle:
|
||||
handle.write(b"jpegbytes")
|
||||
path = handle.name
|
||||
try:
|
||||
url = fc.data_url(path)
|
||||
self.assertTrue(url.startswith("data:image/jpeg;base64,"))
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_rejects_an_unsupported_type(self):
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as handle:
|
||||
path = handle.name
|
||||
try:
|
||||
with self.assertRaises(SystemExit):
|
||||
fc.data_url(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class SidecarShapeTest(unittest.TestCase):
|
||||
def test_studies_and_outputs_are_local_only(self):
|
||||
# The output directory lives under the gitignored Art/reference tree, so
|
||||
# neither the images nor the sidecars can be committed.
|
||||
self.assertIn(os.path.join("Art", "reference"), fc.OUT_DIR)
|
||||
self.assertIn("gitignored", fc.__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for Tools/fbx_export_preset.py.
|
||||
|
||||
The preset module keeps its argument set in plain dicts and builds it in
|
||||
`preset_kwargs()`, so the export contract can be checked without starting
|
||||
Blender. Only `export_static`/`export_character` import `bpy`.
|
||||
"""
|
||||
|
||||
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, "Tools"))
|
||||
|
||||
import fbx_export_preset as preset # noqa: E402
|
||||
|
||||
|
||||
class MetricPresetTest(unittest.TestCase):
|
||||
def test_scale_and_axis_contract(self):
|
||||
# These three are the project's recorded conversion contract: metres ->
|
||||
# centimetres by a factor of exactly 100, east +X, north -Y, up +Z.
|
||||
kwargs = preset.preset_kwargs()
|
||||
self.assertTrue(kwargs["apply_unit_scale"])
|
||||
self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE")
|
||||
self.assertEqual(kwargs["axis_forward"], "-Y")
|
||||
self.assertEqual(kwargs["axis_up"], "Z")
|
||||
|
||||
def test_smoothing_groups_are_exported(self):
|
||||
# "OFF" (the pre-refactor value) drops smoothing groups and every Unreal
|
||||
# import warns about it, with round parts faceted.
|
||||
self.assertEqual(preset.preset_kwargs()["mesh_smooth_type"], "FACE")
|
||||
|
||||
def test_selection_flag(self):
|
||||
self.assertFalse(preset.preset_kwargs()["use_selection"])
|
||||
self.assertTrue(preset.preset_kwargs(selection=True)["use_selection"])
|
||||
|
||||
def test_object_types_are_meshes_only(self):
|
||||
self.assertEqual(preset.preset_kwargs()["object_types"], {"MESH"})
|
||||
|
||||
|
||||
class CharacterPresetTest(unittest.TestCase):
|
||||
def test_character_exports_armature_and_mesh(self):
|
||||
kwargs = preset.preset_kwargs("ue_character_v1")
|
||||
self.assertEqual(kwargs["object_types"], {"ARMATURE", "MESH"})
|
||||
self.assertFalse(kwargs["add_leaf_bones"])
|
||||
self.assertEqual(kwargs["path_mode"], "COPY")
|
||||
self.assertFalse(kwargs["embed_textures"])
|
||||
|
||||
def test_character_keeps_the_scale_contract(self):
|
||||
kwargs = preset.preset_kwargs("ue_character_v1")
|
||||
self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE")
|
||||
self.assertEqual(kwargs["mesh_smooth_type"], "FACE")
|
||||
|
||||
|
||||
class OverrideTest(unittest.TestCase):
|
||||
def test_overrides_win(self):
|
||||
kwargs = preset.preset_kwargs(mesh_smooth_type="OFF")
|
||||
self.assertEqual(kwargs["mesh_smooth_type"], "OFF")
|
||||
|
||||
def test_unknown_preset_raises(self):
|
||||
with self.assertRaises(KeyError):
|
||||
preset.preset_kwargs("ue_imperial_v9")
|
||||
|
||||
def test_filepath_must_not_be_an_override(self):
|
||||
with self.assertRaises(ValueError):
|
||||
preset.preset_kwargs(filepath="/tmp/x.fbx")
|
||||
|
||||
|
||||
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()
|
||||
@@ -236,7 +236,11 @@ try:
|
||||
# the plinth, which is what "portal detail" means here.
|
||||
("hero-portals", unreal.Vector(hero_x + 100, -2250, 170), unreal.Vector(hero_x + 100, hero_y, 230)),
|
||||
# straight on from the north sidewalk; the building body spans Y -1443..+250
|
||||
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 420), unreal.Vector(hero_x, hero_y + 400, 760)),
|
||||
# Level horizon: camera height == target height, so the elevation's
|
||||
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a
|
||||
# 7.6 m target, which converged the columns — the one thing an
|
||||
# architectural elevation must not do (archviz: "straight verticals").
|
||||
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760)),
|
||||
# oblique from the north-west, reading the colonnade in depth
|
||||
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
|
||||
]
|
||||
@@ -276,12 +280,14 @@ try:
|
||||
demo_shots = [
|
||||
# 14 m back so the 14.5 m elevation fits: at 2 m the camera sees only
|
||||
# the colonnade's underside.
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 420), unreal.Vector(-13336, 1300, 620)),
|
||||
# Level horizon for the same reason: a block elevation is read by its
|
||||
# verticals, and a pitched camera makes them lean.
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)),
|
||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||
# the unlit back face. Y -2600 is on the sidewalk in front of the lit
|
||||
# elevation.
|
||||
("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 400)),
|
||||
("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 170)),
|
||||
# The designated mid-block crossing. Street arc length runs from the
|
||||
# west end (s=0 at X -27966 cm), so s=130.1 m is UE X -14956, not the
|
||||
# -24291 the old camera used (that is the s=36.6 crossing). Camera sits
|
||||
|
||||
@@ -61,6 +61,16 @@ def measure_and_place():
|
||||
if asset is None:
|
||||
log(f"MISSING {DEST}/SM_HeroFacade")
|
||||
return
|
||||
# Collision: complex-as-simple, not auto-generated convex hulls.
|
||||
# The facade is 95 separate parts across 26 m; Unreal's auto hull generation
|
||||
# wraps them in a few coarse hulls that bulged into the street (the QA wall
|
||||
# probe went 0/35 -> 2/35 after trying it). Complex-as-simple makes the real
|
||||
# triangles the simple collision, so line traces and capsule sweeps block on
|
||||
# the actual wall — the same approach used for the terrain, for the same
|
||||
# reason. The flag lives on the asset, so save it explicitly.
|
||||
unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True)
|
||||
saved = unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
log(f"collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} (saved={saved})")
|
||||
box = asset.get_bounding_box()
|
||||
mn, mx = box.min, box.max
|
||||
log(f"SM_HeroFacade bounds cm: size=({mx.x - mn.x:.1f}, {mx.y - mn.y:.1f}, {mx.z - mn.z:.1f}) "
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.28
|
||||
Reference in new issue
Block a user