Commit Graph
96 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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
zwitschi 82cafc2f29 fix(camera): prove the boom shortens, and record how to repeat it
With the spring arm activated the collision now works: a 2 m wall spawned 4 m behind the
pawn shortens the boom from 340 cm to 288 cm - 12 cm clear of the wall, matching the probe
size. -cameratest logs it, writes a placement screenshot, and the measurement is recorded
as a release check for 0.2.3.

Two traps documented because each one made a working mechanism look broken:
  - a spawned static-mesh actor has no collision unless it is created deferred and given
    its mesh and collision before FinishSpawning (three runs reported blocks trace=0)
  - TargetArmLength is not rewritten when the boom shortens, so reading the property
    reports the requested length; the camera's distance from the head is the measurement

Writes the 0.2.3 changelog entry and prunes the section from TODO.md.
2026-11-09 14:40:18 +01:00
zwitschi 3e4dbd1014 fix(camera): activate the spring arm with the third-person rig
Real defect, found by reading the code rather than by the harness: the spring arm was
created with SetAutoActivate(false), and ToggleCamera only activated the camera. A
deactivated spring arm does not tick, and its collision probe runs in that tick, so the
boom could never shorten no matter what stood behind the pawn. ToggleCamera and BeginPlay
now activate the arm alongside the camera.

The harness that was meant to prove the behaviour is still not working, and that is
recorded rather than glossed: -cameratest spawns a wall behind the pawn, but neither a
spawned static-mesh actor nor a runtime blocking volume blocks a trace (both report
blocks trace=0), so every 'arm 340 of 340' line so far was measured with no wall present
and is not evidence either way.
2026-11-09 14:37:03 +01:00
zwitschi a6701aa3d1 test(camera): -cameratest harness for the spring-arm collision, result inconclusive
0.2.3 is about camera clipping, and nothing had ever exercised the spring arm's
collision test. This adds a harness: find the nearest wall from the spawn, stand the pawn
70 cm off it facing away, wait for the boom, then report the arm length, whether the boom
ends inside geometry, and a back-trace from camera to head.

Inconclusive, and the setup is the suspect rather than the engine: every run reports the
arm at its resting 340 cm, but BOTH traces come back clear, so nothing was between the
pawn and the boom at all. The placement is wrong, not necessarily the collision.

Three wrong turns recorded in TODO.md because each cost a run: the probe accepted the
floor as a wall, then faced the pawn into the wall by negating the impact normal (which
points away from it), then set actor rotation only - which a possessed pawn discards in
favour of control rotation.

Next: look at the placement screenshot before trusting the measurement.
2026-11-09 14:33:57 +01:00
zwitschi 6c4e0701fb release: v0.2.2 — the character pass
Writes the 0.2.2 entry and prunes the section from TODO.md. The entry records that this
ships as a patch by the owner's decision even though it adds asset categories, which the
policy otherwise puts in a minor release - so the numbering is not mistaken for scope.

Contents: clothing as three separate skinned meshes, hair cards oriented from the scalp
normal, ComfyUI character blueprints and the render-vs-blueprint comparison, procedural
textures, materials built from scratch, the raised sun, and the walk cycle starting at
maximum stride.

Verified: 8/8 shots, all approved; mean deltaE 31.9 -> 21.8 against the blueprint; 95
tool tests. Known limitations listed rather than omitted - garments are shells over body
faces, the baked diffuse cannot be used because the mesh UVs do not map to it, and the
character has no face detail.
2026-11-09 14:26:13 +01:00
zwitschi 5de4183456 docs(review): shots 6, 7 and 8 reflect the clothed, haired character
The appearance changed in 0.2.2 and the README still described an unclothed body with
splayed fingers, which is a defect in its own right: a shot whose README section is
stale is not a review.

Shots 6, 7 and 8 now describe the clothing, the dark card hair and the raised sun, and
their approvals are reset to pending because the appearance changed. 8/8 shots verify.
2026-11-09 14:23:47 +01:00
zwitschi bea6aa3e05 light: raise the sun so the character leaves building shadow (needs approval)
The skin was never grey. A red test colour reached the render and the slot was confirmed
holding M_Character_Skin, so the material and the assignment were both fine - the body
was simply standing in building shadow and lit almost entirely by the blue skylight
(intensity 2.6). Warm skin under blue fill reads blue-grey.

Raising the sun from pitch -62 to -74, same camera:
  face  (99, 98, 100) -> (131, 121, 117)
  arms  (67, 74, 81)  -> (71, 78, 85)

