From abace2ad055038cbbbedcc0c482c43b9761a1c11 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 8 Nov 2026 17:27:27 +0100 Subject: [PATCH] feat(tools): AI detail plates from the reference photography; fix floating windows Adds Tools/facade_detail_concepts.py: generates architectural detail plates from a local reference photograph through OpenRouter's image models. Spec-driven (eight studies: door recess, entrance, column order, entablature/cornice, parapet/motto, window rhythm, street wall, banner), never overwrites (writes -vN), writes a .refgen.json sidecar (model, prompt, source photo, SHA-256) per image, and keeps all output under the gitignored Art/reference/facade-details/ - local only, never shipped. Stdlib only; http 429/5xx back-off; refuses to write a sidecar containing the token. 11 tests cover the study table, prompt assembly, next-free naming, data-URL decoding (both response shapes plus a refusal) and the local-only rule. Measurement pass on the plates: the hero window panes were at Y -1.98, i.e. 1.44 m in FRONT of the wall (Y -0.50) and ahead of the column line, so the glazing floated in the colonnade instead of reading as openings behind it. Moved to Y -0.56; hero rebuilt, re-imported, DemoMap rebuilt, full screenshot set re-rendered. Docs: Tools/README row, Screenshots README known-issues entry now records the fix. --- Art/blender/hero/hero-facade-2026-11-07.blend | 4 +- Game/GigLine/Content/Hollywood/DemoMap.umap | 2 +- .../Hollywood/ElCapitan/SM_HeroFacade.uasset | 4 +- .../Content/Hollywood/Site/M_Asphalt.uasset | 2 +- .../Content/Hollywood/Site/M_Concrete.uasset | 2 +- .../Hollywood/Site/M_FacadeStone.uasset | 2 +- .../Content/Hollywood/Site/M_Ground.uasset | 2 +- .../Hollywood/Site/M_KerbConcrete.uasset | 2 +- .../Content/Hollywood/Site/M_RoadPaint.uasset | 2 +- .../Content/Hollywood/Site/M_Stucco.uasset | 2 +- .../Content/Hollywood/Site/Terrain_DEM.uasset | 2 +- Game/GigLine/Content/Test/ReviewMap.umap | 2 +- Screenshots/README.md | 3 +- Screenshots/demo/review-demo-crossing.png | 4 +- .../demo/review-demo-north-buildings.png | 4 +- Screenshots/demo/review-demo-spawn-view.png | 4 +- Screenshots/demo/review-demo-street-level.png | 4 +- Screenshots/review-character.png | 4 +- Screenshots/review-ground-contact.png | 4 +- Screenshots/review-hero-facade-front.png | 4 +- Screenshots/review-hero-facade-oblique.png | 4 +- Screenshots/review-hero-portals.png | 4 +- Screenshots/review-npc-prefab.png | 4 +- Screenshots/review-overview.png | 4 +- Screenshots/review-player-placement.png | 4 +- Screenshots/review-scale-cubes.png | 4 +- Screenshots/review-shadow-compare.png | 4 +- TODO.md | 2 +- Tools/README.md | 7 + Tools/facade_detail_concepts.py | 319 ++++++++++++++++++ Tools/hero_facade_build.py | 9 +- Tools/tests/test_facade_detail_concepts.py | 108 ++++++ 32 files changed, 487 insertions(+), 45 deletions(-) create mode 100644 Tools/facade_detail_concepts.py create mode 100644 Tools/tests/test_facade_detail_concepts.py diff --git a/Art/blender/hero/hero-facade-2026-11-07.blend b/Art/blender/hero/hero-facade-2026-11-07.blend index d09f55d..b88fd9b 100644 --- a/Art/blender/hero/hero-facade-2026-11-07.blend +++ b/Art/blender/hero/hero-facade-2026-11-07.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efd64c3868efddfc1b7dcf8a4a6e5a04b3e993f2742f6543cee68e6f4424b37f -size 134196 +oid sha256:fe3a08cdf4ca196cd59fe52a7ba9edc140d0e11391dff970a93b883410b415a6 +size 134198 diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index cbb508e..56e6a99 100644 --- a/Game/GigLine/Content/Hollywood/DemoMap.umap +++ b/Game/GigLine/Content/Hollywood/DemoMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16d5f4ee60297154f7e8125f6af47c30593961ae74462ebc4658b5b582df1e62 +oid sha256:58339d354b5afeb8fddefdd2c463eda6d45cd5379ff80f8eb644a33c17599e3f size 54508 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset index 3240b57..01f38d5 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97c39662ded14a52d89e194c8e566d3bc2c1dbbdf89d739070c001f5c198c884 -size 117002 +oid sha256:d3831a508ced9e573ab9db083672c1e16ba46f9f052ceb53665853f6198cc64b +size 117009 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset b/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset index dcf7844..3899d1d 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36ce1de545cf43c1ed8f19ed2dc3499fc8e1e2efca547e9fb971d3261db486d0 +oid sha256:9525922eca9e99a50f97b0c44ede1b908e1d97fa8c0fbddf928ffa3fbc960c4b size 7876 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset index cfdd0b2..5eab245 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85c127d9f7e6a5a5a35dbd114fc9bb43620db84bd04650a0625d827d9499e484 +oid sha256:906f52dcc949ac7dfeae7f60e250434af3d2e57d5d6e23a6deb565d3a138a39e size 7881 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset index 5843bbe..ccecc9c 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c0c6efaa601235b1719752a017cf062001eaf859249af3428e1a133efb21ab3 +oid sha256:0323b2b2ee3023cadb09d8bc3f3a7c024ef2c2a0de53db8442070ef404a3b434 size 7896 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset b/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset index a0c15ad..ea094ac 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e4eab78c46b101565591b6de1ca002fa0f3d63e64ca669c686d363f27518a45 +oid sha256:ebc3cbc40452389f0ae078dad79949653612c5c300fa5644807344dbb6aa97bb size 7871 diff --git a/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset index 580af1e..00b980c 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0e2e423bc6324a211bd44976653b5129d11edda6d401588c0e42724b00f3614 +oid sha256:4f311cc222d4f215b3163841c111e526b09874d5ee8845ea5720e4556babd387 size 7901 diff --git a/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset b/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset index 7cfa980..dba2aed 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a256f29ac54323c4c5efe6fe80d3932381ad0c9d35115dd396d830d6fe9b17f +oid sha256:4b4e25f5d61346ae996c1ad43c889fdabbe0576f306fba83b67f2918e778d675 size 7886 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset b/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset index 088c0d3..c4b8fa9 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a3a70892aca278b313e2f90d53f404f05e8df2cdb40f7a1b81fd1513d216413 +oid sha256:720902f570fb87a38e3847d2645b057e71f8971a82000ca39af3652349349ce7 size 7871 diff --git a/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset b/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset index d628366..c175268 100644 --- a/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2f46d8184e1d8a9f580404be92d3721bb11098d2585548551a161f42532498e +oid sha256:17d9fe3b1ce4589e610f0027c91f361eb73b09c7442b5c4a763b5419aba47040 size 335528 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index 9df8f2b..ec501e9 100644 --- a/Game/GigLine/Content/Test/ReviewMap.umap +++ b/Game/GigLine/Content/Test/ReviewMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fdb42865c9fd632ec28a96b3eebfd03fdab611c57512a54323fb93beb7ded8a +oid sha256:e94a0743d2dfd379b4011e69d79f0fe156a8d2158727b8fc5f3d1395f854e2aa size 28706 diff --git a/Screenshots/README.md b/Screenshots/README.md index 2a5f6fc..73e033f 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -58,5 +58,6 @@ Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs t - 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. diff --git a/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index ea864e4..dd16d77 100644 --- a/Screenshots/demo/review-demo-crossing.png +++ b/Screenshots/demo/review-demo-crossing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da23e9657ac70d9b07b992d80ba1125ea1c37e4341e972a85cc125e2d0fc55d2 -size 2666878 +oid sha256:33e7e224081d4b0a44752a42e93b944f1f41172c85978c8f4f9a20169c4b7502 +size 2666539 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index 240a795..deb86a3 100644 --- a/Screenshots/demo/review-demo-north-buildings.png +++ b/Screenshots/demo/review-demo-north-buildings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b22b2d96e338664960053deef545e7f6c6484b268f7132072f97853066a45960 -size 2503609 +oid sha256:cd7a116c05c31584187d6dd05fb94f69d6ff540737cb212b8a860931f360666b +size 2506551 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index 73bdea9..880f199 100644 --- a/Screenshots/demo/review-demo-spawn-view.png +++ b/Screenshots/demo/review-demo-spawn-view.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4769b99211089f11b7c6a2d69af8169038a62fd76eb218c0092782af6c600202 -size 2607575 +oid sha256:f1cbf3b285c04f9c851ede43f5aa97585facf966f5e7e8cdb20d7ef074f71193 +size 2619015 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index 995d628..9f8899d 100644 --- a/Screenshots/demo/review-demo-street-level.png +++ b/Screenshots/demo/review-demo-street-level.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf2ceebd60382c94a0b66fae1da93554023172ff88c6daa65bacc09059776dd5 -size 2442205 +oid sha256:bba4637c4fb2a60f629def61edc61d231389576a77bd4a02dbe569ceb13ae036 +size 2441194 diff --git a/Screenshots/review-character.png b/Screenshots/review-character.png index 09d2570..6035512 100644 --- a/Screenshots/review-character.png +++ b/Screenshots/review-character.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:284c94b4932504c8b6dad7faaa8ed4e83293ec90aa572a08810f374c6ac3f776 -size 2536672 +oid sha256:874d3a55f9f369c07bb266619550a0ddb6608e60bca03b61e92c5c84172899a2 +size 2533314 diff --git a/Screenshots/review-ground-contact.png b/Screenshots/review-ground-contact.png index a3dafeb..36fd3d6 100644 --- a/Screenshots/review-ground-contact.png +++ b/Screenshots/review-ground-contact.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:454940ab353bd4cf9cc6d15c19581def8cba85551f17f917d835656073fed2d5 -size 1756594 +oid sha256:5e03e459828414a20d22cf82bab6ff2101e2d252e05892d6353de72c0bfcafb7 +size 1751574 diff --git a/Screenshots/review-hero-facade-front.png b/Screenshots/review-hero-facade-front.png index 102caf9..eca1075 100644 --- a/Screenshots/review-hero-facade-front.png +++ b/Screenshots/review-hero-facade-front.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f193afde4e5becdfa0d8c22b9f2b3d5808492a8e84d04645d2593509e5eec64e -size 1548752 +oid sha256:8d43265c7371293f51d3097ac0b64231bb744a8968c34fea6059bda4bf338a94 +size 1552429 diff --git a/Screenshots/review-hero-facade-oblique.png b/Screenshots/review-hero-facade-oblique.png index dd7405f..0519f5e 100644 --- a/Screenshots/review-hero-facade-oblique.png +++ b/Screenshots/review-hero-facade-oblique.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfeabf9ecd2ecba7e5fa9906a5e4753aa369cfe8338dc644661cd3084a62b6ef -size 1305603 +oid sha256:46c18a8e4a04e87f9c5bfe0d485e012ca320ea258d01d2bbf04fb81e8366cdd0 +size 1306107 diff --git a/Screenshots/review-hero-portals.png b/Screenshots/review-hero-portals.png index 5b9857d..2443cb5 100644 --- a/Screenshots/review-hero-portals.png +++ b/Screenshots/review-hero-portals.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fcae54c0d176c60251718e28a05bb3e605eafc084698256995d08a65498fdc3 -size 1321708 +oid sha256:fa0eedb216ff40fd04886f3848de4ed614e57b2cdc376a2881a3869c8b2027f9 +size 1326324 diff --git a/Screenshots/review-npc-prefab.png b/Screenshots/review-npc-prefab.png index 50a8a9f..ae36cee 100644 --- a/Screenshots/review-npc-prefab.png +++ b/Screenshots/review-npc-prefab.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd2016a49dc035945fa350b51fbcba3ddce3f95d723bebe2c42702d3ee3f377f -size 1735461 +oid sha256:77b89f510d5bdbc5a894a65e7244713d055b9e0c5ef365a52c31f5e8886946cc +size 1732427 diff --git a/Screenshots/review-overview.png b/Screenshots/review-overview.png index 18e53e7..1b8ef3b 100644 --- a/Screenshots/review-overview.png +++ b/Screenshots/review-overview.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ddaadc5a847a281a95ef99c31d6aa60a35c62b5b0e794cd6ef04d8dbbbc351c6 -size 2589945 +oid sha256:5bfd21b670e27b2d9642083bc25460023d8acf4d788981b750ad7cc7d74d13ed +size 2482062 diff --git a/Screenshots/review-player-placement.png b/Screenshots/review-player-placement.png index aaee19a..b44ffde 100644 --- a/Screenshots/review-player-placement.png +++ b/Screenshots/review-player-placement.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65dceafdc4a716eb1900e1b7f08e9cb48dd0619ea8245b17c159e27aaf60cf89 -size 2025575 +oid sha256:11933b0e8fe721cd9f785f15d99438dd8c632e814a7fe86b053e855b909673d4 +size 2022340 diff --git a/Screenshots/review-scale-cubes.png b/Screenshots/review-scale-cubes.png index 38c14ef..3bb9a2a 100644 --- a/Screenshots/review-scale-cubes.png +++ b/Screenshots/review-scale-cubes.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09584a3e4c2ee4931671f9e71c0991feea9acbc7dfed7b8f2cdaa2169e6e008e -size 1943609 +oid sha256:c54621f64bd890182f04755154156a74b735ad1a839b712c6c48bd9d60f1afe6 +size 1936777 diff --git a/Screenshots/review-shadow-compare.png b/Screenshots/review-shadow-compare.png index 08c6c6e..c980a6b 100644 --- a/Screenshots/review-shadow-compare.png +++ b/Screenshots/review-shadow-compare.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdc59ffde57459024b5af56d66e489e9522908662e63984b4c27ac6b960392de -size 1715031 +oid sha256:01f3443e4163ec9e3e45f1612c8357f1b05dd6bb87043095b8aa9e8a1ceaddab +size 1721917 diff --git a/TODO.md b/TODO.md index c00ee99..c4e5599 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ Suggested blender skills: **references** (reference-image-match and visual-match ### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`) - [x] Correct building scale and street proportions flagged against references. **Mapbox adopted 2026-11-09.** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints; after adopting its two findings the cross-check reports **66 match, 6 non-building, 0 disagreements**. Adopted: **`LEVEL_HEIGHT` 3.2 → 3.1 m** (`block_height_report.py`, and the Blosm `--level-height` default), plus explicit `HEIGHT_OVERRIDES` (3.0 m) for the three rear `building=yes`/`apartments` structures Mapbox gives as single-storey. Blockout regenerated, frontage re-exported/re-imported, DemoMap rebuilt; packaged `-qaselftest` all PASS. Measured effect: 6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m (both now ~1.9 m above their table height = roof mass). -- [ ] Refine hero façade details where reference photography shows discrepancies. +- [ ] Refine hero façade details where reference photography shows discrepancies. **Started 2026-11-09:** built `Tools/facade_detail_concepts.py`, which generates architectural detail plates from the local reference photographs with an image model (`--list` free, `--all` generates, sidecars + `-vN` no-overwrite, output gitignored). Eight studies generated locally under `Art/reference/facade-details/`: door recess, entrance, column order, entablature/cornice, parapet/motto, window rhythm, street wall, banner. **First fix from the measurement pass:** window panes were 1.44 m in front of the wall (ahead of the columns) and now sit on the wall plane behind the colonnade. Remaining: use the plates to correct the capital/base profiles and the medallion/cornice detail. - [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected. - [x] 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. **Fixed 2026-11-09.** Measured baseline: colonnade (columns + capitals) 6.90 m of the 14.50 m envelope = **47.6 %**, because the entablature (3.40 m) and parapet (2.20 m) left only 7.90 m for the order. Thinned both to **3.00 m** and **1.80 m** (`Tools/hero_facade_build.py`), which lifts the colonnade to **7.70 m = 53.1 %** — inside the photographed 50–60 % band, with the 14.50 m envelope, the motto band and the medallions unchanged in position relative to the parapet. Hero rebuilt, re-imported (bounds 2604 × 1257 × 1450 cm) and re-rendered. - [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section. diff --git a/Tools/README.md b/Tools/README.md index d11e50f..fc8e18f 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -60,6 +60,13 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. | | `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. | +## Reference and concept generation + +| Tool | Purpose | +| --- | --- | +| `facade_detail_concepts.py` | Generates architectural detail plates from a local reference photograph with an image model (OpenRouter). `--list` is free and shows which studies have their reference present; `--all`/`--only ` 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 | diff --git a/Tools/facade_detail_concepts.py b/Tools/facade_detail_concepts.py new file mode 100644 index 0000000..a6e1c60 --- /dev/null +++ b/Tools/facade_detail_concepts.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Generate architectural detail studies 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 +architectural detail plate — no crowd, no staging, straight-on, evenly lit — so +the modelling pass has something to measure against instead of a tourist photo. + + 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/.png` plus a `.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 fixed source of geometry and " + "composition: keep the building's proportions, column rhythm, band heights, " + "signage placement and camera angle exactly as photographed. Only restage " + "lighting, weather, people and surface condition where the request asks for " + "it. Do not invent extra storeys, wings or ornaments that the photo does not " + "show." +) + +# name -> (reference photo relative to Art/reference/, what the study must show) +STUDIES: dict[str, tuple[str, str]] = { + "door-recess": ( + "el-capitan-entrance/01-hollywood-el-capitan-entrance-detail.jpg", + "A clean architectural detail plate of exactly the subject in the " + "reference photograph. Render it as an even-daylight stone study: crisp " + "surface carving, readable depth of every recess and moulding, no people, " + "no signage or banners in front of it, no shop clutter, straight-on to the " + "subject.", + ), + "entrance-wide": ( + "el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg", + "A straight-on architectural study of the street entrance in the " + "reference photograph, the portal group centred and filling the frame, " + "threshold and pavement readable, no pedestrians or vehicles, even " + "overcast daylight, stone surface legible.", + ), + "column-order": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "A close architectural study of the column order visible in the " + "reference photograph: the base, shaft and capital read clearly, " + "neighbouring columns framing it. Straight-on, evenly lit, stone surface " + "and carving legible, no people or banners.", + ), + "entablature-cornice": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "An architectural detail study of the horizontal stone bands above the " + "columns in the reference photograph: every band edge and the projecting " + "cornice with its shadow line. The layered depth of the stone must be " + "readable, flat even light, no people.", + ), + "parapet-motto": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "An architectural study of the parapet above the colonnade in the " + "reference photograph: the inset panel, any round medallions beside it " + "and the coping at the top. Lettering on the panel is blocked out as " + "unreadable texture. Flat even light, no people.", + ), + "window-rhythm": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "An architectural study of the wall surface behind and between the " + "columns in the reference photograph: the opening or window rhythm and " + "the reveal depth. Straight-on, no people, no banners covering the wall.", + ), + "street-wall": ( + "hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg", + "A clean street-wall study from the opposite pavement, as framed in the " + "reference photograph: the building line, the parapet heights of the " + "neighbouring buildings, the shopfront line and the pavement depth. No " + "crowd, even daylight.", + ), + "banner-detail": ( + "el-capitan-premiere/02-premiere-of-spielberg-s-west-side-story---el-capitan---holly.jpg", + "An architectural study of the vertical banner in the reference " + "photograph: how it is fixed, its proportions against the column behind " + "it, and how it sits against the stone. Daylight, no crowd.", + ), +} + + +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: + """`.png`, or `-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 .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()) diff --git a/Tools/hero_facade_build.py b/Tools/hero_facade_build.py index c3c670f..c75015a 100644 --- a/Tools/hero_facade_build.py +++ b/Tools/hero_facade_build.py @@ -245,10 +245,17 @@ 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 diff --git a/Tools/tests/test_facade_detail_concepts.py b/Tools/tests/test_facade_detail_concepts.py new file mode 100644 index 0000000..fae36ca --- /dev/null +++ b/Tools/tests/test_facade_detail_concepts.py @@ -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()