refactor(tools): group the sixty scripts by what they talk to

One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
zwitschi committed 2026-11-10 12:40:30 +01:00
1 parent f681d83736
commit 4d27109668
168 files changed
+799 -407

No files matched your search

+32 -32
View File
@@ -65,10 +65,10 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-07 — Reference photography
- **Licensing is not a gate for local development.** Reference images live in the gitignored `Art/reference/` and are never committed. What each set establishes is written into `Docs/reference-notes-hollywood.md`.
- **Commons text search runs dry; categories do not.** `Tools/commons_fetch.py --category "Category:Hollywood Masonic Temple"` surfaced 15 files a search never showed, including 4–5k px façades.
- **Commons text search runs dry; categories do not.** `Tools/gen/commons_fetch.py --category "Category:Hollywood Masonic Temple"` surfaced 15 files a search never showed, including 4–5k px façades.
- **`upload.wikimedia.org` throttles hard.** Batches of six or fewer with `--delay 3`; a throttled file is skipped and recorded rather than killing the run (HTTP 429).
- **Unqualified queries return junk.** `Hollywood Boulevard shops` returned a Münster street, `Hollywood Masonic Temple entrance` a Seattle card index. Check the hit list before accepting a set.
- **OpenRouter web search is expensive.** One `gpt-5.2` + `openrouter:web_search` call ran 17 searches and cost **$0.38**; two of its eight URLs were Commons files already held. Use it for a known gap, not a sweep. `Tools/page_image_fetch.py` turns results into files by reading `og:image`.
- **OpenRouter web search is expensive.** One `gpt-5.2` + `openrouter:web_search` call ran 17 searches and cost **$0.38**; two of its eight URLs were Commons files already held. Use it for a known gap, not a sweep. `Tools/gen/page_image_fetch.py` turns results into files by reading `og:image`.
- **The inherited "eight stone steps, 12 m wide" is wrong.** Photographs show three portals, each with its own short flight. Do not copy that number into geometry.
## 2026-11-06 — Blender and Blosm, headless
@@ -98,12 +98,12 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-07 — Unreal pipeline findings
- **UE 5.8 game targets must use `BuildSettingsVersion.V7`** to share the installed editor's build environment; V5 is rejected with "modifies the values of properties … not allowed".
- **`UnrealEditor-Cmd -run=pythonscript` swallows Python `print()`** with `-stdout` in 5.8 — write results to a file instead (see `Tools/ue_scale_test_import.py`).
- **`UnrealEditor-Cmd -run=pythonscript` swallows Python `print()`** with `-stdout` in 5.8 — write results to a file instead (see `Tools/unreal/ue_scale_test_import.py`).
- **`unreal.Box` has `min`/`max`, not `origin`/`get_extent()`** in 5.8 Python; compute centre/size from the corners.
- **FBX Blender→UE axis mapping is east→+X, north→−Y, up→+Z** with preset `ue_metric_v1`; the Y mirror is inherent (RH→LH). Compass math in game code: north = −Y. Verified with the three-cube scale test.
- Headless asset import works fine with `-nullrhi`; `unreal.AssetImportTask` + `FbxImportUI` is the pattern (Interchange pipeline under the hood).
- **Flatpak Blender headless needs `XDG_CONFIG_HOME=$HOME/.var/app/org.blender.Blender/config`** for extensions: `bpy.utils.user_resource('EXTENSIONS')` otherwise resolves to `~/.config/blender/5.2/extensions` (empty) and `bl_ext.blender_org.mpfb` is undiscoverable. Blosm unaffected (scripts/addons via `BLENDER_USER_SCRIPTS`). Found via `Tools/addon_compat_probe.py`; documented in `Art/blender/README.md`.
- **UE 5.8 Interchange skips material/texture assets for skeletal FBX imports** (static imports do get materials). Finish the chain in Python: import texture, build Material via `MaterialEditingLibrary`, assign with `sm.set_editor_property('materials', [SkeletalMaterial])` — the struct field is `material_interface`, not `material`, and `SkeletalMesh.set_material()` does not exist. See `Tools/ue_character_import.py`.
- **UE 5.8 Interchange skips material/texture assets for skeletal FBX imports** (static imports do get materials). Finish the chain in Python: import texture, build Material via `MaterialEditingLibrary`, assign with `sm.set_editor_property('materials', [SkeletalMaterial])` — the struct field is `material_interface`, not `material`, and `SkeletalMesh.set_material()` does not exist. See `Tools/unreal/ue_character_import.py`.
- **Blender 5.2 FBX exporter removed `armature_nla`** (passes for 4.x recipes fail). Also: a baked image is only referenced by the FBX exporter when `image.filepath` (not just `filepath_raw`) is set and the exporter can map the node — MPFB's v2 skin node-group tree can't be mapped, so a flat export material is required.
## 2026-11-07 — Collaboration and scope
@@ -122,7 +122,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **`unreal.InputCoreTypes` does not exist** in the 5.8 Python bindings, and `unreal.Key` takes no constructor argument. Build a key with `k = unreal.Key(); k.import_text("SpaceBar")` — the export text is the key name (`MouseX`, `Gamepad_Left2D`).
- **Factories are `unreal.InputAction_Factory` and `unreal.InputMappingContext_Factory`**, not `InputActionFactory`. `create_asset(..., factory=None)` also works.
- **Do not delete an asset and recreate it in the same commandlet run.** `create_asset` returns `None` for a path deleted earlier in that process. Load the existing asset and overwrite it instead (`Tools/ue_create_input_assets.py: fresh()`).
- **Do not delete an asset and recreate it in the same commandlet run.** `create_asset` returns `None` for a path deleted earlier in that process. Load the existing asset and overwrite it instead (`Tools/unreal/ue_create_input_assets.py: fresh()`).
- **Modifiers on `EnhancedActionKeyMapping` do not survive saving.** Assigning `InputModifierNegate` to the mapping's `modifiers` reads back correctly in memory, even after a `gc.collect()`, but the saved asset reads `mods=[NoneType]`. Keep-alive references, explicit flag setting, and in-place saves all failed to change it. Workaround: bind one action per direction (W and S as separate actions) so no modifier is needed.
- **A read-only packed `.umap` silently blocks `save_map`.** `EditorLoadingAndSavingUtils.save_map` returned `False` with no error until the file's mode was fixed (`chmod u+w`). Symptom: a rebuilt review map keeps three cameras after a fourth is added.
- `-run=pythonscript` still swallows `print()`, so write results to a file; `/tmp` is visible to the commandlets even though it is not shared between agent tool calls.
@@ -153,7 +153,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-07 — Blender rebuilds that silently did nothing
- **A read-only `.blend` makes Blender skip the save without failing the run.** `Tools/hero_facade_build.py` printed its part list and "bounds" as normal, then went straight to the export: the `.blend` and the report stayed at their old timestamps, and the Unreal re-import brought back the old mesh. Symptom to watch for: the imported bounds do not change after a rebuild. Fix: `chmod u+w` the `.blend` first, then confirm the timestamp moved.
- **A read-only `.blend` makes Blender skip the save without failing the run.** `Tools/blender/hero_facade_build.py` printed its part list and "bounds" as normal, then went straight to the export: the `.blend` and the report stayed at their old timestamps, and the Unreal re-import brought back the old mesh. Symptom to watch for: the imported bounds do not change after a rebuild. Fix: `chmod u+w` the `.blend` first, then confirm the timestamp moved.
- **Model bottom must land on Z 0.** The hero portal jambs were sized `PORTAL_H + 0.3` and centred at `PORTAL_H / 2`, so they hung 0.15 m below the pavement and the whole facade imported with `min z = -15 cm`. Always check the asset's `bounds_m.min.z` in the build report.
- **Rebuilding a facade is not enough to make a detail shot work**: doors modelled as thin panels on a solid plinth read as shading, not openings. Cutting the plinth into piers with a lintel band is what makes them legible from 8 m.
@@ -180,8 +180,8 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-07 — screenshot judge tool
- `Tools/judge_screenshot.py` — runs an OpenRouter vision model over a screenshot vs an expected-scene description. Reads `OPENROUTER_API_KEY` from the repo `.env` (ignored). Base64 data-URI image under `image_url.url`; OpenAI-compatible body → `POST /api/v1/chat/completions`. `openai/gpt-4o-mini` returns a `{verdict, confidence, reason, missing}` JSON. It flags genuinely missing content (e.g. `main`'s pre-warm-skin character shot judged "no"); it is a pre-check, not the human verdict.
- Test one: `Tools/judge_screenshot.py --image Screenshots/review-01-character.png --expect "<scene>" --verbose`. Bulk: `--shots` (parses the README rows).
- `Tools/review/judge_screenshot.py` — runs an OpenRouter vision model over a screenshot vs an expected-scene description. Reads `OPENROUTER_API_KEY` from the repo `.env` (ignored). Base64 data-URI image under `image_url.url`; OpenAI-compatible body → `POST /api/v1/chat/completions`. `openai/gpt-4o-mini` returns a `{verdict, confidence, reason, missing}` JSON. It flags genuinely missing content (e.g. `main`'s pre-warm-skin character shot judged "no"); it is a pre-check, not the human verdict.
- Test one: `Tools/review/judge_screenshot.py --image Screenshots/review-01-character.png --expect "<scene>" --verbose`. Bulk: `--shots` (parses the README rows).
## 2026-11-07 — Blender skill packs: discovery depth
@@ -202,12 +202,12 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-07 — ReviewMap corruption: deprecated level calls + save-order bug
- **`EditorLevelLibrary.load_level`/`new_level` are deprecated no-ops in `-nullrhi` commandlets** (they return without switching worlds, so staged actors land in whatever world is current and get saved over the wrong map). Use the `LevelEditorSubsystem`: `unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).load_level(path)` actually switches. Symptom was ReviewMap containing Demo actors + `ShotSequencer`.
- **Map-save ordering bug in `Tools/ue_build_review_map.py`**: the script saved ReviewMap _after_ the DemoMap section, but the switch back to ReviewMap fails silently because the map only exists in memory at that point — so the final `save_map` wrote the demo world over ReviewMap. Fix: save ReviewMap first, then stage the demo cameras, and never switch back. Both maps verified by probe (ReviewMap lists Review actors; DemoMap lists Demo actors).
- **Map-save ordering bug in `Tools/unreal/ue_build_review_map.py`**: the script saved ReviewMap _after_ the DemoMap section, but the switch back to ReviewMap fails silently because the map only exists in memory at that point — so the final `save_map` wrote the demo world over ReviewMap. Fix: save ReviewMap first, then stage the demo cameras, and never switch back. Both maps verified by probe (ReviewMap lists Review actors; DemoMap lists Demo actors).
- Rebuilt ReviewMap after the fixes: character, both cubes (A grounded, B floating at 2 m), pawn, NPC prefab, hero facade all present; user re-approved the shots. README cube naming corrected (README had A/B swapped vs the script).
## 2026-11-07 — Materials and lighting in the demo map
- **Blender flat diffuse colours do not survive FBX -> Unreal.** Every site mesh (road, sidewalks, kerbs, crossing paint) imported with a material asset that had a default _white_ base colour, and the terrain imported with `WorldGridMaterial` (UE's bright grey grid). That — not the sun — is what made the demo look blown out and washed white. Fix: create the colours in UE (`M_Asphalt`, `M_Concrete`, `M_Ground`, `M_RoadPaint` via `Constant3Vector`) and assign them per actor in `Tools/ue_build_demo_map.py`.
- **Blender flat diffuse colours do not survive FBX -> Unreal.** Every site mesh (road, sidewalks, kerbs, crossing paint) imported with a material asset that had a default _white_ base colour, and the terrain imported with `WorldGridMaterial` (UE's bright grey grid). That — not the sun — is what made the demo look blown out and washed white. Fix: create the colours in UE (`M_Asphalt`, `M_Concrete`, `M_Ground`, `M_RoadPaint` via `Constant3Vector`) and assign them per actor in `Tools/unreal/ue_build_demo_map.py`.
- **Auto-exposure makes review shots non-comparable**: the same road rendered washed-out in one framing and near-black in another because each camera saw a different amount of sky. Fix: an unbound `PostProcessVolume` with `AEM_HISTOGRAM` and `auto_exposure_min_brightness == auto_exposure_max_brightness` (fixed exposure). Do not use `AEM_MANUAL` + `auto_exposure_bias` — a bias of 11 blew the whole frame out.
- **`DirectionalLightComponent.light_color` takes a `Color` struct, not `LinearColor`** (`unreal.Color(r=255,g=245,b=230,a=255)`); passing `LinearColor` raises a nativize error.
- **One band per object loses geometry downstream.** `crossing_band()` created `Crossing_Markings` each call, so Blender renamed the second and third to `.001`/`.002`; `site_assembly.py` appends by exact name, so only one crossing ever reached the game. Build all bands into a single mesh instead of one object per band.
@@ -317,13 +317,13 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 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).
- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in `Tools/blender/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/`.
- `Tools/gen/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`.
@@ -339,7 +339,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 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.
- Now `Tools/blender/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.
@@ -357,7 +357,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — 0.1.3 packaging/setup fixes
- **`Tools/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0.
- **`Tools/unreal/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0.
- Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` (only `unreal`), so the sys.path line raised `NameError: name 'sys' is not defined` inside the commandlet — an exit code of 255 with no obvious cause.
- Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest` usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and documents the two optional API keys; architecture 07 + combined doc corrected the build-output path.
@@ -371,16 +371,16 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — facade texture maps (0.1.4)
- `Tools/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum).
- `Tools/gen/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum).
- `ue_build_demo_map.py` imports them to `/Game/Hollywood/Textures` and samples them **world-aligned**: WorldPosition (cm) → ×(1/tile_cm) → ComponentMask(R,G) → TextureSample UVs. `tile_cm` per material: stone 440, stucco 260, asphalt 900, concrete 250.
- Gotchas: `MaterialExpressionTextureSample`'s UV input pin is **"UVs"**, and the output pin for colour is **"RGB"** / roughness **"R"**. `ComponentMask` pins are R/G/B/A booleans. A textured material skips the graph coursing on purpose — overlaying both would double the course pattern.
- The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting either fails with `NameError` and an exit code of 0 from the commandlet (always read the result file under /tmp/gigline).
## 2026-11-09 — façade module kit (0.1.4)
- `Tools/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece).
- `Tools/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing).
- `Tools/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings.
- `Tools/blender/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece).
- `Tools/unreal/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing).
- `Tools/unreal/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings.
- **Bug worth remembering:** the first window piece was modelled entirely at +Y (into the wall), so every window sat _inside_ the solid blockout wall and was invisible in every render while the placement maths looked right. The kit now projects the surround 3 cm proud with the glazing 6 cm behind its face — a solid-wall blockout cannot carry a real recess, so the piece has to project.
- Blender gotcha: clearing the scene _including materials_ invalidates handles already taken ("StructRNA of type Material has been removed"); clear objects and orphan meshes only, and create the material after the clear.
- The kit meshes are spawned at the world origin with their position baked into the mesh (like the frontage exports), so placement uses the bbox directly and must not add the actor location.
@@ -401,7 +401,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — visual comparison loop; screenshots are demo-only
- `Tools/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion.
- `Tools/review/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/review/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion.
- **Iterations are gitignored** (`/Art/reference/iterations/`); only confirmed shots are promoted into `Screenshots/`. A test asserts the ignore rule and that every target is a demo-map shot.
- `Screenshots/` now holds **demo-map captures only** — the review staging map is no longer rendered for review. `render_screenshots.sh --verify` renders the demo map, collects the shots and runs the verification gate in one step.
- **PIL gotchas:** `FIND_EDGES` leaves its own border ring at 128 (236/3600 px on a 60x60 frame = 6.5% "detail" on a perfectly flat image), so crop **after** filtering — cropping before does not help, the new border gets the same treatment. Palette shares must divide by the full-image area, not the cropped one.
@@ -409,7 +409,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — theatre marquee + blade sign
- `Tools/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic).
- `Tools/blender/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/unreal/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic).
- **Two placement bugs, both invisible in a render:**
1. **Axis sign.** Blender +Y is Unreal −Y, so a face at Unreal Y +1001 is Blender Y −10.01. Using +10.01 put the whole marquee on the far side of the boulevard.
2. **Absolute Z.** Trim heights were absolute while the pavement at the theatre is 4.56 m above the project datum, so the canopy hung 5 cm above the pavement — the QA wall probe caught it as a knee-height blocker. `GROUND_Z` now offsets every height.
@@ -436,7 +436,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft (120 pieces) rather than boolean cuts — the eye reads them in silhouette and grazing light, booleans would cost far more for that. Motto band is now a real recess: surround 6 cm proud, panel 12 cm behind (was an 8 cm dark stripe). Hero 148 parts/3 044 tris -> 271/5 492, still 14.50 m.
- **The real find:** `ue_hero_facade_import.py` had its own hero position `(-12024.5, -1450, 0)` — the pre-`0.1.0` value, before the frontage placement fix moved the hero to `(-13336, +1307)`. Running the importer on its own therefore teleported the hero **13 m east and onto the north side of the boulevard**, and nothing complained: a displaced building still renders. The only symptom was the reference score dropping 0.69 -> 0.56 with the 3x3 composition correlation flipping +0.54 -> -0.08.
- Fix: `Tools/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet).
- Fix: `Tools/unreal/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet).
- Lesson to keep: **duplicated placement constants drift silently.** The metric that caught it was the composition correlation, not a visual review.
## 2026-11-09 — 1440p re-measured with the kit: 363 fps, ~12% cost
@@ -447,7 +447,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — 604 unmaterialed actors: material rebuild was delete+recreate
- The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured.
- The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/unreal/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured.
- **Cause:** `ensure_material` in `ue_build_demo_map.py` deleted and recreated each `/Game/Hollywood/Site/M_*` asset every run ("so a look change cannot be masked by a stale asset"). That orphane every reference: actors staged by other tools pointed at deleted assets and fell back to the default material. The map looked only _slightly_ flat — no error, no warning.
- **Fix:** update the material **in place** — load it, `MaterialEditingLibrary. delete_all_material_expressions(mat)`, rebuild the graph, recompile, save. Note `Material.expressions` is protected from Python; use the library's deleter.
- **Ordering rule now documented and enforced:** the demo builder rebuilds materials, so kit and theatre staging must run _after_ it. `render_screenshots.sh` now runs demo map → kit assembly → theatre trim → scene audit → shot cameras → capture, and **fails on a non-zero audit count**.
@@ -462,7 +462,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — Retail frontage and two pipeline traps
- **`Tools/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move.
- **`Tools/review/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move.
- **The generic façade kit is applied to every actor labelled `Frontage_*` or `North_*`**, so staging a hand-modelled façade under those labels gets the kit pasted over it — two contradicting façade languages on one wall. `ue_apply_facade_kit.py` now skips `MODELLED` labels, and retail trim stages as `RetailTrim_*`.
- **A Blender builder cannot be imported by a unit test** (`import bpy` at module scope). Pure layout arithmetic lives in `Tools/_common.py`; `Tools/tests/test_retail_frontage.py` tests it there, and the builder delegates. Same trick `fbx_export_preset.py` uses for its preset dicts.
- **Joining 85 parts into one mesh per building** before export cut the Unreal import from 85 assets to 2, and the staging step to two spawns. Record per-part dimensions _before_ the join: after it only the joined bounds exist.
@@ -483,14 +483,14 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **The real reason the character pose never changed across a dozen rebuilds:** the FBX importer reported success every time, but `Character_Anim` and its two AnimSequences kept their original data. Proof: after changing the arm base from 1.55 rad to 0.55 rad and rebuilding, `AnimPose.get_bone_pose("upperarm01_L")` returned the **identical** quaternion `(+0.006, +0.002, -0.179, -0.984)` as before. An asset that does not change when its source does is not being written.
- `AssetImportTask.replace_existing = True` was set, and `EditorAssetLibrary.delete_asset` returned **False** for both clips, even after `chmod u+w` (the `.uasset` files are LFS-lockable and check out read-only). Deleting is blocked because the pawn's C++ constructor hard-references the clips.
- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
- **Workaround that works: import under a new name.** `Tools/unreal/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/blender/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
## 2026-11-09 — Textures: ComfyUI works, Stable Diffusion fails for flat swatches
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/gen/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/gen/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
- **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it.
## 2026-11-09 — Hair as a separate mesh, and a material-editing crash
@@ -502,7 +502,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — Character blueprints and how to compare a render to one
- **A SDXL character sheet is reliable**: "full body front view, neutral pose, plain grey background, character sheet" gives a usable turnaround with fixed seeds. Four views (front/side/back/head) from one description is enough to evaluate against; the sheet and its JSON live in `Art/characters/blueprints/`.
- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character.
- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/blender/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character.
- **The report has to separate the failures it measures.** Aspect, fill, coverage and two colour distances are reported apart on purpose: an absolute similarity number between a photoreal sheet and a low-detail body would mean nothing. The first real comparison (aspect Δ1.24, mean ΔE 31.9) was dominated by the model being **unclothed** while the blueprint wears a tee and jeans.
- **`SetMaterial(0, ...)` in the pawn constructor does not visibly land** on the body mesh: `M_Character_Skin` is built and assigned, yet the render stays neutral grey. Either the slot does not exist yet at that point or the assignment is dropped; `BeginPlay` is the next thing to try.
- **MPFB vertex groups are polluted by helper geometry.** The body mesh weights its helper cubes to groups named `Left`, `Right`, `HelperGeometry` and `JointCubes` at 1.0, so a "strongest group" lookup returns a helper for most vertices - the first clothing build selected **zero** faces. Filter helper names out before asking which bone a vertex belongs to.
@@ -515,9 +515,9 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-09 — The sidewalk does not block, and why
- **The staged `Sidewalk_North` mesh does not stop a trace or a capsule.** It carries `QueryAndPhysics`, its bbox tops out at Z 380, and `Tools/ue_import_site.py` sets `CTF_USE_SIMPLE_AND_COMPLEX` with `auto_generate_collision=True` — yet both a downward trace and a falling capsule pass straight through to `Road_Hollywood` at 366.
- **The staged `Sidewalk_North` mesh does not stop a trace or a capsule.** It carries `QueryAndPhysics`, its bbox tops out at Z 380, and `Tools/unreal/ue_import_site.py` sets `CTF_USE_SIMPLE_AND_COMPLEX` with `auto_generate_collision=True` — yet both a downward trace and a falling capsule pass straight through to `Road_Hollywood` at 366.
- **Likely cause: the auto-generated simple collision for a 279 m x 8.6 m x 3.8 m slab is degenerate.** A convex hull over that aspect ratio either collapses or ends up far from the surface; the complex side is one-sided and the mesh may be wound the wrong way. `bOneSidedGeometry` no longer exists on `BodySetup` in 5.8, so the flag cannot be flipped from Python.
- **Working stopgap:** an invisible `279 x 8.6 x 0.08 m` blocking cube with its top at Z 380 in `Tools/ue_build_demo_map.py`. Verified: `top surface under player: Z=380 mesh=Cube`, `foot: actor Z 470, feet 382`.
- **Working stopgap:** an invisible `279 x 8.6 x 0.08 m` blocking cube with its top at Z 380 in `Tools/unreal/ue_build_demo_map.py`. Verified: `top surface under player: Z=380 mesh=Cube`, `foot: actor Z 470, feet 382`.
- **The real fix belongs in the site export:** split the sidewalk into segments of a few metres, or export explicit `UCX_` boxes, so the collision is a set of sane hulls rather than one degenerate one.
## 2026-11-09 — Slicing the sidewalk did not fix its collision
@@ -597,8 +597,8 @@ Road_Hollywood_07 158 (was 366)
## 2026-11-10 — `chmod u+w` on the Blender files, and what re-rendering does not do
- Every `.blend` under `Art/` checked out read-only; a Blender save then fails with `RuntimeError: Error: Cannot save blend file ... is not writable`, and because the export happens after the save in `Tools/hero_facade_build.py`, **the FBX is never written and the old one is reused silently**. All 20 `.blend`/`.blend1` files were made writable; `find Art -name '*.blend' ! -perm -u+w` now returns 0.
- **Re-rendering does not pick up `.blend` changes.** `Tools/render_screenshots.sh` renders the imported UE assets, so a rebuild is needed in between: Blender build → `Tools/ue_*_import.py` → render. Chmod alone cannot change a pixel; the hero captures are byte-identical before and after.
- Every `.blend` under `Art/` checked out read-only; a Blender save then fails with `RuntimeError: Error: Cannot save blend file ... is not writable`, and because the export happens after the save in `Tools/blender/hero_facade_build.py`, **the FBX is never written and the old one is reused silently**. All 20 `.blend`/`.blend1` files were made writable; `find Art -name '*.blend' ! -perm -u+w` now returns 0.
- **Re-rendering does not pick up `.blend` changes.** `Tools/review/render_screenshots.sh` renders the imported UE assets, so a rebuild is needed in between: Blender build → `Tools/ue_*_import.py` → render. Chmod alone cannot change a pixel; the hero captures are byte-identical before and after.
## 2026-11-10 — skills: one common copy, project-specific stays local