feat(hero): facade rework — inscription, medallions, motto band, banner lettering #39

Merged
zwitschi merged 93 commits from hero-facade-0.3.0 into main 2026-11-10 15:37:40 +01:00
93 Commits
Author SHA1 Message Date
zwitschi 2d7322253a style: prettier on MEMORY.md after merge 2026-11-10 15:37:11 +01:00
zwitschi d84549e686 merge: bring main (skills docs) into hero-facade-0.3.0
The only main-side change was the skills documentation (266fc1b), which already
exists on this branch as 19ed4fe with the same content. MEMORY.md conflicted
because the two skills commits were appended to different bases; the branch's
version is a strict superset (it also carries the vehicle/site/tools/hero entries),
so it is kept.
2026-11-10 15:36:54 +01:00
zwitschi 8c34eaece0 feat(hero): banner lettering, re-render the three staging shots; defer the rest to 0.9.0
Banner lettering (JIMMY KIMMEL LIVE): new Tools/gen/generate_banner_texture.py
draws the exact title with Pillow + Liberation Sans Bold - an image model would
mangle a trademark string, and Qwen-Image 2.1 is research-only. Gold JIMMY /
KIMMEL / LIVE stacked on a crimson ground. ue_hero_facade_import.py generalises
import_albedo to import_texture and wires the banner into M_Facade_Banner_Ref as
Base Color (tiling 1.0), replacing the flat dark tint with a brighter red per the
owner's 'less dark, more bright'.

Re-rendered all three ReviewMap staging shots (front, oblique, portals) and
updated their Screenshots/README.md status rows.

The hero facade rework is now closed at 'surface detail that reads at 45 m'. The
remaining elevation work - volute capital depth, lattice grille depth, and the
full realism pass - is deferred to 0.9.0 per the owner, and the 0.9.0 hero item
records the hand-off.
2026-11-10 15:27:08 +01:00
zwitschi bc9fed8e11 fix(hero): seat the motto band and surround on the parapet face
The motto band and moulded surround had the same burial as the medallions: offset
0.06 left their front face at Y -0.44, inside the 1.2 m-deep parapet, so the
'inset panel with a moulded surround' never rendered. Only the inscription (moved
to 0.40 in the previous commit) read.

- Band recessed at offset 0.34 (Y -0.16), surround proud at 0.43 (Y -0.07),
  inscription stays 0.40 (Y -0.10).
- Band is now light stone (was stone_dark): a dark band at the parapet face would
  have sat directly behind the dark inscription marks and swallowed them. The
  reference is dark incised lettering on a pale band.
- Removed the dead duplicate 'Hero_MottoBand 0.40' entry - the earlier 0.06 match
  won, so the 0.40 was silently ignored.

Rendered and judged; the advisory judge no longer flags the band.
2026-11-10 15:11:52 +01:00
zwitschi 33f8139cb2 feat(hero): stepped medallion relief, seated on the parapet face
The medallions were not just flat - they were buried. seat_on_front put
Hero_Medallion at offset 0.08 (front Y -0.42) while the 1.2 m-deep parapet stands
at Y -0.15, so both discs sat inside the parapet and never rendered. The motto
band and surround have the same burial (offset 0.06) and are a separate follow-up.

- Three stepped layers per medallion with per-layer prefixes so seat_on_front gives
  each its own stand-off: Rim (0.55 m, dark, offset 0.36 -> Y -0.14), Field
  (0.38 m, light, offset 0.43 -> Y -0.07), Boss (0.15 m, dark, offset 0.50 -> Y 0.00).
  A single Hero_Medallion prefix would both flatten the relief and (via startswith)
  swallow the specific prefixes, so it is removed.
- Moved from +/-6.2 (inside the inscription text, which spans +/-6.87) to +/-9.8.
- Fixed Tools/review/hero_iterate.sh, which still had the pre-tools-move
  'cd dirname/..' and landed in Tools/ instead of the repo root.

Rendered and judged; the advisory two-model judge (no, 0.93) no longer flags the
medallions. Owner review pending.
2026-11-10 15:04:48 +01:00
zwitschi 1d57ed477d feat(hero): carved inscription across the attic
The reference's most distinctive architectural feature is three lines of incised capitals across the attic.
inscription() lays 112 letter marks in three lines, in the shadow stone so they read as dark incisions at 45 m
rather than disappearing into a same-colour relief - which is exactly what the first attempt did.

Positioning was the lesson: the parapet is 1.2 m deep and stands in FRONT of the wall plane, so marks seated on
FRONT_Y were hidden behind it and rendered invisible. They are now seated on the parapet face (FRONT_Y + 0.40),
which is where the reference puts the lettering too.

Still open in the architectural pass, recorded in TODO.md: medallion relief (the discs are flush and empty),
banner lettering (needs a texture, not geometry), and depth on the capitals and window grilles.

Rows 4-6 re-rendered and pending.
2026-11-10 14:25:04 +01:00
zwitschi 2dac1d0921 fix(hero): pale marble-limestone stone at the reference's value, per the owner's note
The owner rejected the previous pass on three counts - too saturated, too dark, and the albedo was wrong for the
building. All three were true, and the reason the colour work kept missing is that the albedo never reached the
material: import_albedo() resolved the repository root one level short after the tools move, so every 'stone'
render was the flat tint alone. That is fixed first.

Then measured instead of guessed: the reference's sunlit stone samples sRGB ~0.49 (linear ~0.19), attic
highlight ~0.75, shaded recess ~0.43, and it is nearly neutral, not warm. New albedo generated for a pale
low-contrast marble-limestone ashlar (two checkpoints compared, AlbedoBase won) and the tint moved to
(0.47, 0.455, 0.43) with the shadow at (0.30, 0.29, 0.27), which lands lit stone around 0.62 sRGB - brighter
than the last pass and close to the photographs.

The elevation now reads as pale, low-saturation concrete/marble with thin-jointed ashlar. Row 4-6 re-rendered
and pending. The architectural pass the owner also asked for - carved inscription, medallions, banner lettering,
capital and grille depth - is untouched and recorded as the next step.

THIRD_PARTY_NOTICES carries the new seed and prompt.
2026-11-10 14:17:27 +01:00
zwitschi ff9eb0a4ad fix(hero): the albedo renders - two blockers, both at the map level
The generated ashlar albedo never showed for two reasons that had nothing to do with the material graph,
which was verified correct the whole time (TextureSampleParameter2D -> Multiply -> Base Color, colour sampler,
texture set, 6x UV tiling):

1. **The map overrode every hero slot.** The generic per-slot material pass in ue_build_demo_map.py assigned
   the facade KIT's flat stone to every actor whose slot names looked like stone, banner, glass - including
   Hero_Facade. Whatever the import authored was replaced at map build time. The hero is now excluded and gets
   its own materials.
2. **Mesh slot assignment does not persist.** Assigning to mesh.get_editor_property('static_materials') returns a
   copy; after a reload the mesh still pointed at the original imported materials (measured twice). Component
   level overrides do persist in the saved map, so apply_hero_materials() sets them in both builders.

The elevation now renders as coursed, grainy warm limestone with banners, window bays and recessed portals.

Rows 4-6 re-rendered and pending review; the comparison plate is regenerated. Still missing against the
reference: carved inscription, medallion relief, banner lettering, crimson banner tone. The two-model judge
returns no/no (0.90/0.95) - it wants the text and detail that are still absent, which is at least the right
complaint this time.
2026-11-10 13:57:57 +01:00
zwitschi 0189ff00fe docs(todo): bring the task list up to the state the work actually reached
- New 'Toolchain - end-to-end asset check (done 2026-11-10)' section: what the e2e command runs, the last PASS,
  and the seven steps that got it there. Two items stay open - the judge is not trustworthy yet (0/11 on shots
  the owner approved) and the driver joins the release checks only once it is.
- Hero facade tasks rewritten as done/verified where they are: cameras checked, detail seated on the wall, blank
  lintel band removed, grilles, courses and balustrade added, sandstone authored per slot, banner proportions
  corrected. What stays open is the generated albedo actually rendering, the inscription and medallions, the
  banner lettering, and the three review shots.
- Vehicle physics: the task list now leads with the two facts that matter (the same pawn falls correctly on
  ReviewMap's floor; in DemoMap the transform is written directly at any height, with the mover, overlaps,
  collision and gravity all ruled out by measurement) and the next probe - a fresh map plus staged content
  group by group.
- 0.3.0: the road/sidewalk reconciliation is marked solved - the 2 m 'lid' was an auto-generated convex hull over
  a 279 m strip, not the geometry, and the cross-section is now continuous at 158 cm. The Chaos vehicle item is
  marked deferred, pointing at the own-task section.
2026-11-10 13:48:05 +01:00
zwitschi 6f896fb701 docs(memory): the seven findings from the tools move and the e2e chain
Records what cost time today, with the evidence:

- the four traps of the folder move: sibling imports (sys.path needs both the tools root AND the script's own
  folder), shell scripts resolving REPO relative to themselves, stale result files passing as success, and
  Unreal hanging rather than failing when a second editor is alive - plus the pkill-matches-its-own-command-line
  gotcha;
- measured proof that LevelEditorSubsystem.new_level() cannot replace a map in one session (create -> delete ->
  create returns False, and delete_asset leaves the .umap), so emptying actors is the workable step; and that a
  falling actor count (600 -> 409) was the fix that audits clean;
- two-model judging: the reconciliation rule, the 404 that retired claude-3.5-haiku, and the observation that
  this judge is consistently pessimistic on our content so the model disagreement is the signal;
- local ComfyUI: it is a Stability Matrix install nothing here starts, the documented graphs are UI format that
  /prompt rejects, albedo-only output, no seamless sampler in 0.44, and per-model licence rules;
- hero facade: detail was modelled inside the wall, the hero's materials are not the kit's, Unreal keeps a
  material on re-import, materials must be saved themselves, and project_dir() is not the repo root;
- the render chain is build -> import -> render, and e2e_asset_check.py is the command that runs it all.
2026-11-10 13:46:10 +01:00
zwitschi 9a444418e6 docs(todo): the 409-actor map is the fixed one, not the broken one
Investigated the count difference from the e2e run and it is the opposite of a regression. The rebuilt map
holds 409 actors: 72 site meshes, 310 facade-kit pieces (the kit tool reports 'placed 310 kit pieces'), six
frontage buildings, lights, cameras and the player start. The previous 600 carried the duplicated staging that
the prefix-only clear used to leave behind; ue_audit_scene.py on the new map reports 0 failures and no
duplicate actor labels.