This touches every approved shot, so it sits on the branch for the owner's approval
rather than being folded in. Also records that the Unreal commandlet silently drops
unknown switches, which is why a --unlit-test flag never took effect.
2026-11-09 14:20:07 +01:00
zwitschi 7c57bf318b fix(hair): dark constant colour, strand sheet used only as an opacity mask
The hair read blond and thin because the strand sheet's RGB was driving base colour; the
sheet is a pale highlight map. Colour is now a dark constant (0.030/0.020/0.014) and the
texture supplies only alpha. Sampled in the render: hair (31, 26, 21).

Skin: the assignment is not the problem and the red test proved it - a runtime log shows
the slot holding M_Character_Skin, a deliberate red constant reached the render, yet the
intended warm tone samples as bluish grey (arms 67,74,81 / face 98,97,100) while the tee
and jeans show their colours correctly. Recorded in TODO.md with the next suspects rather
than guessed at.

The temporary material log is removed again.
2026-11-09 13:55:19 +01:00
zwitschi 878d5a1f4e fix(clothing): close the coverage holes, and bind materials without crashing
Solidify thickness went from 1 cm to 2 cm and the shell is fattened 8 mm along its own
normals afterwards. That closed the shoulders, hips and knees where the body was
showing through the cloth.

Evaluation against the blueprint, same shot and box before and after:
  mean deltaE     31.9 -> 21.8
  palette deltaE  25.4 -> 19.7
  aspect delta     1.24 -> 1.22

Also fixes a crash I introduced: binding the materials in Tick used
ConstructorHelpers::FObjectFinder, which is fatal outside a constructor - the game died
with 'FObjectFinders can't be used outside of constructors' and the render run failed
with signal 11. The binding now lives in BeginPlay and uses LoadObject.

Not fixed, and recorded: the arms and face still render neutral grey, so
M_Character_Skin itself is suspect rather than the assignment.
2026-11-09 13:50:21 +01:00
zwitschi 86e9c5b693 feat(clothing): tee, jeans and shoes as separate skinned meshes
Follows the character-artist clothing workflow literally: duplicate the body's own
faces for the garment base, solidify for cloth thickness, and keep each piece a
separate mesh. Attached as leader-pose components beside the hair, so no second rig.

- Tools/character_clothing_build.py builds three garments from the body mesh, trimmed
  geometrically (height band plus a width limit) instead of by dominant bone: the bone
  filter left holes wherever a face's strongest bone was outside the allowed list.
- Tools/ue_character_clothing_import.py imports them as CharacterTee / CharacterJeans /
  CharacterShoes, each arriving with its own single material slot.
- The pawn attaches all three, hidden with the body in first person.

Three traps recorded in MEMORY.md, each of which cost a build: MPFB weights its helper
geometry to groups that dominate every vertex, Blender refuses to apply a modifier to a
mesh with shape keys, and object-mode polygon selection is discarded on entering edit
mode - the delete wiped the whole garment.

Known defects, in TODO.md: the garments have coverage holes at shoulders, hips and
knees, and the hair reads light and wispy since its material was repointed to the
masked strand sheet.
2026-11-09 13:41:39 +01:00
zwitschi 85a8437cbc feat(character): ComfyUI blueprints and a render-vs-blueprint evaluation
Two tools that turn character work into something measurable.

- Tools/comfyui_character_blueprint.py generates a four-view sheet (front, side, back,
  head) plus a contact sheet from one description, with fixed seeds and a JSON record
  of prompts and prompt ids. First sheet: 'GigLine walker', lean pedestrian in a navy
  tee, jeans and sneakers.
- Tools/character_match.py compares a rendered character against a sheet: figure
  aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette
  distance, and a side-by-side montage. The deltas stay separate on purpose - a single
  similarity score between a photoreal sheet and a low-detail body would be a number
  with no meaning.
- comfyui_generate.py grew wait_for_image() so both tools share the polling path
  instead of duplicating it.
- 13 unit tests for the colour maths, masks and palette; 95 tool tests pass.

