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

+68 -51
View File
@@ -4,6 +4,21 @@ Repeatable helpers that drive Blender, Unreal, and the reference/asset sources.
Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side builders live next to their output in `Art/blender/*/`; this folder holds everything that talks to Unreal, the network, or the repository.
## Layout
Sixty scripts in one directory stopped being navigable, so they are grouped by what they talk to. The folder is also the sandbox boundary: everything in `unreal/` needs the engine, everything in `blender/` needs the Flatpak, and the rest is host Python.
| Folder | Holds | Needs |
| --- | --- | --- |
| [`unreal/`](unreal/) | Unreal commandlet scripts and the two map builders; `ue_common.py` and `ue_placement.py` are the shared imports | the engine pin from `Docs/toolchain.md` |
| [`blender/`](blender/) | Blender-side builders and exporters, the FBX preset, the MPFB/MakeHuman character pipeline, `blender_screenshots.py` | Blender Flatpak |
| [`review/`](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` | engine + optional OpenRouter key |
| [`gen/`](gen/) | Image and texture generation: local ComfyUI driver, deterministic procedural texture writers, reference fetching | local ComfyUI / network |
| [`input/`](input/) | `input_emulation.py`, `input_test.py`, `run_pie.sh` — OS-level input injection | `/dev/uinput` |
| `_common.py`, `tests/` | Shared helpers and the stdlib test suite | none |
Scripts resolve the repository root as `../..` from their own folder and put the tools root and their siblings on `sys.path`, so `import ue_common` keeps working from `unreal/`.
## Conventions
- One script, one job. Every input is an argument; no interactive prompts.
@@ -16,19 +31,19 @@ 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 |
| `facade_kit_build.py` | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). |
| `ue_import_retail_detail.py` | Imports the two retail trim meshes into `/Game/Hollywood/Frontage` with complex-as-simple collision — the trim decorates a solid the player already collides with, so it must not add snag points. Staged by the demo builder as `RetailTrim_*`. |
| `ue_import_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. |
| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| `theatre_trim_build.py` | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. |
| `ue_stage_theatre_trim.py` | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. |
| `ue_apply_facade_kit.py` | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. |
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
| `ue_audit_scene.py` | Gate on the DemoMap: no unmaterialed actor, every verified frontage span within tolerance (`ue_placement`), no duplicate actor labels. Writes `/tmp/gigline/scene-audit.txt`; `render_screenshots.sh --verify` fails on a non-zero failure count. Catches the class of defect that still renders plausibly. |
| `ue_placement.py` | Single source of truth for where staged actors belong in the DemoMap (hero position, verified frontage spans) plus `span_error`/`describe_span_failure` validators. Exists because the hero position was written twice and drifted — the importer kept the pre-`0.1.0` value and moved the hero 13 m east onto the wrong side of the boulevard. No `unreal` import, so it is unit-tested. |
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
| `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. |
| [`blender/facade_kit_build.py`](blender/facade_kit_build.py) | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). |
| [`unreal/ue_import_retail_detail.py`](unreal/ue_import_retail_detail.py) | Imports the two retail trim meshes into `/Game/Hollywood/Frontage` with complex-as-simple collision — the trim decorates a solid the player already collides with, so it must not add snag points. Staged by the demo builder as `RetailTrim_*`. |
| [`unreal/ue_import_facade_kit.py`](unreal/ue_import_facade_kit.py) | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. |
| [`blender/retail_frontage_build.py`](blender/retail_frontage_build.py) | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| [`blender/retail_frontage_build.py`](blender/retail_frontage_build.py) | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. |
| [`blender/theatre_trim_build.py`](blender/theatre_trim_build.py) | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. |
| [`unreal/ue_stage_theatre_trim.py`](unreal/ue_stage_theatre_trim.py) | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. |
| [`unreal/ue_apply_facade_kit.py`](unreal/ue_apply_facade_kit.py) | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. |
| [`gen/generate_facade_textures.py`](gen/generate_facade_textures.py) | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
| [`unreal/ue_audit_scene.py`](unreal/ue_audit_scene.py) | Gate on the DemoMap: no unmaterialed actor, every verified frontage span within tolerance (`ue_placement`), no duplicate actor labels. Writes `/tmp/gigline/scene-audit.txt`; `render_screenshots.sh --verify` fails on a non-zero failure count. Catches the class of defect that still renders plausibly. |
| [`unreal/ue_placement.py`](unreal/ue_placement.py) | Single source of truth for where staged actors belong in the DemoMap (hero position, verified frontage spans) plus `span_error`/`describe_span_failure` validators. Exists because the hero position was written twice and drifted — the importer kept the pre-`0.1.0` value and moved the hero 13 m east onto the wrong side of the boulevard. No `unreal` import, so it is unit-tested. |
| [`unreal/ue_common.py`](unreal/ue_common.py) | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
| [`blender/fbx_export_preset.py`](blender/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 |
@@ -36,42 +51,44 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose |
| --- | --- |
| `commons_fetch.py` | Wikimedia Commons: search, exact `File:` titles, or a whole category; writes a provenance `sidecar.json` |
| `page_image_fetch.py` | Resolves a web page's `og:image` and downloads it (Flickr, Commons file pages, photo blogs) |
| [`gen/commons_fetch.py`](gen/commons_fetch.py) | Wikimedia Commons: search, exact `File:` titles, or a whole category; writes a provenance `sidecar.json` |
| [`gen/page_image_fetch.py`](gen/page_image_fetch.py) | Resolves a web page's `og:image` and downloads it (Flickr, Commons file pages, photo blogs) |
| `addon_compat_probe.py` | Verifies MPFB/Blosm can load and generate headless on the pinned Blender |
## Blender content
| Tool | Purpose |
| --- | --- |
| `hero_facade_build.py` | Parametric hero façade; emits a JSON report naming the evidence behind every part |
| `character_anim_build.py` | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. |
| `mpfb_character_export.py` | MPFB human, standard rig, baked skin, FBX export |
| [`blender/hero_facade_build.py`](blender/hero_facade_build.py) | Parametric hero façade; emits a JSON report naming the evidence behind every part |
| [`blender/character_anim_build.py`](blender/character_anim_build.py) | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. |
| [`blender/mpfb_character_export.py`](blender/mpfb_character_export.py) | MPFB human, standard rig, baked skin, FBX export |
## Unreal import and level building
| Tool | Purpose |
| --- | --- |
| `ue_import_site.py` | Imports the six site FBXs into `/Game/Hollywood/Site`, setting collision complexity per mesh |
| `ue_hero_facade_import.py` | Imports the façade and places it on the measured building line |
| `ue_build_demo_map.py` | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start |
| `hero_iterate.sh` | Full hero-facade loop in one command: chmod the Blender files, build the facade, import it, render the capture set, judge the shot and write a comparison plate. `bash Tools/hero_iterate.sh [shot]`. |
| `hero_compare.py` | Side-by-side plate of a reference photograph and a rendered capture, scaled to one height and labelled. `--reference`, `--render`, `--out`. |
| `ue_clear_simple_collision.py` | Removes the FBX import's auto-generated convex hulls from the site and street meshes and sets them to complex-as-simple, so physics bodies are not pushed out of a 279 m hull. |
| `ue_vehicle_probe_map.py` | Copies `ReviewMap` to `VehicleProbe`, optionally adds one demo mesh and the Chaos pawn, and saves it, to bisect which map content throws a physics body into the air. Set `GIGLINE_PROBE_ASSET` to stage one mesh; unset for the control. |
| `ue_build_review_map.py` | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views |
| `ue_create_input_assets.py` | Creates `/Game/Input` actions and the mapping context headlessly |
| `ue_character_anim_import.py` | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. |
| `ue_character_import.py` | Skeletal import plus the material/texture chain Interchange skips |
| [`unreal/ue_import_site.py`](unreal/ue_import_site.py) | Imports the six site FBXs into `/Game/Hollywood/Site`, setting collision complexity per mesh |
| [`unreal/ue_hero_facade_import.py`](unreal/ue_hero_facade_import.py) | Imports the façade and places it on the measured building line |
| [`unreal/ue_build_demo_map.py`](unreal/ue_build_demo_map.py) | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start |
| [`review/hero_iterate.sh`](review/hero_iterate.sh) | Full hero-facade loop in one command: chmod the Blender files, build the facade, import it, render the capture set, judge the shot and write a comparison plate. `bash Tools/hero_iterate.sh [shot]`. |
| [`review/hero_compare.py`](review/hero_compare.py) | Side-by-side plate of a reference photograph and a rendered capture, scaled to one height and labelled. `--reference`, `--render`, `--out`. |
| [`review/e2e_asset_check.py`](review/e2e_asset_check.py) | End-to-end check in one command: Blender renders → both Unreal maps rebuilt → captures for **both** the game map and the staging map → two-model judge → `Screenshots/README.md` regenerated. `--skip-blender`, `--skip-judge`, `--skip-recreate`. Report in `/tmp/gigline/e2e-report.txt`; non-zero exit if any step failed. |
| [`unreal/ue_recreate_maps.py`](unreal/ue_recreate_maps.py) | Deletes `DemoMap` and `ReviewMap` and creates both as empty levels, so a run never inherits an actor from an experiment. Chmods the LFS-read-only `.umap` first and parks the editor on a scratch level when it has the map open. |
| [`unreal/ue_clear_simple_collision.py`](unreal/ue_clear_simple_collision.py) | Removes the FBX import's auto-generated convex hulls from the site and street meshes and sets them to complex-as-simple, so physics bodies are not pushed out of a 279 m hull. |
| [`unreal/ue_vehicle_probe_map.py`](unreal/ue_vehicle_probe_map.py) | Copies `ReviewMap` to `VehicleProbe`, optionally adds one demo mesh and the Chaos pawn, and saves it, to bisect which map content throws a physics body into the air. Set `GIGLINE_PROBE_ASSET` to stage one mesh; unset for the control. |
| [`unreal/ue_build_review_map.py`](unreal/ue_build_review_map.py) | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views |
| [`unreal/ue_create_input_assets.py`](unreal/ue_create_input_assets.py) | Creates `/Game/Input` actions and the mapping context headlessly |
| [`unreal/ue_character_anim_import.py`](unreal/ue_character_anim_import.py) | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. |
| [`unreal/ue_character_import.py`](unreal/ue_character_import.py) | Skeletal import plus the material/texture chain Interchange skips |
## Visual review
| Tool | Purpose |
| --- | --- |
| `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` |
| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Judged by **two models by default** (`--models a,b`), reconciled: agreement carries the verdict, disagreement reports `uncertain` with both reasons. Optional, advisory only — never replaces a human verdict. | `--image` + `--expect`; `--model` for a single judgement |
| `visual_loop.py` | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. |
| `visual_targets.json` | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. |
| [`review/judge_screenshot.py`](review/judge_screenshot.py) | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Judged by **two models by default** (`--models a,b`), reconciled: agreement carries the verdict, disagreement reports `uncertain` with both reasons. Optional, advisory only — never replaces a human verdict. | `--image` + `--expect`; `--model` for a single judgement |
| [`review/visual_loop.py`](review/visual_loop.py) | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. |
| [`review/visual_targets.json`](review/visual_targets.json) | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. |
## Gameplay QA
@@ -85,11 +102,11 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose |
| --- | --- |
| `comfyui_generate.py` | Drives the **local** ComfyUI at `http://127.0.0.1:8188` (Stability Matrix install). `models` lists checkpoints; `txt2img` renders through the HTTP API; **`texture` renders material sheets with the project's documented texture workflows** (see below). Every run writes a provenance sidecar (model, prompt, seed, settings, full graph). Two candidates by default — same prompt and seed, both scored by high-frequency detail, the winner takes `--out`, the loser is kept beside it. Local and human-triggered; no credential. |
| `generate_facade_textures.py` | Procedural, deterministic facade texture maps (ashlar base colour + roughness, stucco, asphalt, concrete) written with a pure-zlib PNG encoder — no model, no network, byte-identical on every run. |
| `generate_character_textures.py` | Procedural character texture maps, same deterministic approach as the facade set. |
| `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) |
| [`gen/comfyui_generate.py`](gen/comfyui_generate.py) | Drives the **local** ComfyUI at `http://127.0.0.1:8188` (Stability Matrix install). `models` lists checkpoints; `txt2img` renders through the HTTP API; **`texture` renders material sheets with the project's documented texture workflows** (see below). Every run writes a provenance sidecar (model, prompt, seed, settings, full graph). Two candidates by default — same prompt and seed, both scored by high-frequency detail, the winner takes `--out`, the loser is kept beside it. Local and human-triggered; no credential. |
| [`gen/generate_facade_textures.py`](gen/generate_facade_textures.py) | Procedural, deterministic facade texture maps (ashlar base colour + roughness, stucco, asphalt, concrete) written with a pure-zlib PNG encoder — no model, no network, byte-identical on every run. |
| [`gen/generate_character_textures.py`](gen/generate_character_textures.py) | Procedural character texture maps, same deterministic approach as the facade set. |
| [`gen/facade_detail_concepts.py`](gen/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. |
| [`review/judge_screenshot.py`](review/judge_screenshot.py) | (see Visual review) |
### Texture generation workflows (from the Stability Matrix install)
@@ -102,7 +119,7 @@ The install documents four verified, core-node graphs in [`Workflows/texture_gen
| `qwen21` | `qwen_image_2.1_int8_convrot.safetensors` | 25 / 1.0 / `euler` `simple` | texture-fix (Qwen 2.1) | material sheets with readable labels/signage, 2K | **Qwen research — non-commercial** |
```bash
python3 Tools/comfyui_generate.py texture \
python3 Tools/gen/comfyui_generate.py texture \
--material "weathered grey limestone ashlar, fine grain" \
--out Art/textures/generated/T_StoneAshlar_AI.png
```
@@ -119,7 +136,7 @@ The default pair is `albedobase` + `krea2` — both commercially usable — gene
`comfyui_generate.py` talks to ComfyUI on `http://127.0.0.1:8188`. That server is **not** started by this repository — it lives in a [Stability Matrix](https://lykos.ai) install and must already be running.
**If the tool reports a connection error, start Stability Matrix and launch ComfyUI**, then re-run. The install is documented in [`/home/zwitschi/StabilityMatrix/Data/README.md`](/home/zwitschi/StabilityMatrix/Data/README.md): package version, launch arguments, and the shared model library (`Models/`, ~80 GB, mapped in through `extra_model_paths.yaml`). Reading that file before writing a prompt is the cheap way to see which checkpoints actually exist — `python3 Tools/comfyui_generate.py models` asks the running server the same question, and it is the authoritative list — the default pair is `juggernautXL_ragnarok.safetensors` and `epicrealismXL_pureFix.safetensors`, both verified installed.
**If the tool reports a connection error, start Stability Matrix and launch ComfyUI**, then re-run. The install is documented in [`/home/zwitschi/StabilityMatrix/Data/README.md`](/home/zwitschi/StabilityMatrix/Data/README.md): package version, launch arguments, and the shared model library (`Models/`, ~80 GB, mapped in through `extra_model_paths.yaml`). Reading that file before writing a prompt is the cheap way to see which checkpoints actually exist — `python3 Tools/gen/comfyui_generate.py models` asks the running server the same question, and it is the authoritative list — the default pair is `juggernautXL_ragnarok.safetensors` and `epicrealismXL_pureFix.safetensors`, both verified installed.
Checkpoints are **not** committed. A generated texture ships only after its model and licence are recorded in [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md); the sidecar carries what that needs.
@@ -127,18 +144,18 @@ Checkpoints are **not** committed. A generated texture ships only after its mode
| 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`. |
| `comfyui_generate.py` | Local ComfyUI driver: list checkpoints, run txt2img through the HTTP API, and write a provenance sidecar (model, prompt, seed, full graph). **Two checkpoints by default**: both are generated from the same prompt and seed, scored by high-frequency detail, and the better one takes `--out` while the loser is kept beside it. Local, human-triggered. | `models`; `txt2img --prompt ... --out <png> [--alt-model ...]` | `python3 Tools/comfyui_generate.py models` |
| `comfyui_character_blueprint.py` | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` |
| `character_match.py` | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` |
| `generate_character_textures.py` | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` |
| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
| `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
| `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
| `blender_screenshots.py` | Renders review screenshots out of a .blend: one per mesh object (`--mode objects`) and four turntable angles (`--mode scene`), framed from the scene's own bounds, into `Screenshots/blender/`. | `--label`, `--mode`, `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/blender_screenshots.py -- --label vehicle --mode both` |
| `build_comparison_md.py` | Regenerates the `Blender vs in-game` section of `Screenshots/README.md` between `<!-- blender-comparison:begin/end -->` markers: in-game captures beside their Blender renders in one table, plus a signoff table with one row per in-game screen. Pairs by name with an alias map for camera-named shots. Run it after every capture or Blender render. | (no arguments) | `python3 Tools/build_comparison_md.py` |
| `shadow_gap.py` | Measures the vertical pixel gap between an object's lowest pixels and the top of its shadow, as a fraction of the object's height, and calls it `contact` or `floating`. Objective replacement for asking a vision model whether something floats. | `--image`, `--object-box`, `--gap-fraction`, `--out` | `python3 Tools/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png --object-box 0.44,0.48,0.60,0.99` |
| [`input/input_emulation.py`](input/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/input_test.py`](input/input_test.py) | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. |
| [`gen/comfyui_generate.py`](gen/comfyui_generate.py) | Local ComfyUI driver: list checkpoints, run txt2img through the HTTP API, and write a provenance sidecar (model, prompt, seed, full graph). **Two checkpoints by default**: both are generated from the same prompt and seed, scored by high-frequency detail, and the better one takes `--out` while the loser is kept beside it. Local, human-triggered. | `models`; `txt2img --prompt ... --out <png> [--alt-model ...]` | `python3 Tools/gen/comfyui_generate.py models` |
| [`blender/comfyui_character_blueprint.py`](blender/comfyui_character_blueprint.py) | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` |
| [`blender/character_match.py`](blender/character_match.py) | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` |
| [`gen/generate_character_textures.py`](gen/generate_character_textures.py) | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` |
| [`blender/vehicle_build.py`](blender/vehicle_build.py) | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
| [`unreal/ue_vehicle_import.py`](unreal/ue_vehicle_import.py) | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
| [`blender/vehicle_from_template.py`](blender/vehicle_from_template.py) | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
| [`blender/blender_screenshots.py`](blender/blender_screenshots.py) | Renders review screenshots out of a .blend: one per mesh object (`--mode objects`) and four turntable angles (`--mode scene`), framed from the scene's own bounds, into `Screenshots/blender/`. | `--label`, `--mode`, `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/blender/blender_screenshots.py -- --label vehicle --mode both` |
| [`review/build_comparison_md.py`](review/build_comparison_md.py) | Regenerates the `Blender vs in-game` section of `Screenshots/README.md` between `<!-- blender-comparison:begin/end -->` markers: in-game captures beside their Blender renders in one table, plus a signoff table with one row per in-game screen. Pairs by name with an alias map for camera-named shots. Run it after every capture or Blender render. | (no arguments) | `python3 Tools/review/build_comparison_md.py` |
| [`review/shadow_gap.py`](review/shadow_gap.py) | Measures the vertical pixel gap between an object's lowest pixels and the top of its shadow, as a fraction of the object's height, and calls it `contact` or `floating`. Objective replacement for asking a vision model whether something floats. | `--image`, `--object-box`, `--gap-fraction`, `--out` | `python3 Tools/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png --object-box 0.44,0.48,0.60,0.99` |
## Tests
@@ -2,7 +2,7 @@
"""Render review screenshots straight out of Blender.
blender -b "Art/blender/vehicle/hot hatch.blend" \
--python Tools/blender_screenshots.py -- \
--python Tools/blender/blender_screenshots.py -- \
--label vehicle --mode both --out Screenshots/blender
Two modes, because two questions get asked about a Blender asset:
@@ -14,7 +14,7 @@ Two modes, because two questions get asked about a Blender asset:
proportion against the in-game shot.
Outputs go beside the in-game captures (`Screenshots/blender/`), one folder layer
below, so `Tools/build_comparison_md.py` can pair them and nothing has to be copied by
below, so `Tools/review/build_comparison_md.py` can pair them and nothing has to be copied by
hand. Every render is framed from the scene's own bounds rather than a hand-placed
camera, so adding geometry does not silently crop the shot.
"""
@@ -9,7 +9,7 @@ re-derived from the numbers below:
GigLine_Walk 30 frames @ 30 fps — one full stride, left and right contact
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_anim_build.py -- \
--python Tools/blender/character_anim_build.py -- \
--out /tmp/gigline/character-anim.fbx \
--report /tmp/gigline/character-anim-report.json
@@ -38,7 +38,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
FPS = 30
@@ -6,7 +6,7 @@ faces for the clothing base, solidify for cloth thickness, and keep each piece a
separate mesh so it can be swapped or re-materialled on its own.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_clothing_build.py -- \
--python Tools/blender/character_clothing_build.py -- \
--out /tmp/gigline/clothing --report /tmp/gigline/clothing.json
Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character
@@ -26,7 +26,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
GARMENTS = (
@@ -8,13 +8,13 @@ slot on a skeletal-mesh import, so the cap rendered with the skin material. A se
object sidesteps that and is what the skill calls for anyway.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_hair_build.py -- \
--python Tools/blender/character_hair_build.py -- \
--out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json
What it builds:
SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet
from Tools/generate_character_textures.py, every vertex weighted
from Tools/gen/generate_character_textures.py, every vertex weighted
to `head` at 1.0 so it follows the head without a second rig.
Cards are built with a taper and a slight outward curl so the silhouette is not a
@@ -35,7 +35,7 @@ import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Evaluate a rendered character against a generated blueprint.
The blueprint comes from `Tools/comfyui_character_blueprint.py`; the render comes from
The blueprint comes from `Tools/blender/comfyui_character_blueprint.py`; the render comes from
the review pipeline. This tool isolates the figure in both, then reports how far the
model has drifted from the intended character:
python3 Tools/character_match.py \
python3 Tools/blender/character_match.py \
--render Screenshots/demo/review-demo-third-person-front.png \
--blueprint Art/characters/blueprints/front.png \
--out /tmp/gigline/character-match.json \
@@ -4,16 +4,16 @@
A "blueprint" here is an art-direction sheet for one character: a front, side and back
view, plus a head detail, generated from a fixed prompt and seed set so the sheet can
be regenerated and compared against later. It is the reference the modelled character
is evaluated against — see `Tools/character_match.py`.
is evaluated against — see `Tools/blender/character_match.py`.
python3 Tools/comfyui_character_blueprint.py \
python3 Tools/blender/comfyui_character_blueprint.py \
--character "GigLine walker" \
--desc "lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers" \
--out Art/characters/blueprints
Writes one PNG per view plus `contact-sheet.png` and `blueprint.json`, which records
the prompt, model, seeds and the ComfyUI prompt ids. Everything is generated locally
by `Tools/comfyui_generate.py`; no network service and no credential is involved.
by `Tools/gen/comfyui_generate.py`; no network service and no credential is involved.
The sheet is generated art, not photography, and it stays out of the reference photo
folders on purpose: AGENTS.md forbids committing reference photography, and a
@@ -27,7 +27,7 @@ import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import comfyui_generate as comfy # noqa: E402
from PIL import Image # noqa: E402
@@ -106,7 +106,7 @@ def main(argv=None):
sheet = contact_sheet(written, os.path.join(args.out, "contact-sheet.png"))
record["contact_sheet"] = sheet
record["note"] = ("generated locally by Tools/comfyui_character_blueprint.py; "
record["note"] = ("generated locally by Tools/blender/comfyui_character_blueprint.py; "
"record the model licence in THIRD_PARTY_NOTICES.md before shipping")
with open(os.path.join(args.out, "blueprint.json"), "w") as handle:
json.dump(record, handle, indent=2)
@@ -1,7 +1,7 @@
# Export the south-side block frontage from the OSM blockout.
#
# blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \
# --python Tools/export_block_frontage.py -- --out /tmp/gigline/frontage
# --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage
#
# The building set is the one verified in
# `Geo/verification/2026-11-06-el-capitan-block.md` ("South frontage, west to
@@ -19,7 +19,7 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
@@ -1,7 +1,7 @@
# Export the two in-scope north-side background buildings from the OSM blockout.
#
# blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \
# --python Tools/export_north_buildings.py -- --out /tmp/gigline/north
# --python Tools/blender/export_north_buildings.py -- --out /tmp/gigline/north
#
# The blockout is already in the shared project frame (metres, X east / Y north /
# Z up, origin Hollywood x Highland), so exporting each object at its own world
@@ -13,7 +13,7 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
@@ -23,9 +23,9 @@ where it resolves them (window proportions, shopfront line); everything the
photographs do not resolve is an assumption and is recorded as one in the part
report, so model and evidence stay separable.
python3 Tools/facade_kit_build.py --out /tmp/gigline/kit \
python3 Tools/blender/facade_kit_build.py --out /tmp/gigline/kit \
--report /tmp/gigline/kit-report.json
blender -b --factory-startup --python Tools/facade_kit_build.py -- --out /tmp/gigline/kit
blender -b --factory-startup --python Tools/blender/facade_kit_build.py -- --out /tmp/gigline/kit
"""
from __future__ import annotations
@@ -38,7 +38,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# --- module ------------------------------------------------------------------
File renamed without changes.
@@ -4,7 +4,7 @@ Builds the north elevation in the project frame (X east, Y north, Z up, metres,
origin Hollywood x Highland) and exports it with the named FBX preset
`ue_metric_v1`, so it imports into Unreal at 1 m = 100 uu.
blender -b --factory-startup --python Tools/hero_facade_build.py -- \
blender -b --factory-startup --python Tools/blender/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
--save-blend Art/blender/hero/hero-facade-2026-11-07.blend \
--report Art/blender/hero/hero-facade-2026-11-07.json
@@ -33,7 +33,7 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
@@ -463,9 +463,9 @@ def seat_on_front(table):
def export_fbx(path):
# Preset lives in Tools/fbx_export_preset.py (ue_metric_v1).
# Preset lives in Tools/blender/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.
# Named export preset "ue_metric_v1" - same settings as Tools/blender/scale_test_export.py.
fbx_preset.export_static(path)
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
@@ -4,7 +4,7 @@
# materializes the v2 skin, bakes its diffuse to a texture, and exports
# FBX with the named export preset "ue_character_v1".
#
# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \
# blender -b --factory-startup --python Tools/blender/mpfb_character_export.py -- \
# --out /tmp/gigline/character.fbx --pose idle \
# --save-blend Art/blender/character/character-<date>.blend
@@ -19,7 +19,7 @@ import sys
import zlib
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
import numpy
@@ -8,9 +8,9 @@ stepped Deco parapet on Lee Drug, and the pilaster-and-fascia rhythm on 6806–6
The trim is built **in world coordinates** in the shared frame (metres, X east,
Y north, Z from the project datum), so the export carries its position and the
Unreal staging step is a single spawn at the origin — the same convention as
`Tools/theatre_trim_build.py` and `Tools/export_block_frontage.py`.
`Tools/blender/theatre_trim_build.py` and `Tools/blender/export_block_frontage.py`.
blender -b --factory-startup --python Tools/retail_frontage_build.py -- \
blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \
--out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
Bounds are **measured**, not assumed: they are the blockout objects' world bounds
@@ -32,7 +32,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import _common # noqa: E402
import fbx_export_preset as fbx_preset # noqa: E402
@@ -8,7 +8,7 @@
# and exports them as one FBX with the named export preset "ue_metric_v1".
#
# Run headless (see Art/blender/README.md for the Flatpak invocation):
# blender -b --factory-startup --python Tools/scale_test_export.py -- \
# blender -b --factory-startup --python Tools/blender/scale_test_export.py -- \
# --out /tmp/gigline/scaletest.fbx --save-blend Art/blender/scaletest/scale-test-<date>.blend
import argparse
@@ -16,7 +16,7 @@ import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
@@ -10,7 +10,7 @@ convention as the block frontage and the north buildings.
The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md`
and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m.
blender -b --factory-startup --python Tools/theatre_trim_build.py -- \
blender -b --factory-startup --python Tools/blender/theatre_trim_build.py -- \
--out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json
Everything the photographs do not resolve at this scale (exact marquee depth,
@@ -27,7 +27,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset as fbx_preset # noqa: E402
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
blender -b --factory-startup --python Tools/vehicle_build.py -- \
blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
--out Art/blender/vehicle/gigline-sedan.blend \
--fbx /tmp/gigline/vehicle/sedan.fbx \
--report /tmp/gigline/vehicle/report.json
@@ -31,7 +31,7 @@ import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset # noqa: E402
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
@@ -2,7 +2,7 @@
"""Turn the provided hot-hatch .blend into UE-ready assets.
blender -b "Art/blender/vehicle/hot hatch.blend" \
--python Tools/vehicle_from_template.py -- \
--python Tools/blender/vehicle_from_template.py -- \
--out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json
Follows two skills:
@@ -28,7 +28,7 @@ import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import fbx_export_preset # noqa: E402
# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall.
@@ -7,10 +7,10 @@ and surface grime. ComfyUI runs locally at `127.0.0.1:8188`, so this is a
no credential is involved.
# what is installed
python3 Tools/comfyui_generate.py models
python3 Tools/gen/comfyui_generate.py models
# one image
python3 Tools/comfyui_generate.py txt2img \
python3 Tools/gen/comfyui_generate.py txt2img \
--prompt "seamless photoreal human skin texture, fine pores, neutral light" \
--negative "text, watermark, hair, clothing" \
--model juggernautXL_ragnarok.safetensors \
@@ -236,7 +236,7 @@ def run_graph(graph, out_path, meta) -> int:
meta = dict(meta)
meta.pop("timeout", None)
meta.pop("poll", None)
meta.update({"tool": "Tools/comfyui_generate.py",
meta.update({"tool": "Tools/gen/comfyui_generate.py",
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"workflow": graph, "comfyui_prompt_id": prompt_id,
"note": "generated locally; record the model licence in "
@@ -304,7 +304,7 @@ def generate(args) -> int:
sidecar = os.path.splitext(args.out)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({
"tool": "Tools/comfyui_generate.py",
"tool": "Tools/gen/comfyui_generate.py",
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"prompt": args.prompt,
"negative": args.negative,
@@ -7,13 +7,13 @@ committed: the output directory is gitignored. The tool records provenance
working copy stays traceable.
# dry run: list what the search returns, download nothing
python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --dry-run
python3 Tools/gen/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --dry-run
# download up to 6 images at 1600 px wide
python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --limit 6
python3 Tools/gen/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --limit 6
# fetch specific files by title
python3 Tools/commons_fetch.py --file "File:Grauman's Chinese Theatre, Hollywood.JPG" --slug north-chinese
python3 Tools/gen/commons_fetch.py --file "File:Grauman's Chinese Theatre, Hollywood.JPG" --slug north-chinese
Defaults: out dir `Art/reference/<slug>/`, 1600 px wide thumbnails, one request
per second, JPEG/PNG only, at least 800 px wide on the original.
@@ -245,7 +245,7 @@ def main() -> int:
"requested_files": args.file,
"slug": args.slug,
"thumb_width": args.width,
"tool": "Tools/commons_fetch.py",
"tool": "Tools/gen/commons_fetch.py",
"note": "local development reference only; not committed to the repository",
"images": records,
}
@@ -10,10 +10,10 @@ 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
python3 Tools/gen/facade_detail_concepts.py --list
python3 Tools/gen/facade_detail_concepts.py --only door-recess,parapet-motto
python3 Tools/gen/facade_detail_concepts.py --all
python3 Tools/gen/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
@@ -45,7 +45,7 @@ import time
import urllib.error
import urllib.request
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
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"
@@ -11,7 +11,7 @@ So skin detail is generated here instead: fine tileable noise with a low-frequen
break-up, which is what the base colour actually needs (pores, not a pattern). Hair
is generated as a strand sheet with alpha, for hair cards.
python3 Tools/generate_character_textures.py --out Art/textures/generated
python3 Tools/gen/generate_character_textures.py --out Art/textures/generated
"""
import argparse
@@ -154,7 +154,7 @@ def main():
path, meta = maker(spec, args.out)
sidecar = os.path.splitext(path)[0] + ".json"
with open(sidecar, "w") as handle:
json.dump({"tool": "Tools/generate_character_textures.py",
json.dump({"tool": "Tools/gen/generate_character_textures.py",
"asset": spec["name"], "size": spec["size"],
"procedural": True, "meta": meta,
"note": "generated, not photographed; no third-party licence"},
@@ -9,9 +9,9 @@ each as base colour plus a roughness map so the specular breaks up.
Standard library only — the PNG encoder is the same pure-zlib writer used for the
MPFB skin, so this runs on the host without numpy or Pillow.
python3 Tools/generate_facade_textures.py # write to /tmp/gigline/textures
python3 Tools/generate_facade_textures.py --size 1024
python3 Tools/generate_facade_textures.py --out Art/blender/textures
python3 Tools/gen/generate_facade_textures.py # write to /tmp/gigline/textures
python3 Tools/gen/generate_facade_textures.py --size 1024
python3 Tools/gen/generate_facade_textures.py --out Art/blender/textures
Output names match the Unreal import destination `/Game/Hollywood/Textures`:
@@ -6,7 +6,7 @@ an API: the page's `og:image` (or `twitter:image`) is the image the site itself
publishes as representative, so it is a defensible pick without scraping every
`<img>` on the page.
python3 Tools/page_image_fetch.py \
python3 Tools/gen/page_image_fetch.py \
--page "https://www.flickr.com/photos/…/in/…" --slug hero-masonic --name entry-01
Writes into `Art/reference/<slug>/` (gitignored) and appends provenance to
@@ -121,7 +121,7 @@ def main():
print(f" saved {filename} ({len(blob)//1024} KB)")
sidecar_path = os.path.join(out_dir, "sidecar-web.json")
data = {"source": "web page og:image", "tool": "Tools/page_image_fetch.py",
data = {"source": "web page og:image", "tool": "Tools/gen/page_image_fetch.py",
"note": "local development reference only; not committed", "images": []}
if os.path.exists(sidecar_path):
try:
@@ -2,7 +2,7 @@
"""Pure-Python uinput driver to inject OS-level keyboard and mouse input.
This is a small, dependency-free input driver used by the demo QA harness
(`Tools/input_test.py`) to prove whether OS input actually reaches the game.
(`Tools/input/input_test.py`) to prove whether OS input actually reaches the game.
It talks to ``/dev/uinput`` directly through ``ctypes``, so it creates a real
virtual input device that the kernel and the desktop session (X11 or Wayland)
treat as genuine hardware. The game cannot tell simulated input from a real
@@ -2,7 +2,7 @@
"""QA harness: inject scripted OS-level keyboard/mouse input at the running game.
This is the tool that proves whether OS input actually reaches the game. It uses
`Tools/input_emulation.py` to create a virtual input device directly on
`Tools/input/input_emulation.py` to create a virtual input device directly on
`/dev/uinput`, so a focused game window receives the injected keys and mouse
movement as genuine hardware input — indistinguishable from a real keyboard.
@@ -11,7 +11,7 @@ Typical workflow
The game must already be running and focused (it will not be launched here; the
user brings up the window). Run this script from a separate terminal:
python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1
python3 Tools/input/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1
It prints a per-step timeline (seconds) to stdout, which you can compare against
the `GigLine IN:` rows in the game's `Saved/Logs`. If the game log shows
@@ -20,9 +20,9 @@ the pawn and the remaining problem is not input delivery.
Usage
-----
python3 Tools/input_test.py # default smoke sequence
python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1
python3 Tools/input_test.py --device /dev/uinput --name "GigLine QA Input"
python3 Tools/input/input_test.py # default smoke sequence
python3 Tools/input/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1
python3 Tools/input/input_test.py --device /dev/uinput --name "GigLine QA Input"
Steps supported in `--sequence` (comma-separated, `name:count`):
move hold W forward (each unit = ~0.5 s)
+1 -1
View File
@@ -2,7 +2,7 @@
# Open the Unreal editor focused on the walkable demo so you can press Play (PIE)
# to compare in-editor input intake vs the packaged standalone build.
#
# Usage: Tools/run_pie.sh
# Usage: Tools/input/run_pie.sh
# Then in the editor: press the green Play button (Alt+P or the PIE toolbar).
set -e
ENGINE="${UE_ENGINE:-/run/media/system/DATA/Engines/UE_5.8.4}"
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Build the Blender-vs-in-game comparison page.
python3 Tools/build_comparison_md.py
python3 Tools/review/build_comparison_md.py
Reads every in-game capture in `Screenshots/demo/` and every Blender render in
`Screenshots/blender/`, pairs them by name, and writes `Screenshots/COMPARISON.md`:
@@ -107,11 +107,11 @@ def build():
lines = [
"### Blender vs in-game",
"",
"Regenerated by `Tools/build_comparison_md.py`; everything between the markers is",
"Regenerated by `Tools/review/build_comparison_md.py`; everything between the markers is",
"rewritten, so edit outside them.",
"",
f"- Captures: `{REVIEW_DIR}/` and `{DEMO_DIR}/` ({len(shots)} files)",
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`",
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender/blender_screenshots.py`",
"",
"Left is what the engine draws, right is what Blender has. A gap between them is the",
"import problem, not a taste problem: scale, material colour, missing parts and split",
+130
View File
@@ -0,0 +1,130 @@
"""End-to-end asset check: Blender render -> Unreal build -> captures -> judge -> README.
python3 Tools/review/e2e_asset_check.py # everything
python3 Tools/review/e2e_asset_check.py --skip-blender # reuse the existing Blender renders
python3 Tools/review/e2e_asset_check.py --skip-judge # no API calls, just the images
The chain, in order, each step failing loudly with its log under /tmp/gigline/:
1. **Blender renders** - `Art/blender/*/` assets rendered by `Tools/blender/blender_screenshots.py`, so the
source the imports came from has an image of its own.
2. **Empty maps** - `Tools/unreal/ue_recreate_maps.py` rebuilds both maps, so a run never inherits an actor
from an experiment. The builders also clear the level they load; this is the belt-and-braces step.
3. **Unreal build** - `Tools/unreal/ue_build_demo_map.py` (game map) and `Tools/unreal/ue_build_review_map.py`
(staging map for individual assets).
4. **Captures** - `Tools/review/render_screenshots.sh`, which renders both maps into `Screenshots/`.
5. **Judge** - every shot in `Tools/review/visual_targets.json` judged by **two models** through
`Tools/review/judge_screenshot.py --shots`, which reconciles agreement and reports disagreement.
6. **Review file** - `Tools/review/build_comparison_md.py` rewrites the Blender-vs-UE block in
`Screenshots/README.md`, then prettier formats it.
The report lands in `/tmp/gigline/e2e-report.txt`; the exit code is non-zero if any step failed.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENGINE = os.environ.get("ENGINE", "/run/media/system/DATA/Engines/UE_5.8.4")
UPROJECT = os.path.join(ROOT, "Game", "GigLine", "GigLine.uproject")
BLENDER = os.environ.get(
"BLENDER_FILES", "/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files")
UNREAL = os.path.join(ENGINE, "Engine", "Binaries", "Linux", "UnrealEditor-Cmd")
REPORT = "/tmp/gigline/e2e-report.txt"
def run(step, cmd, env=None, timeout=None, result_file=None, expect=None):
"""Run one step and record it. A step fails on a non-zero exit or a missing marker."""
started = time.time()
print(f"== {step}")
try:
proc = subprocess.run(cmd, cwd=ROOT, env={**os.environ, **(env or {})},
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return step, False, f"timed out after {timeout}s"
elapsed = time.time() - started
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
return step, False, f"exit {proc.returncode} ({elapsed:.0f}s): {' / '.join(tail)}"
if result_file and expect:
if not os.path.exists(result_file):
return step, False, f"no result file {result_file}"
text = open(result_file, errors="replace").read()
if expect not in text:
return step, False, f"{os.path.basename(result_file)} lacks {expect!r}"
return step, True, f"{elapsed:.0f}s"
def unreal_script(script, timeout=1800):
return [UNREAL, UPROJECT, "-run=pythonscript", f"-script={os.path.join(ROOT, script)}",
"-nullrhi", "-unattended", "-nopause", "-stdout"]
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-blender", action="store_true", help="reuse the Blender renders")
parser.add_argument("--skip-judge", action="store_true", help="no API calls")
parser.add_argument("--skip-recreate", action="store_true",
help="keep the existing maps (the builders still clear their levels)")
args = parser.parse_args()
steps = []
if not args.skip_blender:
steps.append(run("1/6 blender renders",
[os.path.join(BLENDER, "blender", "blender"), "-b", "--factory-startup",
"--python", os.path.join(ROOT, "Tools", "blender", "blender_screenshots.py"),
"--", "--mode", "both", "--out", os.path.join(ROOT, "Screenshots", "blender")],
env={"LD_LIBRARY_PATH": os.path.join(BLENDER, "blender", "lib")},
timeout=1800))
else:
steps.append(("1/6 blender renders", True, "skipped, reusing Screenshots/blender"))
if not args.skip_recreate:
steps.append(run("2/6 empty maps", unreal_script("Tools/unreal/ue_recreate_maps.py"),
timeout=900, result_file="/tmp/gigline/recreate-maps.txt",
expect="VERDICT: PASS"))
else:
steps.append(("2/6 empty maps", True, "skipped"))
steps.append(run("3/6 unreal build: demo map", unreal_script("Tools/unreal/ue_build_demo_map.py"),
timeout=1800, result_file="/tmp/gigline/demo-map.txt", expect="save_map"))
steps.append(run("3/6 unreal build: review map", unreal_script("Tools/unreal/ue_build_review_map.py"),
timeout=1800, result_file="/tmp/ue-review-map-result.txt", expect="save_map"))
steps.append(run("4/6 captures", ["bash", os.path.join(ROOT, "Tools", "review", "render_screenshots.sh")],
timeout=2400))
if not args.skip_judge:
steps.append(run("5/6 judge (two models)",
[sys.executable, os.path.join(ROOT, "Tools", "review", "judge_screenshot.py"), "--shots"],
timeout=1800))
else:
steps.append(("5/6 judge (two models)", True, "skipped"))
steps.append(run("6/6 review file",
[sys.executable, os.path.join(ROOT, "Tools", "review", "build_comparison_md.py")],
timeout=600))
lines = [f"e2e asset check {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}", ""]
ok = True
for step, passed, detail in steps:
lines.append(f"{'PASS' if passed else 'FAIL'} {step}: {detail}")
ok = ok and passed
lines += ["", f"VERDICT: {'PASS' if ok else 'FAIL'}",
"Review: Screenshots/README.md (status table at the top)."]
os.makedirs(os.path.dirname(REPORT), exist_ok=True)
with open(REPORT, "w") as handle:
handle.write("\n".join(lines) + "\n")
print()
print("\n".join(lines))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,6 +1,6 @@
"""Build a side-by-side plate from a reference photograph and a rendered capture.
python3 Tools/hero_compare.py \
python3 Tools/review/hero_compare.py \
--reference Art/reference/hero-masonic/02-masonictemple01.jpg \
--render Screenshots/review/review-hero-facade-front.png \
--out Screenshots/review/hero-compare-front.png
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Full hero-facade iteration: build -> import -> render -> judge -> comparison plate.
#
# bash Tools/hero_iterate.sh [shot]
# bash Tools/review/hero_iterate.sh [shot]
#
# `shot` defaults to review-hero-facade-front. Every step writes to /tmp/gigline/ so a failure can be
# read back: Unreal commandlets swallow print().
@@ -25,7 +25,7 @@ find Art -name '*.blend*' -exec chmod u+w {} \;
step "2/6 build the facade"
LD_LIBRARY_PATH="$BLENDER_FILES/blender/lib" "$BLENDER_FILES/blender/blender" -b --factory-startup \
--python Tools/hero_facade_build.py -- \
--python Tools/blender/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
--save-blend Art/blender/hero/hero-facade-2026-11-07.blend \
--report Art/blender/hero/hero-facade-2026-11-07.json \
@@ -34,24 +34,24 @@ grep -E 'exported FBX' /tmp/gigline/hero-build.log || { echo "no FBX written"; e
step "3/6 import into Unreal"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/ue_hero_facade_import.py" -nullrhi -unattended -nopause -stdout \
-script="$(pwd)/Tools/unreal/ue_hero_facade_import.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-import.log 2>&1
grep -q 'save_map' /tmp/gigline/hero-facade-import.txt || { echo "import failed - see /tmp/gigline/hero-facade-import.txt"; exit 1; }
step "4/6 rebuild DemoMap (materials live in the map builder)"
"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \
-script="$(pwd)/Tools/ue_build_demo_map.py" -nullrhi -unattended -nopause -stdout \
-script="$(pwd)/Tools/unreal/ue_build_demo_map.py" -nullrhi -unattended -nopause -stdout \
> /tmp/gigline/hero-demomap.log 2>&1
grep -q 'save_map' /tmp/gigline/demo-map.txt || { echo "demo map rebuild failed - see /tmp/gigline/demo-map.txt"; exit 1; }
step "5/6 render the capture set"
bash Tools/render_screenshots.sh > /tmp/gigline/hero-render.log 2>&1 || { echo "render failed - see /tmp/gigline/hero-render.log"; exit 1; }
bash Tools/review/render_screenshots.sh > /tmp/gigline/hero-render.log 2>&1 || { echo "render failed - see /tmp/gigline/hero-render.log"; exit 1; }
step "6/6 judge and compare"
python3 Tools/judge_screenshot.py --image "Screenshots/review/${SHOT}.png" \
python3 Tools/review/judge_screenshot.py --image "Screenshots/review/${SHOT}.png" \
--expect "A neoclassical Masonic temple facade: warm yellow-grey sandstone with horizontal ashlar courses, six giant fluted Ionic columns on square pedestals, three tall red vertical banners reading JIMMY KIMMEL LIVE hanging between the columns, dark lattice metal grilles in the window bays behind the colonnade, three recessed dark entrance doorways with steps, above the columns a carved inscription band flanked by two round medallions, topped by a crested parapet." \
| tee /tmp/gigline/hero-judge.txt | tail -2
python3 Tools/hero_compare.py --reference "$REFERENCE" \
python3 Tools/review/hero_compare.py --reference "$REFERENCE" \
--render "Screenshots/review/${SHOT}.png" \
--out "Screenshots/review/hero-compare-${SHOT#review-hero-}.png"
@@ -70,7 +70,7 @@ def combine(judgements):
return {"verdict": verdict, "confidence": confidence, "reason": reason, "models": by_model}
# Project root = parent of the Tools/ directory the script lives in.
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ENV_PATH = os.path.join(PROJECT_ROOT, ".env")
@@ -149,14 +149,14 @@ def judge(api_key, image_path, expect, model, timeout=60):
def parse_shots_from_targets():
"""Read the review shots and their expectations from Tools/visual_targets.json.
"""Read the review shots and their expectations from Tools/review/visual_targets.json.
Parsing the README's tables broke when the README was restructured into per-shot
sections with the comparison table appended: the old parser looked for a table whose
first cell held a backticked `review-` name. The targets file is the same source the
render gate uses, so it is both stable and the thing the expectations are written to.
"""
targets_path = os.path.join(PROJECT_ROOT, "Tools", "visual_targets.json")
targets_path = os.path.join(PROJECT_ROOT, "Tools", "review", "visual_targets.json")
if not os.path.isfile(targets_path):
sys.exit(f"error: no {targets_path}")
import json
@@ -195,7 +195,7 @@ def main():
if args.shots:
shots = parse_shots_from_targets()
if not shots:
sys.exit("error: no shots found in Tools/visual_targets.json")
sys.exit("error: no shots found in Tools/review/visual_targets.json")
n = len(shots)
print(f"Judging {n} screenshots with {' + '.join(models)} ...")
ok = 0
@@ -5,7 +5,7 @@
# angles synchronously via SceneCapture2D render targets, writing PNGs
# directly into <repo>/Screenshots/ (gitignored).
#
# Invoked by Tools/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript).
# Invoked by Tools/review/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript).
# NOTE: no -nullrhi — rendering needs the real Vulkan RHI. In 5.8 the
# commandlet swallows print(); results go to /tmp/ue-screenshots-result.txt.
# Do NOT load_map here: after a map load the commandlet's spawn context dies
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
# Render the demo-map screenshots used for visual review into <repo>/Screenshots/demo/.
#
# Usage: Tools/render_screenshots.sh [--verify]
# Usage: Tools/review/render_screenshots.sh [--verify]
#
# Only the **demo map** is captured: the review staging map exists to hold the
# hero/camera list, not to produce review screenshots. Everything here is what a
# player actually sees when the demo runs.
#
# --verify run Tools/visual_loop.py --verify afterwards and fail if a shot
# --verify run Tools/review/visual_loop.py --verify afterwards and fail if a shot
# does not show the content its expectation describes
#
# The PNGs are committed (see Screenshots/README.md): a reviewer must see exactly
@@ -20,11 +20,13 @@ PROJ="$REPO/Game/GigLine/GigLine.uproject"
VERIFY="no"
[ "${1:-}" = "--verify" ] && VERIFY="yes"
# Tools are grouped by discipline now: Unreal commandlets under Tools/unreal, review helpers under
# Tools/review. The script name alone is no longer enough to find one, so each call names its folder.
run_cmdlet() {
local script="$1" label="$2"
local script="$1" label="$2" folder="${3:-unreal}"
echo "== $label =="
( cd "$ENGINE" && ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
-run=pythonscript -script="$REPO/Tools/$script" \
-run=pythonscript -script="$REPO/Tools/$folder/$script" \
-nullrhi -unattended -nopause -stdout )
}
@@ -107,5 +109,5 @@ fi
if [ "$VERIFY" = "yes" ]; then
echo "== verify: does each shot show what it should? =="
cd "$REPO"
python3 Tools/visual_loop.py --verify
python3 Tools/review/visual_loop.py --verify
fi
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Measure how far an object sits above its own shadow.
python3 Tools/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png \
python3 Tools/review/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png \
--object-box 0.52,0.55,0.60,0.75 --out /tmp/gigline/shadow-gap.json
Why a measurement instead of another opinion: a still cannot distinguish "the object floats"
@@ -13,7 +13,7 @@ engine screenshot ─┘ │
It answers three questions with numbers instead of opinions:
1. **Does the shot show what it was supposed to?** (`--verify`) — every capture
has an expectation in `Tools/visual_targets.json`; a shot that is blank, the
has an expectation in `Tools/review/visual_targets.json`; a shot that is blank, the
wrong size, or wildly unlike its expectation is reported as a failure.
2. **How far is the render from the reference?** (`--compare`) — luminance,
contrast, edge density, palette and 3×3 composition are measured on both and
@@ -26,9 +26,9 @@ Iterations are **not committed**: every artefact goes under
`Art/reference/iterations/<iteration>/`, which is gitignored. Promote a shot into
`Screenshots/` only after the owner confirms it.
python3 Tools/visual_loop.py --verify # gate the current set
python3 Tools/visual_loop.py --compare --iteration 3 # measure vs references
python3 Tools/visual_loop.py --compare --iteration 3 --judge # + vision advisory
python3 Tools/review/visual_loop.py --verify # gate the current set
python3 Tools/review/visual_loop.py --compare --iteration 3 # measure vs references
python3 Tools/review/visual_loop.py --compare --iteration 3 --judge # + vision advisory
Metrics are computed with Pillow (already in requirements-tools.txt); no numpy.
"""
@@ -43,8 +43,8 @@ import sys
from PIL import Image, ImageFilter, ImageStat
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
TARGETS = os.path.join(ROOT, "Tools", "visual_targets.json")
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
TARGETS = os.path.join(ROOT, "Tools", "review", "visual_targets.json")
ITER_ROOT = os.path.join(ROOT, "Art", "reference", "iterations")
# A shot must clear these floors or it is treated as broken, not merely ugly.
File renamed without changes.
+9
View File
@@ -4,6 +4,15 @@ 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
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import unittest
+9
View File
@@ -1,5 +1,14 @@
"""Tests for the pure parts of Tools/character_match.py."""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import unittest
+9
View File
@@ -1,5 +1,14 @@
"""Graph shape for the documented texture workflows (no ComfyUI process involved)."""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import unittest
from comfyui_generate import TEXTURE_PROFILES, texture_workflow
@@ -4,6 +4,15 @@ 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 os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import base64
import json
import os
+9
View File
@@ -5,6 +5,15 @@ The preset module keeps its argument set in plain dicts and builds it in
Blender. Only `export_static`/`export_character` import `bpy`.
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import unittest
+9
View File
@@ -5,6 +5,15 @@ covered here; the end-to-end injection is verified by running the tool (see
Docs/release-checks.md and MEMORY.md).
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import unittest
import input_emulation as ie
+9
View File
@@ -1,5 +1,14 @@
"""Tests for the multi-model reconciliation in judge_screenshot.combine()."""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import unittest
from judge_screenshot import combine
+9
View File
@@ -5,6 +5,15 @@ the API returns, and only the selection/comparison/formatting helpers are
exercised. The live call is verified by running the tool.
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import json
import os
import sys
+9
View File
@@ -4,6 +4,15 @@ The builder itself needs Blender; `bay_centres` does not, so the bay rhythm the
façade depends on is checkable without starting Blender or Unreal.
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import unittest
from Tools._common import bay_centres
+9
View File
@@ -1,5 +1,14 @@
"""Tests for the pure parts of Tools/shadow_gap.py."""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import unittest
+9
View File
@@ -4,6 +4,15 @@ Pure stdlib: these helpers exist to turn two silent failures into loud ones, so
they are tested without starting Unreal.
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import stat
import sys
+11 -2
View File
@@ -6,6 +6,15 @@ Running the importer moved the hero 13 m east and onto the north side of the
boulevard, and nothing complained because a displaced building still renders.
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import unittest
@@ -70,14 +79,14 @@ class SingleSourceOfTruthTest(unittest.TestCase):
"""The import script and the demo builder must not carry their own literals."""
def test_import_script_uses_the_shared_constant(self):
src = open(os.path.join(ROOT, "Tools", "ue_hero_facade_import.py"),
src = open(os.path.join(ROOT, "Tools", "unreal", "ue_hero_facade_import.py"),
encoding="utf-8").read()
self.assertIn("ue_placement.HERO_LOCATION_CM", src)
self.assertNotIn("(-12024.5, -1450.0, 0.0)", src,
"the stale pre-0.1.0 hero position must not come back")
def test_demo_builder_uses_the_shared_constant(self):
src = open(os.path.join(ROOT, "Tools", "ue_build_demo_map.py"),
src = open(os.path.join(ROOT, "Tools", "unreal", "ue_build_demo_map.py"),
encoding="utf-8").read()
self.assertIn("ue_placement.HERO_LOCATION_CM", src)
+9
View File
@@ -5,6 +5,15 @@ numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge
density and contrast; a checkerboard has a lot of both).
"""
import os
import sys
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _TOOLS)
for _sub in ("review", "gen", "unreal", "blender", "input"):
sys.path.insert(0, os.path.join(_TOOLS, _sub))
import os
import sys
import tempfile
@@ -10,7 +10,7 @@ blockout gives: the bay count is `round(width / BAY)` and the storey count comes
from the height above the ground floor. Nothing is hand-placed per building.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
-script=Tools/unreal/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
# or, as a step inside the demo builder:
from ue_apply_facade_kit import apply_to_actor
@@ -25,7 +25,7 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
KIT = "/Game/Hollywood/Kit"
@@ -206,11 +206,11 @@ def apply_to_actor(actor, kit, materials=None, prefix=None):
def main() -> None:
kit = load_kit()
if not kit:
log(f"no kit meshes under {KIT} — run Tools/ue_import_facade_kit.py first")
log(f"no kit meshes under {KIT} — run Tools/unreal/ue_import_facade_kit.py first")
return
log(f"kit meshes: {len(kit)}")
# Site materials built by Tools/ue_build_demo_map.py.
# Site materials built by Tools/unreal/ue_build_demo_map.py.
materials = {}
for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass",
"M_FacadeBanner", "M_Stucco", "M_Concrete"):
@@ -237,7 +237,7 @@ def main() -> None:
# The kit is the fallback for buildings that have nothing but a blockout. These
# two are modelled from their own photograph in 0.1.6
# (Tools/retail_frontage_build.py); pasting the generic kit over them put a
# (Tools/blender/retail_frontage_build.py); pasting the generic kit over them put a
# second, contradicting facade language on the same wall.
MODELLED = ("Frontage_SM_LeeDrug_6800", "Frontage_SM_Hollywood_6806")
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
@@ -7,13 +7,13 @@ Checks that are cheap and catch the failures this project actually had:
because the demo builder deleted and recreated the site materials, orphaning
every reference; the map looked merely "a bit flat".
2. **Placement spans.** Each verified frontage building is probed against
`Tools/ue_placement.py`; a building 13 m off its footprint still renders fine,
`Tools/unreal/ue_placement.py`; a building 13 m off its footprint still renders fine,
which is exactly why it needs a number, not an eye.
3. **No duplicate actor labels.** Two buildings' kit pieces sharing a label made
the outliner ambiguous and hid one of them.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_audit_scene.py -nullrhi -unattended -nopause -stdout
-script=Tools/unreal/ue_audit_scene.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/scene-audit.txt`. Exit code from the commandlet is
always 0, so **read the file** — `visual_loop.py --verify` does not depend on it.
@@ -27,7 +27,7 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_placement # noqa: E402
MAP = "/Game/Hollywood/DemoMap"
@@ -1,11 +1,11 @@
# Build /Game/Hollywood/DemoMap: the walkable demo level.
#
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout
#
# Content this map expects (built by earlier steps):
# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/ue_hero_facade_import.py
# /Game/Hollywood/Site/... Tools/ue_import_site.py
# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/unreal/ue_hero_facade_import.py
# /Game/Hollywood/Site/... Tools/unreal/ue_import_site.py
#
# Map conventions, from Geo/manifests/local-origin.md:
# X east, Y south, Z up, centimetres, origin at Hollywood x Highland.
@@ -22,8 +22,8 @@ import unreal
MAP = "/Game/Hollywood/DemoMap"
# Repository root, so the texture generator can be invoked by path.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_placement # noqa: E402
RESULT = "/tmp/gigline/demo-map.txt"
lines = []
@@ -284,6 +284,25 @@ def ensure_material(mat_name, spec, textures=None):
return mat
def clear_all():
"""Destroy every actor in the level.
The prefix-based clear only removed what this builder knows about, so anything an experiment left behind
survived every rebuild - that is how a stale road mesh and a duplicate vehicle actor both ended up saved
back into the map. Every build now starts from an empty world.
"""
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
label = actor.get_actor_label()
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
_ = label
except Exception: # noqa: BLE001 - a locked actor must not abort the clear
log(f"could not destroy {label}")
return removed
def clear(prefixes=()):
removed = 0
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
@@ -329,14 +348,8 @@ def build():
# Repeat until nothing is left: destroying actors while iterating a snapshot can miss
# some, which is how two Demo_Vehicle_Sedan actors survived into a saved map and the
# scene audit failed on a duplicate label.
removed = 0
for _ in range(4):
pass_removed = clear(("Demo_", "PlayerStart", "Hero_Facade", "Frontage_",
"RetailTrim_", "ShotSequencer"))
removed += pass_removed
if pass_removed == 0:
break
log(f"cleared {removed} previous demo actors")
removed = clear_all()
log(f"cleared {removed} previous actors (whole level, not just known prefixes)")
# --- lighting: fixed daytime ------------------------------------------
# The demo is a fixed daytime scene, not a time-of-day simulation. Three
@@ -448,7 +461,7 @@ def build():
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
log("staged: Hero_Facade at the building line")
else:
log("MISSING: SM_HeroFacade — run Tools/ue_hero_facade_import.py")
log("MISSING: SM_HeroFacade — run Tools/unreal/ue_hero_facade_import.py")
# --- site meshes, if they have been imported --------------------------
# Blender's flat diffuse colours do not survive FBX -> Unreal: Interchange
@@ -637,7 +650,7 @@ def build():
for asset_name, label in FRONTAGE:
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
if mesh is None:
log(f"MISSING frontage {asset_name} — run Tools/ue_import_block_frontage.py")
log(f"MISSING frontage {asset_name} — run Tools/unreal/ue_import_block_frontage.py")
continue
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Frontage_{asset_name}")
if actor:
@@ -660,7 +673,7 @@ def build():
("SM_Retail_6806", "6806-6820 trim")):
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
if mesh is None:
log(f"MISSING trim {asset_name} — run Tools/ue_import_retail_detail.py")
log(f"MISSING trim {asset_name} — run Tools/unreal/ue_import_retail_detail.py")
continue
# RetailTrim_ rather than Frontage_ so the kit assembler leaves it alone.
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"RetailTrim_{asset_name}")
@@ -1,11 +1,11 @@
# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
# map asset. Run before the screenshot pass (Tools/render_screenshots.sh does
# map asset. Run before the screenshot pass (Tools/review/render_screenshots.sh does
# this automatically).
#
# Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character,
# the three 1 m scale cubes, and an AGigLineShotActor with the camera list.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_build_review_map.py \
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/unreal/ue_build_review_map.py \
# -nullrhi -unattended -nopause -stdout
#
# Do NOT load_map here: after a map load the commandlet's spawn context dies
@@ -49,6 +49,23 @@ def ensure_writable(path):
pass
def clear_level():
"""Destroy every actor before staging.
Without this the review map accumulated whatever previous runs and experiments left behind, and the
captures showed a scene nobody staged. The map is rebuilt from empty on every run.
"""
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
except Exception: # noqa: BLE001
pass
log(f"cleared {removed} actors before staging")
return removed
def sit_on_floor(actor, floor_z=2.0):
"""Shift an actor so the mesh's lowest point lands on `floor_z`.
@@ -85,6 +102,10 @@ def spawn(cls, loc, label=None, scale=None, rot=None):
# --- scene -------------------------------------------------------------------
try:
# Start from an empty level: the review map used to keep whatever previous runs left behind, and
# captures showed a scene nobody staged.
clear_level()
# Neutral gray floor material (the scale-test mesh ships red)
floor_mat = None
if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"):
@@ -205,7 +226,7 @@ try:
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
log("staged: hero facade at the measured building line")
else:
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/ue_hero_facade_import.py)")
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/unreal/ue_hero_facade_import.py)")
# --- player pawn ---------------------------------------------------------
# The review rounds must show the pawn the game actually spawns, not only the
@@ -222,7 +243,7 @@ try:
# --- vehicle, from the latest Blender import ------------------------------
# Staged from the same assets the demo map uses, so the review always shows what
# Tools/vehicle_from_template.py most recently produced rather than a copy that can
# Tools/blender/vehicle_from_template.py most recently produced rather than a copy that can
# drift. The panels are plain static meshes because the vehicle pawn keeps its
# physics only.
vehicle_origin = unreal.Vector(-1600.0, 600.0, 0.0)
@@ -417,7 +438,7 @@ try:
unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap")
log(f"demo map staged: {len(demo_shots)} cameras, save {saved}")
else:
log("MISSING: /Game/Hollywood/DemoMap (run Tools/ue_build_demo_map.py)")
log("MISSING: /Game/Hollywood/DemoMap (run Tools/unreal/ue_build_demo_map.py)")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
@@ -1,9 +1,9 @@
# Import the character animation clips into Unreal (0.2.0).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx
# --python Tools/blender/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout
#
# Imports the animated FBX next to the existing character assets and verifies the
# result rather than assuming it: an FBX whose export omitted `bake_anim` imports
@@ -31,7 +31,7 @@ def log(msg):
def main():
if not os.path.exists(SRC):
log(f"missing {SRC}; run Tools/character_anim_build.py first")
log(f"missing {SRC}; run Tools/blender/character_anim_build.py first")
return
task = unreal.AssetImportTask()
@@ -1,9 +1,9 @@
# Import the garment meshes as separate skeletal meshes (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_clothing_build.py -- --out /tmp/gigline/clothing
# --python Tools/blender/character_clothing_build.py -- --out /tmp/gigline/clothing
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
#
# Each garment is its own asset, per the character-artist skill's rule that clothing
# stays separate so it can be swapped or re-materialled. The pawn attaches each one as
@@ -1,9 +1,9 @@
# Import the hair cards as their own skeletal mesh (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
# --python Tools/blender/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
#
# The hair is a separate asset on purpose: Unreal collapses the second material slot
# when hair is joined into the body mesh, so a card system has to be its own object
@@ -1,7 +1,7 @@
# Unreal-side half of the MPFB character acceptance: imports the skeletal FBX
# with the named import preset "ue_character_v1" and verifies the result.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_character_import.py \
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/unreal/ue_character_import.py \
# -nullrhi -unattended -nopause -stdout
import unreal
@@ -1,8 +1,8 @@
# Wire the generated character textures into Unreal materials (0.2.2).
#
# python3 Tools/generate_character_textures.py --out Art/textures/generated
# python3 Tools/gen/generate_character_textures.py --out Art/textures/generated
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_materials.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_character_materials.py -nullrhi -unattended -nopause -stdout
#
# Why new materials instead of editing the imported ones:
#
@@ -1,7 +1,7 @@
"""Remove the auto-generated simple collision from the site meshes, keeping their triangles.
<engine>/UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=<repo>/Tools/ue_clear_simple_collision.py -nullrhi -unattended -nopause -stdout
-script=<repo>/Tools/unreal/ue_clear_simple_collision.py -nullrhi -unattended -nopause -stdout
Why: the FBX import runs with `auto_generate_collision`, which adds ONE convex hull per mesh. For a
279 m terrain or road that hull is a giant slab, and any *physics body* inside it - the Chaos vehicle -
@@ -6,7 +6,7 @@ is swallowed and a failed save reports `False` rather than raising. Both are tra
that cost time in `0.1.x`, so they live here once instead of in each script.
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common
ue_common.make_map_writable("/run/media/.../Content/Hollywood/DemoMap.umap")
@@ -6,7 +6,7 @@
#
# Run headless (imports/dirty packages are saved before exit):
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_create_input_assets.py -nullrhi -unattended -nopause \
# -script=<repo>/Tools/unreal/ue_create_input_assets.py -nullrhi -unattended -nopause \
# -stdout -FullStdOutLogOutput
#
# The result goes to /tmp/gigline/input-assets.txt because 5.8's pythonscript
@@ -2,7 +2,7 @@
# and places it at the measured site position.
#
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout
#
# Placement follows Geo/manifests/local-origin.md and the blockout frame:
# project X = UTM easting - 376515.389 (east +)
@@ -17,7 +17,7 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
import ue_placement # noqa: E402
@@ -27,7 +27,7 @@ ACTOR_LABEL = ue_placement.HERO_ACTOR
LEVEL = "/Game/Hollywood/DemoMap"
# Unreal centimetres: south-east corner of the façade, on the building line.
# Placement lives in Tools/ue_placement.py. It used to be a literal here, and it
# Placement lives in Tools/unreal/ue_placement.py. It used to be a literal here, and it
# drifted from ue_build_demo_map.py: this file kept the pre-0.1.0 position
# (-12024.5, -1450) while the builder staged (-13336, 1307), so running the importer
# on its own moved the hero 13 m east and onto the north side of the boulevard.
@@ -41,7 +41,7 @@ def log(msg):
lines.append(str(msg))
# Generated with Tools/comfyui_generate.py texture (AlbedoBase XL, see the sidecar beside the PNG). The
# Generated with Tools/gen/comfyui_generate.py texture (AlbedoBase XL, see the sidecar beside the PNG). The
# reference facade is coursed rubble-ashlar, not a flat tint, and a texture is what carries that at 45 m.
FACADE_ALBEDO = "Art/textures/generated/T_FacadeAshlar_AI.png"
FACADE_ALBEDO_ASSET = "T_FacadeAshlar_AI"
@@ -1,14 +1,14 @@
# Import the south-side block frontage FBX set into Unreal.
#
# blender ... --python Tools/export_block_frontage.py -- --out /tmp/gigline/frontage
# blender ... --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_import_block_frontage.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_import_block_frontage.py -nullrhi -unattended -nopause -stdout
#
# Each mesh already sits at its world position in the project frame (the FBX was
# exported without moving the object), so the actor is spawned at the origin and
# the geometry carries the offset. Destination: /Game/Hollywood/Frontage/<name>.
#
# Staging into the DemoMap is done by Tools/ue_build_demo_map.py, which also
# Staging into the DemoMap is done by Tools/unreal/ue_build_demo_map.py, which also
# assigns the look materials; this script only produces the assets.
import glob
@@ -73,7 +73,7 @@ def import_fbx(path, name):
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Tools/export_block_frontage.py first")
log(f"no FBX in {SRC}; run Tools/blender/export_block_frontage.py first")
return
for path in files:
name = os.path.splitext(os.path.basename(path))[0]
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Import the façade module kit into Unreal: /Game/Hollywood/Kit/*.
Pairs with `Tools/facade_kit_build.py`. Each piece is imported as its own static
Pairs with `Tools/blender/facade_kit_build.py`. Each piece is imported as its own static
mesh at the module's origin (bottom-front centre), so a building can place a
piece by bay index and storey index with a single offset.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout
-script=Tools/unreal/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout
Result file: /tmp/gigline/kit-import.txt (the commandlet swallows print()).
"""
@@ -18,7 +18,7 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/kit"
@@ -35,7 +35,7 @@ def log(message: str) -> None:
def main() -> None:
if not os.path.isdir(SRC):
log(f"MISSING source directory {SRC} — run Tools/facade_kit_build.py first")
log(f"MISSING source directory {SRC} — run Tools/blender/facade_kit_build.py first")
return
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
@@ -9,14 +9,14 @@
# (0,0,0) like the site meshes, not at the pivot-shifted hero facade position.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/north"
@@ -1,9 +1,9 @@
# Import the retail frontage trim (6800 Lee Drug, 6806–6820) into Unreal.
#
# blender -b --factory-startup --python Tools/retail_frontage_build.py -- \
# blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \
# --out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout
#
# Two meshes arrive, one per building, each already at its world position in the
# project frame (the FBX carries the offset), so staging spawns at the origin.
@@ -76,7 +76,7 @@ def import_fbx(path, name):
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Tools/retail_frontage_build.py first")
log(f"no FBX in {SRC}; run Tools/blender/retail_frontage_build.py first")
return
for path in files:
import_fbx(path, os.path.splitext(os.path.basename(path))[0])
@@ -2,7 +2,7 @@
#
# blender ... --python Art/blender/site/export_site_fbx.py -- --out /tmp/gigline/site
# <engine>/.../UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_import_site.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_import_site.py -nullrhi -unattended -nopause -stdout
#
# Each mesh is a separate FBX because they carry different materials and want
# different collision settings (terrain and street are walkable surfaces).
@@ -9,7 +9,7 @@ boulevard** — and nothing complained, because a displaced building still rende
The values are Unreal centimetres, absolute world space. Every one of them is
verified against `Geo/verification/2026-11-06-el-capitan-block.md` and probed by
`Tools/visual_loop.py --verify` (via the demo screenshot expectations) and by the
`Tools/review/visual_loop.py --verify` (via the demo screenshot expectations) and by the
world-span probes recorded in `MEMORY.md`.
Keep this file boring: constants and one validator, no `unreal` import, so it can
+86
View File
@@ -0,0 +1,86 @@
"""Rebuild both Unreal maps from an empty level, deleting whatever was there before.
<engine>/UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=<repo>/Tools/unreal/ue_recreate_maps.py -nullrhi -unattended -nopause -stdout
Why: `Tools/unreal/ue_build_demo_map.py` and `Tools/unreal/ue_build_review_map.py` load the existing map and destroy the
actors they know about. Anything else that accumulated - an actor from an experiment, a material override, a
stale world setting - survives every rebuild, which is how a stale road mesh and a duplicated vehicle actor
both got saved back into the map. Deleting the `.umap` and creating an empty level makes every run start from
the same place.
The umap files are LFS-tracked and check out read-only, so chmod comes first; and a level cannot be deleted
while it is the one the editor has open, so a scratch level is created to release it.
Result: /tmp/gigline/recreate-maps.txt
"""
import os
import unreal
MAPS = ("/Game/Hollywood/DemoMap", "/Game/Test/ReviewMap")
SCRATCH = "/Game/Test/_RecreateScratch"
RESULT = "/tmp/gigline/recreate-maps.txt"
lines = []
def log(message):
lines.append(str(message))
unreal.log(str(message))
def make_writable(path):
"""A .umap under Content/ that is read-only cannot be deleted; LFS checks it out that way."""
disk = os.path.join(unreal.Paths.project_content_dir(),
path.replace("/Game/", "") + ".umap")
try:
mode = os.stat(disk).st_mode & 0o777
if not mode & 0o200:
os.chmod(disk, 0o644)
log(f"{path}: chmod u+w ({oct(mode)} -> 0o644)")
except FileNotFoundError:
pass
def main():
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
scratch_made = False
for path in MAPS:
make_writable(path)
current = unreal.EditorLevelLibrary.get_editor_world()
current_path = current.get_path_name().split(".")[0] if current else ""
if path == current_path:
# The editor cannot delete the map it has open: park it on a scratch level first.
if not scratch_made:
les.new_level(SCRATCH)
scratch_made = True
else:
les.load_level(SCRATCH)
log(f"{path}: released by switching to {SCRATCH}")
if unreal.EditorAssetLibrary.does_asset_exist(path):
if unreal.EditorAssetLibrary.delete_asset(path):
log(f"{path}: deleted")
else:
log(f"{path}: could not delete")
if les.new_level(path):
log(f"{path}: created empty")
else:
log(f"{path}: FAILED to create")
if unreal.EditorAssetLibrary.does_asset_exist(SCRATCH):
unreal.EditorAssetLibrary.delete_asset(SCRATCH)
ok = all(f"{path}: created empty" in line for path in MAPS for line in lines)
lines.append(f"VERDICT: {'PASS' if ok else 'FAIL'}")
log(f"recreate {'ok' if ok else 'FAILED'}")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as handle:
handle.write("\n".join(lines) + "\n")
@@ -3,7 +3,7 @@
#
# Run headless:
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout
import unreal
@@ -10,7 +10,7 @@ Two steps, both idempotent:
happen — the kit was once placed 12 km away that way.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
-script=Tools/unreal/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/theatre-import.txt`.
"""
@@ -22,7 +22,7 @@ import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/theatre"
@@ -52,7 +52,7 @@ def log(message: str) -> None:
def import_pieces() -> list[str]:
if not os.path.isdir(SRC):
log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first")
log(f"MISSING {SRC} — run Tools/blender/theatre_trim_build.py first")
return []
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
@@ -1,9 +1,9 @@
# Import the sedan systems as separate static meshes (0.3.0).
#
# blender -b --factory-startup --python Tools/vehicle_build.py -- \
# blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
# --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout
#
# One asset per system, per the vehicle-artist skill's object split: body, glass, front
# and rear lights, mirrors, and one wheel reused four times. The body FBX also carries
@@ -1,13 +1,13 @@
# Rebuild the vehicle's materials from the Blender colours (0.3.0).
#
# blender -b "Art/blender/vehicle/hot hatch.blend" \
# --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch
# --python Tools/blender/vehicle_from_template.py -- --out /tmp/gigline/hatch
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_vehicle_materials.py -nullrhi -unattended -nopause -stdout
# -script=<repo>/Tools/unreal/ue_vehicle_materials.py -nullrhi -unattended -nopause -stdout
#
# Why this exists: the template's car paint is a **legacy node graph**, so its colour never
# reached the exported FBX and the car arrived in Unreal near-black while Blender showed it
# red. `Tools/vehicle_from_template.py` now reads those graphs and records the colours; this
# red. `Tools/blender/vehicle_from_template.py` now reads those graphs and records the colours; this
# replaces the imported material assets in place with plain constant-colour materials, so
# the mesh slots pick them up by path without anything being re-imported.
#
@@ -1,7 +1,7 @@
"""Add one piece of demo content to ReviewMap and save it as a vehicle probe level.
GIGLINE_PROBE_ASSET=Terrain_DEM [GIGLINE_PROBE_NOCOLLISION=1] <engine>/UnrealEditor-Cmd <uproject> \
-run=pythonscript -script=<repo>/Tools/ue_vehicle_probe_map.py -nullrhi -unattended -nopause
-run=pythonscript -script=<repo>/Tools/unreal/ue_vehicle_probe_map.py -nullrhi -unattended -nopause
Why: the Chaos pawn falls correctly on ReviewMap's empty floor and is thrown upward everywhere in
DemoMap. Rather than guess which of DemoMap's 593 staged meshes is responsible, add them one group