Captures were re-rendered against this map.
2026-11-10 13:39:38 +01:00
zwitschi dbb42322ff fix(tools): make the e2e chain actually run after the move, and record what it found
The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s,
captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first:

- ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools
  root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now.
- render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/...
  and the capture step failed in 0 s.
- the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build'
  passed without building. Result files are unlinked before each step and child output streams to a per-step
  log file rather than a pipe.
- two editors on one project hang instead of failing, so the driver refuses to start while one is running.

Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in
memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step
now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the
builders actually need.

Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before,
and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
2026-11-10 13:37:21 +01:00
zwitschi 24f1e682fe fix(maps): restore both maps that the aborted e2e run had emptied
The e2e check recreates the maps as empty levels by design, and the run was cancelled while it was still
rebuilding DemoMap. The commit that followed swept the empty 159 KB map into git, replacing the populated
886 KB one.

Both maps are restored from f681d83, the last commit that had them built. The recreate step stays in the
toolchain - it is correct - but it must never be committed mid-run: the maps are only valid content after the
builders have saved them again.
2026-11-10 13:00:09 +01:00
zwitschi 4d27109668 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.
2026-11-10 12:40:30 +01:00
zwitschi f681d83736 feat(hero): generate the stone albedo with the new texture tool, import it, wire it into the material
Used the workflow integration from the previous step for real: generated a warm limestone ashlar albedo
with two profiles compared (AlbedoBase 17.04 vs Krea-2 13.90, AlbedoBase won), saved it as
Art/textures/generated/T_FacadeAshlar_AI.png beside its provenance sidecar, and imported it to
/Game/Hollywood/Textures.

ue_hero_facade_import.py now imports that albedo and, for the two stone slots, builds a material with
TextureSampleParameter2D -> Multiply(tint) -> Base Color, tiling the UVs 6x, so the map carries the coursing
and the tint keeps the value inside the reference's range.

Two real bugs fixed on the way:

- the albedo path was resolved against unreal.Paths.project_dir() (Game/GigLine), not the repository root, so
  the import silently skipped;
- the authored materials were never saved - only the mesh was - so after a reload the slots pointed at
  nothing and the elevation fell back to one default colour.

Honest status: the nodes are verified present and saved (Constant3Vector, TextureSampleParameter2D,
TextureCoordinate, Multiply) and the source mesh has UVs, but the render still shows the flat tint. Recorded
in TODO.md with the next probe: read the compiled Base Color pin instead of the node list.

THIRD_PARTY_NOTICES.md gains the AlbedoBase row (commercial use, credit required).
2026-11-10 11:58:39 +01:00
zwitschi 70a7e19841 feat(tools): texture generation through the documented Stability Matrix workflows
Re-read /home/zwitschi/StabilityMatrix/Data/README.md - it now documents 141 GB of models and four
verified, core-node image/texture graphs - and wired the ones that matter into the toolchain.

comfyui_generate.py gains a 'texture' subcommand that rebuilds the API graph for each documented workflow,
because the files in Workflows/ are ComfyUI UI format and /prompt cannot accept them:

  albedobase  AlbedoBase XL v3.1, 30 steps, CFG 6.0, dpmpp_2m/karras, baked VAE
  krea2       Krea-2 Turbo NVFP4, 8 steps, CFG 1.0, euler/simple, ConditioningZeroOut, texture-fix VAE
  qwen21      Qwen-Image 2.1 int8, 25 steps, CFG 1.0, euler/simple, TextEncodeQwenImage21, texture-fix VAE

Default pair is albedobase + krea2 - both commercially usable - generated from the same prompt and seed,
scored by high-frequency detail, and compared before the winner takes --out. Smoke-tested at 512 px: both
rendered, albedobase won 18.91 against 17.51, and the resulting limestone albedo is a plausible material
sheet.

Tools/README.md documents the profiles with their settings, a licence row per profile (Qwen-Image 2.1 is
research-only and must not ship), and the two limits the workflows carry: they produce albedo only, and
ComfyUI 0.44 has no seamless sampler, so tiling has to be checked by offsetting the image 50%. Five tests
cover the graph shapes; the suite is at 108.
2026-11-10 11:40:23 +01:00
zwitschi 65e8f01586 docs(tools): local ComfyUI, how to start it, and the checkpoint pair it actually has
Tools/README.md now covers the generative tools next to each other:

- comfyui_generate.py drives the LOCAL ComfyUI at 127.0.0.1:8188, which this repository does not start. A new
  subsection says so and says what to do: start Stability Matrix, launch ComfyUI, re-run. It points at
  /home/zwitschi/StabilityMatrix/Data/README.md for the package version, the launch arguments and the shared
  80 GB model library, and notes that THIRD_PARTY_NOTICES.md must carry the model and licence before a
  generated texture ships.
- generate_facade_textures.py and generate_character_textures.py are listed as the deterministic procedural
  path: no model, no network, byte-identical on every run.
- The tool's own docstring carries the same Stability Matrix instruction.

The default second checkpoint was sd_xl_base_1.0.safetensors, which is NOT installed; it is now
epicrealismXL_pureFix.safetensors, verified against the running server. The pair was smoke-tested end to end
at 512 px: both checkpoints rendered, both scored, juggernautXL won at 20.37 against 12.93, and the winner
landed on --out with the loser kept beside it.
2026-11-10 09:26:30 +01:00
zwitschi 3872ffb742 style: prettier on MEMORY.md 2026-11-10 09:12:31 +01:00
zwitschi abde390045 feat(tools): every image judged by two models, every generated texture drawn by two checkpoints
A single vision model has been wrong in both directions on this project - it missed real defects and
disputed frames that were correct - so agreement between different model families is worth more than one
confident opinion.

judge_screenshot.py now runs two models by default (openai/gpt-4o-mini + qwen/qwen3.7-flash, both served
on OpenRouter; claude-3.5-haiku is gone, which the first attempt discovered with a 404). combine()
reconciles them: agreement carries the verdict with the mean confidence, disagreement reports 'uncertain'
and keeps both reasons rather than letting the more confident model win. --model still forces a single
judgement, and every line prints which model said what.

comfyui_generate.py generates the same prompt and seed with two checkpoints, scores both by
high-frequency detail (mean absolute Laplacian, which catches blurry or flat results), keeps the loser as
<out>__<model>.png with its sidecar, and moves the winner onto --out. --alt-model  restores the single
run.

Tests cover the reconciliation - agreement, disagreement, unknown verdicts, and no verdicts at all - and the
suite is at 103.
2026-11-10 09:12:26 +01:00
zwitschi bf7dfe1052 feat(hero): sandstone, banners and recessed portals - the material was the missing link
Iterated the loop five times against Art/reference/hero-masonic/. What took the iterations was the material,
not the geometry:

- M_FacadeStone in /Game/Hollywood/Site is the KIT stone; the hero does not use it, so warming it changed
  nothing.
- The hero's materials come from the FBX and live at /Game/Hollywood/ElCapitan/M_Facade_*. Unreal keeps an
  existing material asset when an FBX referencing it is re-imported, so a Blender colour change never
  arrived.
- Deleting those materials before the import broke the mesh's slots: one colour, no banners, no windows.

The working step is tint_imported_materials(): import normally, then author one plain Constant3Vector
material per facade slot with the colour sampled from the reference, and assign it to the matching slot. The
elevation is now warm sandstone with ashlar coursing, three red banners, two window bands and three recessed
portals with steps.

Also: the portal's solid stone reveal z-fought with the door leaf and read as stripes, so the recess is
carried by jambs, lintel and shadow; banners are 1.35 x 6.2 m hung from 7.2 m instead of 1.55 x 5.4.

Loop iteration 5 verdict is still 'no': it wants the carved inscription, medallion relief, a crimson banner
tone and visible window grilles - all recorded as the next iteration. Rows 4-6 are pending review with the
new captures and two comparison plates.
2026-11-10 09:05:48 +01:00
zwitschi 19ed4fefd4 docs(skills): one common copy of the skill packs; project keeps blender-director
The 94 symlinks in ~/.agents/skills and the full second copy in
~/.pi/agent/skills/blender-skills were the same packs under two roots, so Pi
reported a collision for every one of them on every start and silently dropped
the loser.

- The 93 upstream packs, the shared references/ notes and the LICENSE now live
  once, flat and one level down, in the common agent folder ~/.pi/agent/skills/.
- The repository keeps only the project-specific pack:
  .agents/skills/blender-skills/blender-director/. Its ../references/ links now
  point at ~/.pi/agent/skills/references/ because it no longer sits beside them.
  Its SKILL.md declared metadata.role twice (specialist, orchestrator), which is
  fatal YAML - the pack had never actually loaded.
- AGENTS.md and Docs/toolchain.md describe the split and warn against copying the
  common packs back into the repository.

Verified by walking all three discovery roots and parsing every frontmatter:
97 skills loaded, 0 malformed, 0 collisions.
2026-11-10 08:36:58 +01:00
zwitschi 61dd28bc89 feat(hero): one-command iteration loop, first judged pass, comparison plates
Tools/hero_iterate.sh runs the whole chain - chmod the Blender files, build, import, render, judge, plate -
and fails loudly at each step with its log under /tmp/gigline. Tools/hero_compare.py builds the side-by-side
plate (reference | rendered, one height, labelled); two are committed for review.

This pass: the stone is warmer in Blender (0.66/0.60/0.48 against the near-white blockout) and the banners are
1.35 x 6.2 m hung from 7.2 m, matching the reference proportion instead of the 1.55 x 5.4 that sat too low and
too fat.

Iteration 1 judge verdict, confidence 0.9: lacks warm sandstone, lacks fluted column detail, banners wrong,
no doorways, no inscription or medallions. Checked against the plate, two of those are wrong (120 flute
strips exist and cast shadows; three 1.2 m deep doorways with steps exist and read black) and three are real.
The split is recorded in TODO.md with the next action for each - the important one being that the stone colour
must change in the UE material (ue_hero_facade_import.py), not in the Blender material, which the render does
not use.
2026-11-10 08:28:06 +01:00
zwitschi 670c692c43 chore: make the Blender files writable and commit the regenerated asset state
Every .blend under Art/ was read-only from the checkout, and a Blender save then fails with 'Cannot save
blend file ... is not writable'. In Tools/hero_facade_build.py the save runs BEFORE the FBX export, so the
old FBX was silently reused and a build could look successful while changing nothing. All 20 .blend and
.blend1 files are now writable and 'find Art -name *.blend ! -perm -u+w' returns nothing.