First real result, recorded rather than smoothed over: aspect delta 1.24, fill delta
0.12, mean deltaE 31.9, palette deltaE 25.4. The montage shows the reason - the model
is a nude body and the blueprint wears a tee and jeans, so clothing dominates every
metric. Figure segmentation is explicit (--render-box): auto-detection picked backdrop
blobs on every attempt, and a background plate is the proper fix.
2026-11-09 13:36:28 +01:00
zwitschi 1fc5835327 fix(hair): cards follow the scalp normal, three overlapping rows
The first card build anchored every card on a fixed outward vector, so the silhouette
was a spiked crown. Each card is now oriented from the scalp normal at its anchor: the
length runs along the downward tangent, the width across it, and a small tip lift gives
the cut volume. Three overlapping rows (7/9/10) replace the single ring; overlap is
what stops cards reading as separate spikes.

26 cards, 401 verts, 224 quads, 468 tris, still all quads on one head bone.

Recorded as still open: the skin renders darker than the baked diffuse suggests, and
the generated pore and strand textures remain unwired because the material-editing
tool segfaults in the headless commandlet.
2026-11-09 13:25:40 +01:00
zwitschi 39f07b0f5f feat(hair): hair cards as their own skinned mesh, attached as a second component
Follows the character-artist skill on two points it is explicit about: hair for a
game character is a card system, and swappable parts stay separate meshes.

- Tools/character_hair_build.py builds SM_Char_Hair: a scalp cap plus 18 cards in
  three rings, quads only, every vertex weighted 1.0 to the head bone, UV-mapped for
  the strand sheet from Tools/generate_character_textures.py. 321 verts / 404 tris.
- Tools/ue_character_hair_import.py imports it as its own skeletal mesh, which is the
  point: joined into the body, Unreal collapses the second material slot and the hair
  renders with the skin material.
- The pawn carries it as a second USkeletalMeshComponent on SetLeaderPoseComponent, so
  it follows the head bone with no second rig and hides with the body in first person.

Still wrong and recorded: the cards point outward, so the silhouette reads as a spiked
crown rather than a short cut. The next pass orients them tangentially along the
skull with a second overlapping row. Tools/ue_character_materials.py also segfaults in
the headless commandlet when editing a material graph, so the generated textures are
not wired yet and the hair keeps its Blender base colour.
2026-11-09 13:18:40 +01:00
zwitschi 54c579ab1a feat(tex): ComfyUI tool, procedural character textures, hair cap behind a flag
Tools/comfyui_generate.py drives the local ComfyUI instance for generated textures
and writes a sidecar with model, prompt, seed and workflow, so a generated asset can
be regenerated or rejected instead of being an unexplained PNG. Models list and
generation both verified against 127.0.0.1:8188.

Stable Diffusion is not usable for flat skin swatches, and that is worth recording:
three attempts produced an anatomical close-up twice and a leather-scale surface once.
Skin detail is generated procedurally instead - tileable octave noise for pores, plus
a strand sheet for hair - in Tools/generate_character_textures.py, with sidecars.

Hair geometry exists (cap conforming to the head, weighted to the head bone, second
material slot in Blender) but Unreal's import collapses the second material slot, so
the cap renders with the skin material and reads as a bald skull. It is behind
--hair and off; the next step is a separate skeletal mesh attached as a second
component.
2026-11-09 13:09:39 +01:00
zwitschi 4b9ad22108 fix(walk): the cycle starts at maximum stride, so the capture shows one
The stride shot regressed twice for two different reasons, and only the second was
the clip. The walk cycle was authored on a sine, which is zero at both the start and
the middle of the cycle - exactly the two frames where the legs pass together - so a
deterministic capture at any phase near those frames read as a still. The probe
proved it: frames 0 and 15 carried identical hip rotations while 7 and 22 were the
stride.

The cycle now starts at maximum stride (cosine). Length, amplitudes, keys and loop
are unchanged; only the phase moved. demo-third-person-walk is back as shot 8 and
shows a genuine stride with the curled hands from 0.2.1.

8/8 shots verify.
2026-11-09 12:55:53 +01:00
zwitschi e9f31f271e release: v0.2.1 — rig fixes changelog entry, TODO pruning
Writes the 0.2.1 entry (fingers, derived arm angle, base keys) and removes the
section from TODO.md. The stride-capture regression is listed under known
limitations rather than omitted: the shot is retired, not shipped.
2026-11-09 12:52:01 +01:00
zwitschi 03af8146a6 fix(rig): curl the fingers, derive the arm angle
Two defects from the 0.2.0 review, both fixed and both measured.

- Fingers splayed because the bind is an A-pose hand. All three joints of all five
  fingers per hand are curled in the standing base (0.28/0.34/0.22 rad), with the
  curl axis probed on the rig first. The front shot now shows relaxed hands.