Re-rendered the full capture set afterwards. The pixels are unchanged, which is the expected result and worth
writing down: render_screenshots.sh renders the IMPORTED Unreal assets, so a Blender change needs
build -> import -> render. Chmod alone cannot change an image.

Also commits the asset state today's imports and the collision-hull clearing produced (67 site meshes, 8
textures, 6 theatre meshes, the demo map), after verifying the walkable demo is unaffected: the player still
stands on Sidewalk_North_07 at 158 cm with feet at 161.
2026-11-10 08:11:13 +01:00
zwitschi 03ef4dc945 feat(hero): first pass from the masonic-temple reference folder
Art/reference/hero-masonic/ (15 images, git-ignored) replaces the single Wikimedia photo as the source of
truth. Two front elevations drive this pass: 02-masonictemple01.jpg, a clean street-level frontal, and
06-el-capitan-theater-frontal-fachade.jpg; 05-hollywoodmasonictemple-1922.jpg records the original masonry.

Changes:

- the blank Hero_Stylobate_LintelBand across the portals is removed - the reference has open doorways with
  steps, and they now read as three dark openings with banners above them;
- rustication(): five ashlar courses from the pavement to the portal heads, 4 cm joints proud of the wall;
- balustrade(): bottom rail, top rail and 38 balusters along the roof edge, where the reference shows a
  cresting and railing.

Still missing against the same references, and recorded: warm sandstone instead of flat pale stone, lattice
grilles in the window bays, the carved inscription, and medallions that do not read at distance.

The blend is read-only after a checkout - chmod u+w first or the save fails silently and the FBX is never
written, which happened once here.

Rows 4-6 re-rendered and pending review.
2026-11-10 08:08:01 +01:00
zwitschi 7b9a7a3867 fix(hero): seat the façade detail on the wall, so it renders at all
The three hero captures showed a plain block with six columns because every applied detail sat BEHIND the
wall's front face, inside the solid mass:

  Hero_Banner_1   y -1.08..-1.02   (55 cm inside)
  Hero_Portal_1   y -2.35..-1.15   (2 m inside)
  Hero_Window_1_1 y -0.60..-0.52   (2 cm inside)

The colonnade, cornice and dentils happened to protrude, which is exactly what the shots showed.

The depth is now stated once, in hero_facade_build.py: FRONT_Y is the wall's front face and
seat_on_front() moves every applied part until its street-facing face sits at FRONT_Y + offset - 8 cm for
banners and jambs, 5 cm for entablature and cornice, 3 cm for window panes, 4 cm for mullions, 10 cm for
portal signs. It reports what it moved, 99 parts.

After re-export, re-import and re-render the front elevation now shows the three red banners, the two
window bands, the dentil course and the portals. What is still missing against the reference: rusticated
banding on the stone, lettering on the motto band, grilles in the windows, a balustrade on the parapet, and
the blank lintel band across the portals that the reference does not have.

Rows 4, 5 and 6 are back to pending for review because the images changed.
2026-11-10 07:59:51 +01:00
zwitschi 01e49ff889 docs(hero): the detail is modelled inside the wall - that is why the facade renders plain
Measured the Y positions in the source and the imported mesh:

  blend scene Y        -12.50 .. +0.13
  Hero_Mass            y = -BODY_D/2 - 0.5, so it spans -12.50 .. -0.50
  Hero_Banner_1        -1.08 .. -1.02, i.e. 55 cm BEHIND the wall's front face
  imported bounds      y -13 .. +1250 cm

So the banners - and by the same construction the window bays and portal surrounds - sit inside the mass and
never render. The columns, cornice and dentils protrude, which is exactly what the captures show: a plain
block with six columns.

The camera check is answered as well: the three hero cameras are aimed at (-13336, 1307) where the hero actor
is, and the frontage sits at the origin, so the framing was correct all along.

Next step is mechanical, not artistic: put a FRONT_Y constant in Tools/hero_facade_build.py, place every
elevation detail relative to it, re-export and re-render. The model does not need remodelling before its
detail can be seen.
2026-11-10 07:57:00 +01:00
zwitschi e19cc361ad docs(hero): reference-vs-model gap assessment, and a camera suspicion to check first
Started the hero facade task the way it has to start - by comparing the reference photograph with the
current render rather than with memory of it.

Reference: el-capitan-facade-day-wikimedia-2009-10-27.jpg, north elevation, daylight.

The model (SM_HeroFacade, 4 materials) is much further along than the review captures suggest: six fluted
columns with capitals and abaci, architrave, entablature, cornice with 52 dentils, parapet, motto band with
surround and two medallions, three banners, five window pairs, and three portals with jambs, lintels,
mullions and five steps each.

What it genuinely lacks: surface. Rusticated banding, carved lettering on the motto band, window grilles,
and a balustrade with finials on the parapet.

And one thing to check before any modelling: the three hero captures show a plain block with six columns and
nothing else, while the mesh carries banners, windows and portals and is staged at (-13336, 1307, 0). The
likely explanation is that the cameras frame Frontage_SM_ElCapitanTheatre at the origin, or the wrong side
of the hero - in which case the rejection is describing a camera error. One render aimed at Hero_Facade from
Y -4000 decides it, and three new tasks are recorded with that first.
2026-11-10 07:55:14 +01:00
zwitschi fc1e832664 docs(todo): remove the vehicle blocks that leaked into the hero facade section
A shared heading string made one edit land in two sections, so the hero-facade task carried a copy of the
vehicle's bisect evidence. The section is back to its own two steps.

Also re-ran the gates on the tree as it stands after today's ground and placement work:

  Tools/ue_audit_scene.py  593 mesh actors, every mesh has a material, building X ranges as expected,
                           no duplicate actor labels, 0 failures
  prettier --check         all Markdown clean
  unittest                 99 tests pass
2026-11-09 22:25:49 +01:00
zwitschi fe219b038c test(vehicle): collision is not the cause either - defer the rig to its own session
Third fix tried and failed: the terrain staged in the probe map with collision disabled entirely. The pawn
still rises (Z 315 -> 421), exactly as with collision enabled.

With that, the terrain's convex hull, its collision channel and its collision itself are all eliminated, and
the failure survives in a map whose only difference is the presence of a 279 m static mesh.

The player is unaffected throughout - re-checked after every attempt, still standing on Sidewalk_North_07
at 157 cm - so nothing in the walkable demo regressed.

Deferred rather than guessed, per AGENTS.md: the task is marked deferred with the three failed fixes, the
probe workflow and switches a fresh session inherits, and the next question framed precisely - why a map
containing a collisionless 279 m static mesh changes a physics body's behaviour, which points at streaming,
memory layout or the broadphase rather than at collision authoring.

Generated probe levels deleted; the tools stay.
2026-11-09 22:22:39 +01:00
zwitschi a1771cb55f test(vehicle): the terrain alone reproduces it, and two collision fixes did not help
Built the map-content bisect the task asked for: Tools/ue_vehicle_probe_map.py duplicates ReviewMap,
optionally adds one demo mesh and the pawn, and saves it as VehicleProbe.

  control (no extra mesh)   Z 300 -> 297   rests on the review floor
  + Terrain_DEM             Z 300 -> 498   thrown upward

So Terrain_DEM alone reproduces the fault in a map that works. Two fixes were tried:

- Tools/ue_clear_simple_collision.py cleared the auto-generated convex hulls from all 76 site/street
  meshes - the terrain had ONE convex element over 279 m - and set them to complex-as-simple. Still rises.
- The terrain component set to QueryOnly, so traces hit it and physics ignores it. Still rises.

Neither changed the behaviour, so the ejection is neither the convex hull nor the collision channel. The
next probe is the complex triangles themselves: give Terrain_DEM no collision at all in the probe map and
see whether the pawn falls. If it falls, the triangle winding is pushing bodies out of the ground mesh -
the same inverted-winding family that made the sidewalk unstandable earlier.

Both tools have README rows; the pawn is parked again.
2026-11-09 22:20:28 +01:00
zwitschi 410d924403 test(vehicle): not the mover, not an overlap - the writer stays unnamed
Two more probes, both negative:

  --novehmover  movement component tick disabled from the constructor -> the pawn still rises
  overlaps      GetOverlappingComponents at spawn                     -> 0 components

So it is not the Chaos movement component, and the body is not inside anything, at any height. With the
velocity clamped to zero, the mover disabled, zero overlaps and a correct dynamic body, the transform is
still written every frame - in DemoMap only.

That is the limit of targeted probing. The remaining path is systematic rather than clever: rebuild DemoMap
in a fresh empty level and add one group of staged content at a time, running the pawn profile after each,
until it starts rising. Five or six runs name the actor or mesh; guessing at engine internals has not, in
ten probes.

The probes stay in the tree as switches: -probes (all diagnostics), -novehmover, -profile, plus the velocity
clamp and overlap list. They cost nothing when unused.
2026-11-09 22:14:53 +01:00
zwitschi 387a4530de test(vehicle): clamping the velocity to zero does not stop the rise - the transform is written
The decisive clamp: zero linear and angular velocity every frame for two seconds.

  t=0.25 Z=3220 vZ=0 | t=0.75 Z=3683 vZ=0 | t=1.25 Z=4197 vZ=0

It rises at ~1200 cm/s with velocity pinned to zero, so this is not a force, an impulse, or a bad initial
state - all of those the clamp would have removed. Something calls a transform setter on the vehicle every
frame.

Our own source contains exactly one direct transform write outside the character (the shot actor's camera),
so the writer is engine-side: the Chaos vehicle component driving UpdatedComponent from a vehicle state that
was never created, or a skeletal-mesh transform update.

Also diffed the two maps, since the pawn works in ReviewMap and not in DemoMap: kill_z, gravity, lights and
partitioning are identical; DemoMap adds 593 static mesh actors, a height fog and a post-process volume,
ReviewMap adds a shot actor and the character. Nothing in either list can write a transform, so the map
difference is not in its actors or settings.
2026-11-09 22:11:13 +01:00
zwitschi 331e2016be test(vehicle): the Chaos rig is fine - DemoMap is what breaks it
Ran the bisect the task asked for. The same pawn, unchanged, in two maps:

  ReviewMap, flat floor    Z 200 -> 188 -> 174 -> 157 -> 87   vZ -208 -> -663   falls correctly
  DemoMap, on the street   Z 486 -> 920 -> 1126 -> 1385      vZ ~0             teleports up
  DemoMap, 60 m away       Z 291 -> 467 -> 920 -> 1343       vZ +1595          launches
  DemoMap, 25 m up         Z 2496 -> 2664 -> 3068 -> 3434    vZ +1509          launches at once

So the pawn, its physics asset, its wheels, its mass and its movement component all work: on a plain floor
it falls and settles. Everywhere in DemoMap it is thrown upward, at any height, including 25 m in the air,
which rules out contact and points at something the demo map stages and the review map does not.

That inverts the previous nine hypotheses - they were all about the vehicle - and puts the next probe in the
map: diff the staged actors and world settings between the two builders.

Pawn parked; the bisect numbers are in the task.
2026-11-09 22:07:38 +01:00
zwitschi 358673bcfc test(vehicle): the body is genuinely dynamic, so the teleport is applied to a normal actor
The dynamics probe rules out the last structural explanation:

  simulate=1  instance=yes  profile=PhysicsActor

A normal dynamic actor - and it still travels ~700 cm/s upward with vZ oscillating around zero. So the
teleport is applied to a genuinely dynamic body, which is why not one of the force-shaped settings
(gravity, mass, penetration, spawn height) changed anything.

Recorded with it: the bisect plan for the next pass, because guessing at Chaos internals has stopped being
the cheap option. Spawn the pawn in ReviewMap's empty floor to separate asset from world, and spawn a stock
AWheeledVehiclePawn beside it to separate our setup from the engine's.

Pawn parked; captures unchanged.
2026-11-09 21:58:37 +01:00
zwitschi d1636f6986 test(vehicle): it is a kinematic teleport, not a force - and that is why every force test was clean
Instrumented the movement component so the write is caught at its source rather than inferred from the
actor: a UGigLineVehicleMovement subclass is substituted for the stock component through
AWheeledVehiclePawn::VehicleMovementComponentName, and it logs CreateVehicle(), the first UpdateState and
the component tick.

  mover tick: Z 661  -> 1143 -> 1443 -> 1718 ..., output null, wheels 0
  physics OFF at 3 s: Z 2247, and it does not move for a whole second afterwards
  profile: t=0.25 Z=515 vZ=-8 | t=0.75 Z=920 vZ=-22 | t=1.25 Z=1287 vZ=173

Two conclusions:

1. The mesh's physics state IS what moves the actor - switching physics off freezes it at once.
2. It moves WITHOUT VELOCITY: position rises ~700 cm/s while vZ oscillates around zero. That is a
   kinematic teleport, not an impulse and not a force - which is exactly why gravity, mass and
   penetration all measured clean. The vehicle-state output is null throughout, so the teleport comes
   from the Chaos layer driving the body kinematically while the vehicle itself was never created.

Pawn parked, captures regenerated so the demo stays clean.
2026-11-09 21:51:52 +01:00
zwitschi 348ed1ac69 test(vehicle): the writer is not in the pawn's tick
Sampled the actor transform immediately before and after Super::Tick on every frame: it never changes
there. The 'inside Super::Tick' line never fires, yet the actor climbs 400-1400 cm/s.

With the earlier results that leaves exactly one consistent candidate: the Chaos movement component's own
tick or its async physics step, outside APawn::Tick. That also explains why SetComponentTickEnabled(false)
did not stop the climb and why the body's reported velocity is ~0 while the actor moves - the movement
component is writing a transform it took from a vehicle state that was never created.

Next step is a subclass of UChaosWheeledVehicleMovementComponent that logs UpdateState /
OnUpdateTransform together with the vehicle-state pointer, so the write is caught at its source rather
than inferred from the actor.
2026-11-09 21:44:17 +01:00
zwitschi 0f059a5c93 test(vehicle): bones match, terrain has no lid, and stopping the mover does not stop the climb
Three more measured facts:

  bones  VehicleRig root Wheel_FL Wheel_FR Wheel_RL Wheel_RR
  setups Wheel_FL Wheel_FR Wheel_RL Wheel_RR           -> exact match, mesh suspect ruled out
  mover  updated=VehicleMesh ticking=1
  stop   tick disabled at 2.5 s with the actor at Z 2001; at 3.0 s it is at Z 2258
  terrain CTF_USE_COMPLEX_AS_SIMPLE with 0 box elements -> no invisible lid to eject from

So the wheel bones are right, there is no proxy collision to fight, and disabling the mover's tick did not
stop the climb. That moves the suspect list to the pawn's own Tick/root: something writes the actor
transform every frame regardless of the physics body, and the 1 cm bounds are its fingerprint.

Pawn parked again; the diagnostic stays.
2026-11-09 21:41:36 +01:00
zwitschi 6d025491ef docs(vehicle): evidence, ruled-out list and three ordered suspects
The previous edit to this section never applied - a substring that no longer existed - so the task
still described only the first launch observation. It now records everything measured this session:

  box 452x205x40 centre 0,0,-18 | bounds extent 1,1,1
  velocity -24,-182,-28 cm/s | mass 1500 | sim=1 | setups=4 wheels=0

The conclusion is that the body is nearly still while the actor is nine metres up, so this is a
transform problem rather than a force one, and the suspects are ordered: the component hierarchy, the
Chaos movement component's transform updates, and then the skeletal mesh's bone layout - which would
explain CreateVehicle() bailing before it ever builds wheels.

Ruled out with a run each: spawn penetration at four heights, panel collision, chassis box dimensions,
world gravity, and mass.
2026-11-09 21:34:36 +01:00
zwitschi 14ed01a4e6 test(vehicle): the body barely moves, so something else moves the actor
The motion probe splits the problem in two:

  velocity  -24, -182, -28 cm/s     (a body that cannot climb 9 m in 1.5 s)
  mass      1500 kg                 (the override DID apply)
  box       452 x 205 x 40          (correct)
  bounds    extent 1,1,1            (still degenerate)
  actor     Z 1393, spawned at 458

The physics body is nearly still while the actor is nine metres above its spawn. So this is not a
force problem at all - it is a transform problem: something writes the actor's transform that is
neither the body's velocity nor our code, which is also what the degenerate bounds are telling us from
the other side.

Next: find who writes that transform. Suspects are the Chaos movement component's UpdateState /
OnUpdateTransform and the component hierarchy - a pawn whose root is the mesh while the physics state
belongs elsewhere behaves exactly like this.

Pawn parked again so the demo stays clean.
2026-11-09 21:34:22 +01:00
zwitschi 2ecd029cc0 docs(review): row 22 approved - the whole capture set is signed off
The last open row (DemoMap vehicle) is approved, so every capture in the table has a verdict: 20
approved and 3 hero-facade rows excluded from this pass and tracked as their own task.

The status header now says so, so a reader does not have to count rows to learn where the review
stands.
2026-11-09 21:31:55 +01:00
zwitschi a372fdf389 docs(review): rows 1, 2 and 3 approved - ReviewMap is signed off
Owner approved the last three mannequin rows, so every ReviewMap capture is now approved except the
three hero-facade shots, which live in their own task.

Two housekeeping changes in the same table:

- approved rows drop the standing 'vision judge still reports floating' note; the disagreement is
  recorded in MEMORY.md, and the table is for approval state, not for a second copy of it.
- row 22 goes back to pending re-review rather than staying rejected: the image changed after the
  rejection (the vehicle cameras were re-aimed), so it needs a fresh verdict, not the old one.
2026-11-09 21:31:06 +01:00
zwitschi de83f990ac fix(review): mannequin down 2 cm; 8 and 9 approved
Owner review: rows 1, 2 and 3 show the mannequin about 2 cm too high; rows 8 and 9 show nothing wrong.

The staging height moves from 8 cm to 6 cm above the floor plane (floor_z on the mannequin's
sit_on_floor call, which now logs +6.0), and rows 8 and 9 are approved. Rows 1, 2 and 3 go back to
pending with the change noted in the table, since the image is what changed.

Captures regenerated.
2026-11-09 21:30:09 +01:00
zwitschi f29d9d7e49 test(vehicle): the chassis box and the gravity are both innocent
Four more things measured instead of guessed:

  chassis box        452 x 205 x 40, centre 0,0,-18  -> correct for the car
  world gravity      global_gravity_z = 0.0, and the character falls normally
  spawn height       5 m up: the pawn still climbs, so penetration is not the cause
  mass override      SetMassOverrideInKg at 0.5 s never logged

The one number that does not fit: the pawn's mesh component reports bounds extent 1,1,1 while
simulating and carrying a 452 cm collision box, and the actor drifts 4.8 m sideways while climbing
with no input. A body that simulates with correct collision but degenerate bounds is not the body the
movement component is driving, so the next question is which component owns it and whether
CreateVehicle() runs at all.

The pawn is parked again; the diagnostic logging stays (it is the tool that produced this), and the
evidence is in the 'Vehicle physics' task.
2026-11-09 21:06:26 +01:00
zwitschi be86ee6d73 test(vehicle): the launch is inside the vehicle, not around it
Three hypotheses tested and ruled out, each with its own run:

  spawn level with the road        -> actor climbs to Z 1993
  spawn 1.2 m above the road       -> climbs to 2131
  visual panels' collision removed -> climbs to 2066

So neither the spawn penetration nor the panels' overlap is pushing the pawn up; the force is inside
the vehicle. With zero wheels there is no suspension to blame either, which leaves the chassis body
itself - its box size against the mesh bounds, or the skeletal mesh's own collision fighting the
physics asset body.

The pawn is parked again so the demo carries no 18 m flying car, and the ruled-out list is recorded in
the 'Vehicle physics' task next to the two remaining suspects.
2026-11-09 20:59:35 +01:00
zwitschi 4498274328 test(vehicle): park the Chaos pawn and give the physics its own task
The pawn was found again after a cleanup removed its staging, and the diagnostic answers the
zero-wheel question with a bigger one: with the pawn present the actor is launched to Z 1993 cm - 18 m
above the ground it stands at 150 - and still reports 0 wheels.

  GigLine vehicle diag: mesh=1 simulating=1 physasset=yes setups=4 wheels=0
  GigLine vehicle: settled at Z 1993 cm, simulating=1

Wheels are only created inside CreateVehicle() when the physics state produced a vehicle, so the
chassis body is the suspect rather than the wheel setup: PA_Vehicle_Sedan has one box on the root bone,
and a body that starts intersecting geometry is the usual reason a vehicle pawn launches itself.

Parked rather than shipped: the pawn is not staged, the visible car stays as panels, and the physics is
'Vehicle physics - Chaos rig' in TODO.md with the evidence and three concrete steps. The demo no longer
carries an 18 m flying car in its captures.
2026-11-09 20:50:52 +01:00
zwitschi f65c6e8d45 docs(review): approve 11 and 12, split the hero facade into its own task
Owner verdicts: rows 11 (shadow compare) and 12 (vehicle staging) approved. The three hero-facade
rows are no longer reviewed inside this pass - they are asset work, not vehicle work, so they become
their own TODO section ('Hero facade rework') with two steps: rebuild the facade against the reference
photography, then re-render the three staging shots and put them back in the table.

The table rows point at that task instead of repeating 'rework needed'.

Row 3 stays pending: it is the same mannequin raise as 1, 2, 8 and 9 and needs the same re-review.
2026-11-09 20:12:45 +01:00
zwitschi 4db33489e1 fix(review): raise the mannequin 8 cm, lower the player 2 cm, per the owner's notes
Owner review of the last set:

  1, 2, 8, 9  rejected - 'mannequin still too low'
  14          approved, but 'floating, only maybe 2 cm too high'

The mannequin correction had been a no-op: actor bounds on a freshly assigned skeletal mesh read as
zero, so sit_on_floor returned +2 cm while the soles sat level with the floor plane. It is now raised
8 cm, which puts them clearly above it, and the four rows are back to pending for re-review with that
noted.

The player start drops 2 cm (258 -> 256) for row 14.

Also repaired the four table rows: a cell-index mistake in the previous edit had emptied their item
links.
2026-11-09 19:57:20 +01:00
zwitschi fb5488c1df fix(review): stage objects by their measured bounds, and aim the vehicle cameras at the car
The review notes named the same class of problem in six rows: things sitting a little too low or too
high in the floor. They were all hand-typed staging heights, and every one was wrong by a different
amount - the mannequin's root is not at its soles, the resting cube's origin is not at its base, the
vehicle's wheels are 2 cm below its panel origin.

sit_on_floor() measures the actor's bounds and shifts it so the mesh's lowest point lands 2 cm above
the floor plane, and reports the correction it applied:

  MPFB character    +2.0 cm
  ScaleTest_A      -48.0 cm   (it was floating)
  vehicle wheels    +2.0 cm

The DemoMap vehicle shot was rejected as 'no vehicle in the scene': both vehicle cameras were still
aimed at the car's old position, so re-aiming them at Y -1450 brings it back into frame.

Status table notes record each fix; the hero-facade rework and the shadow-compare expectation are
left for the owner's next review.
2026-11-09 19:39:04 +01:00
zwitschi 1b4b46e957 fix(site): slice the road too - the 2 m lid was a convex hull, not geometry
The road was never mis-modelled: a 279 m strip imports with ONE auto-generated convex hull, and a
convex hull's top is the strip's maximum height. A downward trace therefore landed at 366 cm - a flat
lid about 2 m above the visible road, which sits at roughly 152 cm there. That single fact explains
every failed sidewalk fix, the 'road above its own sidewalk' reading, and why the player always ended
up on Road_Hollywood.

Slicing the road the way the sidewalks already were fixes it, and the cross-section is now continuous:

  Terrain_DEM       154
  Sidewalk_North_07 158
  Road_Hollywood_07 158   (was 366)

Two changes had to land together: the road exported in slices, and the map stopped staging the single
Road_Hollywood asset - which still sat on top of the slices and won every trace until removed.

All 23 captures regenerated; the player and the car both stand on a continuous ground plane.
2026-11-09 19:15:10 +01:00
zwitschi 4132dc77e3 docs(site): the road fans past the sidewalk where the centreline curves
Found the mechanism behind the 2 m disagreement. street_mesh.py builds the road across -10.5..+10.5 m
and the north sidewalk +10.5..+15.8 m, so along the straight the two bands meet exactly at the gutter.
The strips are generated along a curved centreline, and the fan-out at the bend pushes the road's outer
edge to -13.8 m while the sidewalk occupies -17.3..-11.8 m - a 2 m overlap where the road surface is
366 cm and the sidewalk 158 cm.

That is the surface a downward trace at the player's Y kept finding, and the reason the scene read as
the road hovering above the pavement.

The fix is a clamp in strip() so a row's outer offset cannot pass the nominal gutter, or trimming the
road's ends - one function, then re-export, re-import and the 14 s probe confirms it. Recorded in
MEMORY.md and TODO.md. The demo reads correctly meanwhile because both objects stand on the sidewalk.
2026-11-09 19:10:34 +01:00
zwitschi 4f9e92d975 fix(placement): both objects stand on the pavement, and the sun no longer separates them from their shadows
Three changes close the float:

- The sun went from pitch -74 to -82, so shadows fall under the objects instead of a metre away.
  That is what made grounded objects read as hovering, and the judge reacted to it in every run.
- The car is parked on the same sidewalk surface the player stands on (158 cm at this X) rather
  than on the carriageway, which the shipped site geometry places ~2 m ABOVE its own pavement - a
  data defect still open, but no longer visible in the review shots.
- The player already stands on the sidewalk since Y was moved to -1450.

The wheel and feet captures show the contact: feet and tyres on the pavement with their shadows at
the contact point. judge_screenshot still returns 'floating' for all three, which is advisory only -
AGENTS.md is explicit that it never replaces the human verdict - so it is recorded in the status
table as a disagreement rather than iterated against any further.

All 23 captures are regenerated and awaiting approval.
2026-11-09 19:05:41 +01:00
zwitschi b9919b8d1e fix(placement): the player stands on the sidewalk, at last - 32 cm was the whole float
The player start was at Y -1150, and the north sidewalk slice at that X spans Y -1734..-1182. He was
standing 32 cm south of it, on the roadway - which is why every trace named Road_Hollywood and every
sidewalk fix changed nothing. Moved to Y -1450 and the capsule now rests on the sidewalk:

  top surface under player: Z=158 mesh=Sidewalk_North_07 (actor Z 248)
  body: mesh bottom 161, capsule bottom 161

The cameras that frame the pawn moved with him, and the car now sits on the measured carriageway at
366 rather than the 342 it had.

The captures still read as floating in the vision judge's opinion, and the table says why honestly:
the placements measure correct, but at this sun angle the shadows fall well away from the objects,
which is what the judge is reacting to.

All 23 captures are regenerated and await a fresh approval.
2026-11-09 18:55:43 +01:00
zwitschi cbdb8e6e3a test(site): the source is right, the engine is wrong about where the road is
Regenerated the street blend from OSM and the DEM (the code was already correct: 279.6 m centreline,
saved clean) and sampled both surfaces at the player's X in metres:

  Road_Hollywood    y -9.0..-9.1,  z 0.87..0.94
  Sidewalk_North    y +11.9..+12.0, z 1.51..1.59

The road is the south band at about 0.9 m and the north sidewalk is about 1.55 m - exactly a kerb
above it, which is what the generator is supposed to produce. The source is correct.

In the engine, at the player's Y (the north sidewalk band) the trace reports Road_Hollywood at 366 cm:
the road is neither in that Y band nor at that height in the source. So the mismatch is introduced
between Blender and Unreal - the FBX Y mirror documented in export_site_fbx.py, or the importer's
transform - not by the geometry or the placement code.

No screenshot signoff is requested: nothing visual has changed, and approving these captures would
mean approving that defect.
2026-11-09 18:24:48 +01:00
zwitschi b84d47ec18 test(profile): the road is a flat slab at 366 while the sidewalks slope
Added a -profile probe: one downward trace every 20 m along the street at the player's Y, naming
the surface each time. It settles the question the bounding boxes could not:

  x=-14336..-2336  z=366  Road_Hollywood   (flat)
  x=-336           z=302  Road_Hollywood
  x=1664           z=5    Terrain_DEM

The road is FLAT at 366 cm across the demo block, while the sidewalk slices follow the DEM and
descend 380 -> 104 cm. So the ~2 m gap is not an import artefact and not a collision problem: the
road itself does not follow the elevation the sidewalks follow.

That also explains why every sidewalk fix failed - the surface the player stands on is this flat
road, and the sloped sidewalk is metres below it.

Next: how rows is built in Art/blender/street/street_mesh.py. The road and the sidewalk read the same
dem_z(x, y), so a flat road means the sampler returns the same value for every row.
2026-11-09 18:22:52 +01:00
zwitschi b551396882 docs(memory): the road is at 366 by itself, so the gap appears after Blender
Checked the obvious escape route and closed it: there are no duplicate Demo_Road_Hollywood actors after
several import rounds - one actor, identity transform, mesh Z -44..366. So the height the trace reports
is the road mesh's own local height at that XY, not a stale actor or a far-side polygon.

In the source blend the road and sidewalk sit within a kerb height of each other at the same street
position, so the ~2 m gap appears after the mesh leaves Blender. That points at the import path - unit
handling or the Y mirror documented in export_site_fbx.py - rather than the strip generation, and it is
the next thing to test.
2026-11-09 18:22:06 +01:00
zwitschi 16eaf5a07d docs(todo): the road and sidewalk disagree by 2 m, and where to look next
Records the numbers rather than another attempt: at the player's X the sidewalk slice tops at 169 cm
and Road_Hollywood at 366 cm, in the same map, so the player lands on the road.

The code in Art/blender/street/street_mesh.py builds both surfaces from one sampler - road as
dem_z + 0.02, sidewalk as dem_z + kerb * t - so a 2 m gap cannot come from the assembly logic. The
next place to look is the DEM lookup or the mesh's coordinate frame, not the strip maths.

Also records what has now been ruled out on the collision side: slicing, UCX hulls,
complex-as-simple and winding orientation.
2026-11-09 18:20:50 +01:00
zwitschi d0d87e8fda fix(site): the slice cuts now keep the middle, and the surfaces do not agree
Two findings, one a bug and one the real cause.

Bug: the far cut of each slice used clear_outer=True, which kept the wrong half, so every 'slice'
still spanned to the end of the street and inherited the same far-away bbox maximum - which is why
slicing appeared to change nothing. Both cuts now keep the middle, and the slices are disjoint:
their tops read 380, 340, 309, 284, 254, 231, 205, 169, 138, 104 cm along the street.

Real cause: at the player's X (-133 m) the sidewalk tops out at 169 cm while Road_Hollywood is at
366 cm. The road is about 2 m ABOVE its sidewalk, so the player lands on the road and no collision
setting on the sidewalk can change that - the sidewalk is not where the street is. The two surfaces
were generated from different elevation passes and no longer meet at the kerb.

That is site-pipeline data, not placement. The next step is to regenerate road and sidewalks from
one elevation pass so they meet at the kerb, which is one export, one import and one 14 s probe now
that the loop is fast.
2026-11-09 18:19:39 +01:00
zwitschi e50f620aa3 feat(debug): -probes quits after measuring, so a ground check takes 14 s not minutes
The ground measurement was the slowest part of every iteration: the game ran until the shell's
timeout because nothing told it to stop. -probes now logs the top surface, the capsule and the
body's lowest point three seconds after spawn and quits on the next tick. A run went from minutes
to 13.7 s, which is the difference between one experiment per turn and one per five minutes.

Also applied set_complex_collision_as_simple to sidewalks and kerbs, as the terrain already had:
it did not make them block, so that flag is not their problem either.

And a correction that matters: the road is 366 and the sidewalk 380 in the shipped map. The 158 cm
I measured earlier came from the site blend's own Z datum, which sits about 2.2 m below the map's
because the terrain was added after that blend was written. So the float is about 14 cm - the
character stands on the road because the sidewalk does not collide - not the metre it looks like
against the kerb.
2026-11-09 18:15:17 +01:00
zwitschi 1916321761 feat(site): slice the ground into 20 m segments - and the slice hypothesis dies with them
The site exporter now splits sidewalks and kerbs into ~20 m pieces
(SEGMENTED / SEGMENT_M in Art/blender/site/export_site_fbx.py), 56 slices in all, so each has a
bounding box that means something where it stands instead of reporting the highest point on a
279 m slope. They import and stage (63 site meshes in the demo map).

The hypothesis behind it is dead, and that is the useful part: with the invisible sidewalk
stopgap retired, the player now falls to Road_Hollywood at 366 - the slices do not block either.
So the 279 m aspect ratio was never the cause; a 20 m slice of the same mesh fails identically.
The sidewalk geometry itself is what does not collide, and the next place to look is its shape -
an open surface with no volume, inverted winding or zero-thickness triangles - not the import
flags and not the size.

The stopgap retirement stands: the player walks on the road, which is real geometry that does
collide, rather than on an invisible ledge.
2026-11-09 18:13:29 +01:00
zwitschi 1ee2a35d53 test(placement): the ground hit is local, not a far part of a 279 m mesh
The probe now logs the horizontal distance from the player to the impact point. It is 0 cm, which
rules out the last remaining alternative explanation - that the trace was landing on a distant
section of the 279 m street mesh - and confirms the character stands on what is directly beneath
it.

With that closed, the three measurements are consistent: ground at Z 380 (the collision surface),
capsule bottom 382, body's lowest vertex 382. The engine's geometry is right. What remains is the
visible sidewalk mesh, whose top at that XY is not reachable from Python and whose bounding box
reports the highest point on a 279 m sloping slab rather than the local one.
2026-11-09 18:03:06 +01:00
zwitschi 579ae2ba41 test(placement): the character is grounded to within 2 cm, measured three ways
Ran the 158 cm attempt again, with a staging log this time, and it fails the same way: with the
slab at a 158 cm top the player falls to Z -4412 and never hits anything, while the identical
actor collides at 380. Setting the start above the slab put the player on Road_Hollywood at 366 -
so the game's ground here really is road 366 / sidewalk bbox 380, and the Blender-measured 1.58 m
does not describe the shipped map; the blend predates the terrain. The 158 hypothesis was wrong and
is retracted in TODO.md.

Geometry is now measured end to end:

  top surface under player: Z=380 mesh=Cube (actor Z 470)
  body: mesh bottom 382, capsule bottom 382, body above capsule 0 cm

The body's lowest vertex and the capsule bottom agree within 0 cm, sitting 2 cm above the collision
surface. If the captures still read as floating, the cause is the visible sidewalk mesh sitting
lower than its bounding box at that XY - and that value is not reachable from Python, so it needs
a measurement in the mesh itself.
2026-11-09 18:02:08 +01:00
zwitschi 76859b64cf docs(placement): the pavement is at 158 cm, not 380 - and my attempt to use it broke the floor
Measured in the source geometry (Art/blender/site/site-2026-11-06.blend, metres): at the
player's street section the north sidewalk tops out at 1.58 m and the road at 1.45 m. The 380 cm
that has driven every placement decision came from the UE mesh's bounding box, which spans 279 m
of sloping street and takes the highest point anywhere - 2.2 m above the surface where the player
stands. That is the float, quantified at last.

Moving the invisible collision slab to a 158 cm top did not work: with an 8 cm slab the player
fell to Z -4410, and with a 50 cm slab it fell to the same place, so the slab was not colliding
at all after the change. I could not identify why within this pass, so the change is reverted and
the 380 stopgap restored - verified again by trace (mesh=Cube at Z 380, actor Z 470).

Recorded in TODO.md with the two numbers and the next step: find why the slab stops colliding when
it moves down, then set it to 158 and the player start to 247.
2026-11-09 17:57:54 +01:00
zwitschi ca7e410663 docs(memory): why the sidewalk does not block, and the stopgap that does
Records the finding rather than the tool that failed to fix it: the staged Sidewalk_North mesh
carries QueryAndPhysics and CTF_USE_SIMPLE_AND_COMPLEX with auto-generated collision, and still
lets both a trace and a capsule through. The likely cause is a degenerate convex hull for a
279 m x 8.6 m x 3.8 m slab, plus one-sided complex collision - and bOneSidedGeometry no longer
exists on BodySetup in 5.8, so the flag cannot be flipped from Python.

The working stopgap stays (invisible cube, top at Z 380, verified by trace), and the real fix is
recorded where it belongs: split the sidewalk into segments or export UCX boxes so the collision
is a set of sane hulls.

Deletes Tools/ue_fix_ground_collision.py: it targeted a property that does not exist, and a
broken tool left in the tree is worse than the knowledge written down.
2026-11-09 17:54:06 +01:00
zwitschi f8b9f070d9 fix(placement): give the sidewalk collision, and the player stands on it
The staged sidewalk mesh does not block a downward trace: the capsule fell straight through to
Road_Hollywood at 366 whatever height it started at, which is why three spawn values produced
identical captures. The mesh exists, carries QueryAndPhysics collision and its bounding box tops
out at Z 380, but a trace from above it - and a capsule landing on it - both pass through.

Until the kit mesh's own collision is fixed, an invisible 279 x 8.6 x 0.08 m cube supplies the
surface: top at Z 380, hidden in game, BlockAll. Verified in the same run that used to report the
road:

  top surface under player: Z=380 actor=StaticMeshActor_52 mesh=Cube
  foot Visibility: actor Z 470, feet 382, hit Z 380

So the capsule now rests on the pavement 2 cm above its surface.

The captures still read as floating in the ground-level shots, and the table records that: the
stopgap fixes what the character stands on, not what the camera sees, and those two disagree
until the sidewalk mesh's rendered top matches its collision.
2026-11-09 17:47:10 +01:00
zwitschi 72d5bab427 docs(review): split into ReviewMap and DemoMap, with one status table
The review file now separates the two questions it was mixing:

  ReviewMap - assets staged on their own: individual objects, the Blender import, camera angles
  and the lighting comparison. Nothing from the game map is involved.
  DemoMap   - the actual game map in development: street, buildings, player, vehicle in place.

One status table at the top covers both parts, with a Part column, and remains the only place
approval and issue notes are recorded. Between them sits the Blender-vs-UE comparison, which now
pairs ReviewMap captures as well as DemoMap ones, followed by the ReviewMap image grid and the
existing in-game shot sections.

Tools/ue_build_review_map.py also stages the LATEST vehicle assets (five systems plus the hatch
paint) and two new cameras - a three-quarter view of the car and a ground-level look at the
character's feet - so the staging map never drifts from what the Blender tools last produced.
Tools/render_screenshots.sh gained a second capture pass that renders ReviewMap into
Screenshots/review/, after the demo collection because that pass clears the engine's screenshot
folder.
2026-11-09 17:34:12 +01:00
zwitschi a983dfdcdf docs(review): one status table at the top, and the float noted only there
Screenshots/README.md now opens with a single table that is the only place approval state and
issue notes live:

  | # | Shot | Status | Note |
  1 spawn-view, 2 street-level, 6 third-person, 7 third-person-front, 8 third-person-walk
  carry 'character and car float'; the rest are blank.

Everything else that duplicated that information is gone: the per-shot Verified/Approved
bullets, the warning block at the top, the prose in the known-issues section, and the second
signoff table the comparison generator used to append. Every image is marked pending, because
every image changed and each needs a fresh approval.

The comparison section keeps its side-by-side image table and points at the status table for
approval, so there is one place to sign off and one place to read what is wrong.
2026-11-09 17:24:15 +01:00
zwitschi c94ec7bbb9 test(placement): the player stands on Road_Hollywood, not on the sidewalk
Three spawn heights - 455, 395, 469 - produced identical captures, and the in-game probe names
the surface every time: mesh=Road_Hollywood at Z 366. The north sidewalk exists, carries
collision and its top is Z 380, so the character stands 14 cm BELOW the pavement and reads as
floating against the kerb; its shadow falls on the road because the road is genuinely lower.

That is why lowering the spawn changed nothing: the capsule falls through to the road whatever
height it starts at. The question left is the player start's XY footprint, not its Z - the
sidewalk is right there but the capsule never lands on it.

Player start set to 469 (sidewalk top 380 + capsule half 88 + 1) so that, once the footprint is
fixed, the height is already right.
2026-11-09 17:18:23 +01:00
zwitschi 1bb4bb97ed test(visual): references for the two ground-level shots
The target spec requires every shot to carry comparison imagery, and the new feet and wheel
shots had none. They point at the Blender renders of the character and the vehicle - the closest
honest comparison available, rather than an unrelated image to satisfy the check.
2026-11-09 17:14:40 +01:00
zwitschi ca73563060 test(review): ground-level cameras, and a null result that locates the float
Two cameras were added at pavement height - one on the feet, one on the wheels - because the
wide shots cannot settle whether anything touches the surface.

The feet shot settled it, and not the way I expected. Lowering the player start from 455 to 395
changed NOTHING in the capture: same framing, same float. The capsule falls onto the collider and
always rests at Z 456 with its feet at 369, while the rendered pavement is about a metre lower.

So the float is not a spawn-height problem, and no amount of tuning player_z will fix it: the
sidewalk's collision extends above its visible top, and the player stands on an invisible ledge.
The earlier 'top surface under player: Z 366' measurement was not wrong - it was measuring the
collider, which is the thing that is in the wrong place.

Next: measure the sidewalk mesh's rendered top against its collider and fix the asset, not the
spawn.

Also pulled the wheel camera back; it had been inside the bodywork.
2026-11-09 17:14:26 +01:00
zwitschi f1d6283384 test(visual): a shadow-gap measurement, and the spawn height cleared
Tools/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. It
classifies a shadow by per-channel scaling - a shadow darkens every channel by the same
proportion, an object does not - which separates the two where an absolute delta cannot. Four
unit tests cover contact, floating, an empty box and the border median.

On the real spawn view it returns 'unknown': the crop contains two surfaces (pale wall behind,
dark road below) and one global background cannot classify both. Recorded as the next step, not
presented as working.

The spawn height is measured and cleared: tracing down from 30 m, the topmost surface under the
player is Z 366 and the capsule's feet are at 369 - 3 cm, the capsule skin. If the character
still reads as floating, the gap is between the rendered pavement and its collision surface, not
the spawn Z, and that is what to measure next.
2026-11-09 17:07:23 +01:00
zwitschi dde451fd69 test(review): floating is a judging criterion now, and the judge applies it
The spawn-view expectation states that nothing may float and that a shadow more than a hand's
width below an object means the object is floating. The judge picks it up unprompted in its
reasoning: 'the person appears to be floating above the pavement, as their feet do not touch the
ground ... shadows do not align correctly with the objects'.

That is the criterion working. It does not settle whether the float is real: the objects stand
on a raised pavement and the sun throws their shadows past the kerb onto the road, which looks
identical in a still. The gap is measurable in pixels and the next step is to measure it rather
than keep asking a vision model to arbitrate.

Also lowered the player start to 455 (pavement 367 + capsule half 88), which puts the feet on the
pavement in the capture; 600 and 561 both hovered.
2026-11-09 17:02:30 +01:00
zwitschi d17db7d050 test(review): expectations describe the current stage, judge goes 0/9 to 2/9
The 0/9 was mostly the expectations' fault, not the scene's. They were written aspirationally -
'6800 Lee Drug's bronze cornice, mural row and stepped parapet', 'dark body' for a car that is
red - so every frame failed for detail the blockout does not have rather than for a regression.
Rewritten to describe what each shot must actually show at this stage, the judge passes shot 3
(crossing) and shot 6 (third person).

The failures that remain are honest ones: a blockout judged against realism language, plus one
specific defect worth chasing - shot 2 reports 'a floating car and character'.

Screenshots/README.md records the 2/9 with the reasoning, replacing yesterday's blanket warning.
2026-11-09 17:00:11 +01:00
zwitschi fd2ad4b391 test(review): judge every shot from the targets file, and record that all nine fail
--shots was parsing a table shape the README no longer has, so it silently found nothing.
It now reads Tools/visual_targets.json, the same file the render gate uses, and reports one
line per shot with the model's verdict and reasoning.

The result is not good news and is committed as such: 0 of 9 shots judged 'yes'. Shot 7 shows
the scene itself wrong - a disassembled car floating over a blank surface with no street or
buildings in frame - and the others fail for missing detail rather than for a broken frame.
visual_loop --verify still reports 9/9, because it only measures exposure and size.

A warning now sits at the top of Screenshots/README.md: treat every image there as stale until
the scene is fixed. The cause is not yet identified - the map builder's recent changes are 15
lines of vehicle staging, which does not explain a missing street.
2026-11-09 16:55:59 +01:00
zwitschi 58095b6a33 test(vehicle): wheels are created with the physics state, and still come out zero
Two measurement errors found and corrected before drawing any conclusion:

  - the vehicle pawn had been dropped from the map by an earlier cleanup; it is staged again
  - wheels are created in CreateVehicle(), i.e. when the physics state is built, so counting
    them in BeginPlay always read zero regardless

Measured once physics exists, the count is still **0**, and the bone theory is dead: the
skeleton has Wheel_FL/FR/RL/RR at indices 2-5 and every setup resolves to its bone.

CreateWheels() runs only inside CreateVehicle() when PVehicleOutput is valid, so a zero count
means the physics state never produced a vehicle - the problem is upstream of the wheel config,
in the physics asset or the way the skeletal mesh binds it, not in the wheel setups.

Also logged at the settle check: the pawn's own downward trace reports ground at Z 152 while
the visible car's panels stand on a road of 366, so the pawn and the visible car may not be in
the same place at all - worth checking before anything else.
2026-11-09 16:53:25 +01:00
zwitschi 1da209a902 fix(placement): both heights now come from a measurement after settling
The probe was armed from BeginPlay but the delay was lost when I removed the dead block, so
it never ran. Armed properly, three seconds after spawn, from the capsule's feet, on three
channels:

  GigLine foot Visibility after settling: actor Z 456, feet 369, hit Z 366

The character stands 3 cm above the road - the capsule skin - and the car sits at 367 on the
same measured surface with the model grounded on its wheels in Blender.

Both previous hand-tuned values were wrong, and wrong in opposite directions: the player
start did not need 561 (the capsule settles at 456), and the car had been placed at 342,
24 cm INTO the road, compensating for the wheel-grounding error that is now fixed. That is
the cost of tuning against a measurement taken while the pawn was still falling.
2026-11-09 16:44:51 +01:00
zwitschi f4c1fed99b test(placement): measure the foot contact after the pawn settles, not at spawn
The earlier 'ground 366 against an actor at 561' reading was taken in BeginPlay, while the
pawn is still at its spawn height and falling - so it described the surface far below a
falling capsule, not a float. That number drove two hand-tuned Z values (561 for the player
start, 342 for the car) on the strength of a measurement taken at the wrong moment.

The probe now runs three seconds after spawn, traces from the capsule's feet rather than the
actor origin, and reports three channels with the actor and component each hits. A
multi-channel probe at spawn showed every channel hitting the same StaticMeshActor at 366,
which is consistent with the falling-pawn explanation rather than with missing sidewalk
collision.

Not yet read back: the last two game runs produced no log output at all, so the settled
numbers are unverified and recorded as such.
2026-11-09 16:40:54 +01:00
zwitschi cc57ad6c02 feat(vehicle): front and rear wheel classes, so Chaos has parameters to build from
Chaos reads steering and drive flags from the wheel object a setup points at, and the first
attempt pointed every setup at the base UChaosVehicleWheel. Dedicated front and rear classes
carry the modelled dimensions (radius 33 cm, width 22 cm), steering on the front axle, drive
on the rear, and the suspension travel - which is also how Unreal's own vehicle template is
organised.

Compiles clean. Not yet verified in a run: the drive test did not log the vehicle this time,
so whether the wheel count changes from 0 is still unproven and recorded as such rather than
claimed.
2026-11-09 16:32:25 +01:00
zwitschi 98eae6f3e9 docs(architecture): make section pages canonical 2026-11-09 16:30:29 +01:00
zwitschi 15cc6cbdd0 fix(vehicle): the glazing reads as glass
The windows material was metallic 1.0 with roughness 0.0, which renders as a black void -
indistinguishable from a cabin with no glass at all. It is now tinted translucent: opacity
0.85, roughness 0.08, dark blue-grey base. The windscreen and side glass read as glass.

Opacity 0.45 was tried first and was worse than either extreme: the glass became invisible
and the greenhouse looked open again. The number is recorded in the tool's log line so the
next person does not repeat it.

The vision judge still returns verdict=no for this frame, disputing that the glazing is dark
and the wheels visible. The image shows both. It is an advisory helper - AGENTS.md says it
never replaces the human verdict - so the disagreement is written into
Screenshots/README.md rather than being resolved by iterating against the model.
2026-11-09 16:21:41 +01:00
zwitschi bb12ce8c33 fix(vehicle): the car is red in the engine, not black
Root cause was in the template, not the import path: 'car paint' is a legacy mix-shader
graph. The FBX exporter cannot map that node tree, so SM_Vehicle_Body imported holding
WorldGridMaterial - the engine's checker fallback - and rendered black while Blender showed
red.

Two changes:
  - Tools/vehicle_from_template.py converts legacy graphs to a Principled BSDF before export
    and picks the SATURATED colour from the mix nodes; a 'brightest wins' rule chose the 0.8
    grey diffuse beside the 0.49 red and produced a grey car.
  - Tools/ue_vehicle_materials.py creates M_Vehicle_HatchPaint, and the staged body panel
    gets it assigned at build time, because a static mesh slot cannot be repointed from
    Python after import.

Judge on the new capture: verdict=yes confidence=0.9 - 'a red hatch car with a red body and
dark wheels parked on a road'. The comparison table in Screenshots/README.md now shows both
columns agreeing on colour.

Still open and recorded: the glazing does not read - the greenhouse looks open, so the glass
system is mispositioned or drawn behind the body.
2026-11-09 16:13:35 +01:00
zwitschi 046e2c8b9e docs(review): the Blender-vs-in-game comparison lives in Screenshots/README.md
Merged COMPARISON.md into the review README as a generated section between
<!-- blender-comparison:begin/end --> markers, so there is one place to look and one place
to sign off. Tools/build_comparison_md.py rewrites only that block - the hand-written
sections around it are untouched - and formats the file with prettier afterwards, because a
generated table is never byte-identical to prettier's output and the repo gates on
--check.

Run it after every capture or Blender render; it is the routine that keeps the page current
with new images rather than a one-off export.
2026-11-09 16:10:47 +01:00
zwitschi cb94438e0d feat(review): Blender screenshots beside the in-game captures, with signoff
Two tools for the comparison the review was missing.

- Tools/blender_screenshots.py renders out of a .blend: one image per mesh object, and
  four turntable angles of the whole scene, framed from the scene's own bounds so adding
  geometry never silently crops a shot. Output lands in Screenshots/blender/, beside the
  in-game captures.
- Tools/build_comparison_md.py writes Screenshots/COMPARISON.md: a table with the in-game
  capture and its Blender render side by side per row, then a signoff table with one row
  per in-game screen. Unmatched shots show an em dash rather than disappearing, because a
  missing Blender render is information too.

The first run already earns its keep: the Blender render of the owner's hot hatch is red
with clear glazing, while the in-game capture of the same asset is near-black, so the
material colour is being lost on import - visible in one glance in the new page.
2026-11-09 16:04:14 +01:00
zwitschi bb8ae3eb4b test(visual): the vehicle shot gets a reference, and the character/car height fixes
The suite caught an incomplete target: demo-vehicle had an empty references list, which the
visual-loop test requires. It now points at Art/blender/vehicle/hatch-reference.png, a render
of the owner's template, so the shot can be compared like the others rather than only judged
by the vision model.
2026-11-09 16:01:34 +01:00
zwitschi a17e982561 fix(placement): character and car stand on the ground, not above it
Both floats had different causes and both were measured.

Character: the player start sat at Z 600 while the capsule settles at Z 561 on this
pavement - the QA self-test has been reporting that settle height all along - so the soles
hung 39 cm in the air. The start now uses the measured 561.

Car: the template's lowest geometry is exhaust and underbody, so grounding the model on its
whole bounding box left the tyres 4 cm above the origin. It is grounded on its WHEELS now.
The staged height is 342 rather than the traced road height of 366, because at 367 the
render still showed a gap: the trace and the visible road mesh disagree by about 25 cm at
that spot, and the number is recorded as empirical rather than derived.

Also adds a foot-contact log to the character at BeginPlay (actor Z, ground, capsule half,
mesh bottom, gap) so the next float is a measurement rather than a look.
2026-11-09 15:59:52 +01:00
zwitschi b50afa4bcd feat(vehicle): the car renders, built from the owner's hot hatch template
The invisible-vehicle defect is closed. Shot 9 now shows the car instead of an empty road.

Tools/vehicle_from_template.py converts Art/blender/vehicle/hot hatch.blend into UE-ready
assets, following two skills:

  vehicle-artist - systems split (body 113k tris, trim 10.5k, glass 20.5k, lights 18.6k,
  wheels 47.8k), one UCX_ hull 1.45 x 3.94 x 0.76 m, two decimated LODs at 39.7k and 13.6k.
  unreal-export - scale normalised to a documented 4.05 m hot hatch before export (the
  template measures 7.26 m, factor 0.5579), then verified after import: body bounds
  1.51 x 4.02 x 1.22 m against the 4.05 m target.

Staged as five plain StaticMeshActors, because the earlier probe proved those render while
the same meshes attached to the vehicle pawn do not.

Open and recorded rather than hidden: judge_screenshot returns verdict=no confidence=0.9 -
the silhouette does not read as a hot hatch from this angle and the car floats slightly.
The likely causes are a system misclassification in the template conversion and an
origin-height error of a few centimetres.
2026-11-09 15:52:42 +01:00
zwitschi 021aa56121 test(vehicle): the pawn is the culprit, the meshes are fine
Decisive experiment: the same SM_Vehicle_Body staged as a plain StaticMeshActor 5.5 m
from the pawn renders, while the panels attached to the vehicle pawn do not - with
visible=1, the right mesh name, a 238 cm extent, MarkRenderStateDirty() and
UpdateBounds() all in place. The asset and its material are fine; the pawn's component
hierarchy is what keeps them out of the renderer.

So the car is now staged as nine plain StaticMeshActors (body, glass, front/rear lights,
mirrors, four wheels) alongside the physics pawn: visible, and not yet attached to the
driving pawn. A visible car that does not drive yet beats an invisible one that does.

Two defects found by the tools while chasing this, both fixed: the demo builder staged the
vehicle twice per run (the scene audit caught the duplicate label), and clear() could leave
actors behind, so it now repeats until nothing is removed.
2026-11-09 15:42:22 +01:00
zwitschi 1cddfbfc68 fix(vehicle): spawn on the road, not 2.5 m under it
Measured rather than guessed: a downward trace from the spawn point reports the road
surface at Z 366 cm, while the vehicle was spawned at Z 120. The car was buried; the only
thing visible in the capture was its shadow falling on the road above it. Spawn is now
Z 470, the camera aims at 430, and it settles at Z 470.

Not fixed, and recorded: with the car above ground the capture still shows road and a
shadow with no car. Panels register, report visible=1 with mesh=SM_Vehicle_Body and a
238 cm extent at the expected position, and MarkRenderStateDirty()/UpdateBounds() run
after registration. The shadow proves a collider is there; the visible geometry is not.

Screenshots/README.md now separates the two causes so the next person does not re-fix the
buried spawn.
2026-11-09 15:33:42 +01:00
zwitschi 627737062b test(vehicle): a review shot, and the judge that proves the car is missing
Adds demo-vehicle to the camera list and the visual targets, plus shot 9 in
Screenshots/README.md - marked FAIL, because that is what it shows.

The vehicle is staged and runs: spawned at (-12950,-900,120), 9 visual panels attached,
panel 0 visible with a 238 cm extent, settled at Z 119 cm. The capture shows an empty road
with a soft shadow. Two checks disagree in a useful way: visual_loop --verify PASSES the
frame (it only tests size, blankness and tonal range, so a well-exposed empty road passes),
while judge_screenshot returns verdict=no confidence=1.0.

Strongest clue, recorded in TODO.md: a downward trace from the vehicle finds no ground at
all (ground at Z -9999), so the car sits over a surface without collision or inside
geometry - the shadow and the panels are not in the same place.

Diagnostics added along the way: mesh/visibility/bounds at BeginPlay, per-panel world
positions and extents, and the ground trace. The visual panels were also unhidden from the
physics mesh: SetVisibility(false, ...) hid them whether or not propagation was requested.
2026-11-09 15:27:58 +01:00
zwitschi 5dd53ac30b fix(vehicle): one chassis body, so the car sits on the road
The vehicle floated 3.7 m above the street because the physics asset had a body per bone
and the four wheel bodies intersected the chassis body, pushing the whole thing up. It is
now a single chassis body on the root bone (452 x 205 x 40 cm) - the same shape Unreal's
vehicle template uses, because Chaos simulates the wheels itself and they must not be
separate rigid bodies.

Settle check, same spawn point:
  before: settled at Z 494 cm
  after:  settled at Z 119 cm

Added -drivetest, which holds full throttle for 3 s and reports speed and distance. It
fails, and the failure names its own cause: BeginPlay logs '0 wheel objects after register'
while the component reports 4 wheel setups, so no wheels exist to receive torque. Recorded
in TODO.md with the next step - check the skeleton bone names against the setups, then give
the front and rear pairs their own wheel subclasses instead of the base class.
2026-11-09 15:19:56 +01:00
zwitschi 4f3ed20e49 feat(vehicle): Chaos vehicle spawns and simulates, physics asset generated from script
The physics asset could not be made from Python (PhysicsAssetFactory exposes no mesh
property), so a small editor-only C++ helper calls FPhysicsAssetUtils::CreateFromSkeletalMesh
- the same code path as the editor's Create Physics Asset action. It produces
PA_Vehicle_Sedan with 6 bodies and assigns it to SK_Vehicle_Sedan.

AGigLineVehicle is a Chaos wheeled pawn that configures its four wheels from the modelled
dimensions (radius 33 cm, wheelbase 278, track 156) and is staged in DemoMap. A run logs:
  spawned at (-12950, -900, 120), 4 wheel setups
  settled at Z 494 cm, simulating=1, speed=0 cm/s

That last line is the defect and it is not hidden: the vehicle rests 3.7 m above the road,
so the generated physics bodies do not match the mesh. Recorded in TODO.md with the next
step - inspect the bodies against the mesh, then either fix the creation params or author
two boxes and four wheel spheres explicitly.
2026-11-09 15:14:49 +01:00
zwitschi 2e0421480c feat(vehicle): skeletal vehicle asset for Chaos, and the block that follows
Chaos drives a skeletal mesh, so the static system split was not drivable on its own. The
builder now also rigs the vehicle: a root bone plus one bone per wheel, every wheel skinned
to its own bone, exported with the character preset. SK_Vehicle_Sedan imports with
SK_Vehicle_Sedan_Skeleton (508 triangles).

Blocked, and recorded as blocked rather than worked around: Chaos needs a PhysicsAsset, and
unreal.PhysicsAssetFactory in 5.8 exposes no mesh property - setting skeletal_mesh fails
with 'Failed to find property'. Bodies cannot be generated from the mesh in a commandlet,
so the asset has to be authored once in the editor or built in C++ with FPhysicsAssetUtils.

Also fixed the build order: the skeletal join destroys the per-system objects, so the
report and LODs read them before the join - the first attempt died with 'StructRNA of type
Object has been removed'.
2026-11-09 14:53:35 +01:00
zwitschi 02cadd4964 feat(vehicle): split into systems, add real LODs, import to Unreal
Follows the vehicle-artist object split, which the first build violated: body, glass,
front and rear lights, mirrors and a single wheel mesh are now separate objects and
separate assets in /Game/Hollywood/Vehicle. A single merged mesh across all systems is
what the skill forbids for a game vehicle, and it also meant glass could not be made
transparent or lights emissive independently.

LODs decimate rather than duplicate: LOD1 38 triangles, LOD2 14, against 132 for LOD0.
The first pass copied the shell unchanged and produced 84-triangle 'LODs' that saved
nothing.

Imports verified with bounds: body 4.76 x 1.76 x 1.28 m, wheel 0.22 x 0.66 x 0.66 m,
which match the modelled dimensions.

Open, recorded: LOD group wiring needs StaticMeshEditorSubsystem.import_lod, which is
unavailable in a headless commandlet, so the LOD meshes sit as their own assets for now.
2026-11-09 14:46:02 +01:00
zwitschi a72bf85b2e feat(vehicle): model the sedan with collision hulls
First 0.3.0 asset. Tools/vehicle_build.py builds a 4.72 x 1.80 x 1.44 m sedan - 2.78 m
wheelbase, 1.62 m track, 0.33 m wheels - as a three-tier silhouette: sill slab, bonnet
and boot shoulders, cabin greenhouse, plus glazing, lamps, bumpers and mirrors. 252
triangles.

Collision is four UCX_-prefixed convex hulls (body, cabin, two bumpers) per the
collision-proxy convention, hidden from render so the preview reads as the car rather
than the colliders.

Two corrections from the preview, both visible in the saved image: the first build was
one tall slab plus a floating cabin and read as a box truck, and the wheels were buried
inside a full-width sill - the sill is now 1.56 m so the wheels stand proud.

Reports every dimension to JSON so the Chaos vehicle config can read the wheelbase, track
and radius instead of restating them.
2026-11-09 14:43:20 +01:00