- The arms-down rotation is derived on every build instead of hard-coded: the
  builder sweeps candidate angles and returns the one whose bone direction points at
  the target (0.60 rad, direction dot 1.000 this build). The old 0.55 was a
  hand-swept constant.
- Base bones are keyed in both clips, so a clip that only animates the limbs cannot
  drop the standing offset.

Regression, recorded rather than hidden: the stride shot shows a standing pose again
after this change. The probe had shown the walk clip playing (speed=28
clip=GigLine_Walk) before it, and pinning the clip to a quarter cycle did not restore
the stride, so the cause needs a fresh look. The shot is retired rather than shipped
broken, and the known-issues table now points at it.
2026-11-09 12:48:40 +01:00
zwitschi 30e4d65743 todo: claim 0.2.1 rig fixes — splayed fingers, derived standing pose 2026-11-09 12:43:25 +01:00
zwitschi 12272d6b96 release: v0.2.0 — third-person body, animation, stride capture
Writes the 0.2.0 changelog entry with the measured numbers, removes the completed
section from TODO.md (its goal stays in ROADMAP.md) and updates the roadmap status.

Release checks: 8/8 shots verify with 6, 7 and 8 approved; 1440p at 2.64 ms average
(379 fps); -qaselftest passes walk, reset, pause, transect, wall and facade probes;
82 tool tests pass. Known limitations are listed rather than hidden: splayed fingers,
a swept base rotation and unproven camera collision, all marked for later patches.
2026-11-09 12:30:49 +01:00
zwitschi 56e104f5f3 todo: close the 0.2.0 animation check, leaving only the tag
Both clips play and advance (idle proved by the single-node instance's asset time,
walk by shot 8), the body carries its skin material and the QA self-test passes.
The two cosmetic defects — splayed fingers and a base rotation that was swept rather
than derived — stay open as 0.2.2 items and are named in the check.
2026-11-09 12:30:00 +01:00
zwitschi 8e880ed09d perf: 0.2.0 re-measured at 1440p — 379 fps average, 6.3x the floor
-qaselftest at 2560x1440, TSR on, VSync off, no frame generation: 759 frames,
avg 2.64 ms (379 fps), p50 2.54, p95 3.39, p99 3.76, max 4.05 ms. The body, skin
material and animation add about 2 % over 0.1.5's 2.61 ms.

The same run passed every QA check: walk, reset 0 cm from spawn, pause/unpause,
transect 1456 samples with 0 holes, wall probe 0/35, facade probe 0/9.

Series committed as Docs/perf/0.2.0-walk-frametimes.csv and the result recorded in
the release-checks table next to the 0.1.0 and 0.1.5 numbers.
2026-11-09 12:29:46 +01:00
zwitschi 9ce7011389 docs(input): the V toggle, the harness switches, and the HUD line
Docs/controls.md documented the 0.1.0 bindings only, so the camera toggle added with
0.2.0 was undocumented for the player-facing set and the review switches were
undocumented everywhere. Adds the V row, a changes-since-0.1.0 note that first person
stays the default, and a table for -thirdperson / -walk / -qaselftest with the note
that the walking capture is driven per shot rather than by the switch.

The in-game HUD line gains 'V view' so the packaged build documents it too.
2026-11-09 12:21:11 +01:00
zwitschi dbb177da6f feat(walk): the stride is captured — walking shot 8, traversal check closed
Shot 8 shows the body mid-stride in profile: legs apart, arms counter-swinging. The
traversal release check for 0.2.0 is closed with that evidence.

The reason earlier attempts froze on one pose was not the clip. A probe of the
single-node instance showed the clip advancing normally (assetTime 0.19 -> 1.03,
rate 1.0, playing) while the pawn's speed stayed 0 and the clip stayed Idle: the
call that enables walking sat in the shot sequencer's BeginPlay, which runs before
the player pawn exists, so the cast found nothing and silently did nothing. Moved
into the per-frame follow update, the same run logs walk=1 speed=28 clip=GigLine_Walk
and the stride appears.

Also: the walk expectation was missing from visual_targets.json after the earlier
retirement, so --verify was checking 7 shots while 8 were captured. Restored; 8/8
verify.
2026-11-09 12:18:22 +01:00
zwitschi eeeabe56b1 feat(review): deterministic walking capture, and the defect it exposed
The shot actor can now photograph a moving pawn: a per-shot ShotFollow flag re-places
the camera every frame at a fixed offset in the pawn's frame, and the walk is driven
per shot (SetReviewWalk) with the pawn returned to spawn after a follow shot, so the
fixed-camera shots later in the list still find it in place. With that, the capture
stopped depending on where a 4.5 m/s pawn happened to be.

Two further bugs fixed on the way:
- the engine's screenshot folder is cleared before each run. The collect step copies
  every review-*.png it finds in Saved/, so a shot retired from the camera list kept
  reappearing with byte-identical content from an older run - which is how a walk shot
  that was never captured looked like a captured one.
- UpdateBodyAnimation no longer calls SetAnimation every frame. Re-setting the same
  clip restarts it, which freezes the body on frame 0.

What the deterministic capture then exposed: the walk clip is applied but does not
advance - every walking frame shows the same pose (arms forward, legs together). The
stride shot is retired again rather than shipped, and TODO.md names the next step:
log GetCurrentAssetTime across frames and check the imported PlayRate. The harness
stays wired for when the clip plays.

Shots 6 and 7 were re-rendered after these changes and still pass; 7/7 verify.
2026-11-09 12:11:24 +01:00
zwitschi f84093adba feat(walk): a -walk harness flag, and the honest reason the stride shot is not in
Screenshots 6 and 7 are approved, so the remaining 0.2.0 work is the traversal
release check. This adds the piece of harness it needs - `-walk` holds forward input
from the first frame, so the pawn actually crosses the sidewalk while the third-person
rig follows it - and records what that check still lacks.

A stride shot was attempted three times and removed: a fixed review camera cannot
catch a pawn moving at 4.5 m/s. Each attempt framed it at a different distance and
the last two missed it entirely, so shipping it would have been a broken shot with a
green verification. The TODO item now says the blocker is the capture harness (a
camera that tracks the pawn), not the game, and names where the shot goes back in.

The flag stays because it is what the traversal check will drive.
2026-11-09 11:50:07 +01:00
zwitschi 46b84884b8 fix(character): the arms hang at the sides — and why that took so long
The character now stands naturally: arms at its sides with a soft elbow bend, hands
at the hips, feet flat. Both character shots confirm it, front and back.

The root cause was not the pose. The FBX importer had been silently refusing to
replace the existing assets: after changing the arm base from 1.55 rad to 0.55 rad
and rebuilding, the imported clip returned the IDENTICAL bone quaternion
(+0.006, +0.002, -0.179, -0.984). replace_existing=True was set and delete_asset
returned False even after chmod u+w, because the pawn's constructor hard-references
the clips. Importing under a new name (CharacterV2) created fresh assets and the
same probe then returned (+0.439, +0.898) — the rotation had landed. Every earlier
attempt had been invisible for this reason.

The rotation itself is measured, not inherited: sweeping it in Blender gives 0.55 rad
for arms down and clear of the body, 0.65 for straight down against the hip, 0.75 for
past vertical and into the torso. It lives in the clamp keys, not in the bind: every
bind experiment was impossible on this shape-keyed MPFB mesh or left Unreal showing
the old bind.

Remaining, for 0.2.2: the fingers splay from the bind, and the walk clip has still
not been captured in motion.
2026-11-09 11:42:36 +01:00
zwitschi 8993fd83c4 feat(body): pose the arms down, add a front-angle shot
The character now stands with its arms at its sides instead of in a bind A-pose,
and there is a second camera to prove it: the third-person shot only ever showed
the pawn's back, so a front-facing pose defect was invisible in review.

  demo-third-person-front  camera on the road, ~6 m in front, looking back north
                           at the pawn; embedded in Screenshots/README.md as shot 7.

The pose work itself is a chain of measured findings, all recorded in TODO.md:

- clip data is exported relative to the REST pose, so arms-down offsets held only in
  the keys arrived as a ±20 degree swing around an A bind;
- a rest-pose bake alone does not move the mesh: the MPFB mesh has shape keys, the
  armature modifier cannot be applied while they exist, and the vertices stayed in
  the A shape (X span unchanged at 1.051 m). Clearing the shape keys, baking the
  deformation into the vertices and re-binding is what made bind and clips agree.

Not settled, and left as an open item rather than a claim: the base pose uses
inverted signs to compensate for the FBX axis mapping, the upper arms clip into the
torso, and the fingers splay. An aim-based pose module was written and then removed
- applied without the vertex bake it produced arms raised in a V, and a half-working
shared module is worse than none. The proper solve is 0.2.2: apply the standing pose
at character creation, then author clips against that bind.
2026-11-09 11:28:29 +01:00