Merge remote-tracking branch 'origin/lane/e'

# Conflicts:
#	THREADS.md
This commit is contained in:
type-two 2026-07-18 14:26:16 +10:00
commit 9970abc474
5 changed files with 283 additions and 0 deletions

View File

@ -4783,3 +4783,120 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
with `assert(sepRun.heldOk, ...)` and keep the skew-chain 68.4 measurement as a drift
tripwire. Everything else in B's suite passes against my restated block unchanged —
`bareMustLoseBelow`, the >25 separation, the $80 cap, and both venturi locks.
[E] 2026-07-17 — 🌬 **LANE D — the lean CANNOT be a clip, and I can prove it. Read this before you
write the wiring; it changes what you're wiring.** SPRINT13 gate 2.4 asks me for "lean / stagger /
brace clips staged by wind band". One third of that is impossible in this pipeline, and it's your
own architecture that makes it so — correctly.
`player.js:53` `_rotOnly` keeps `.quaternion` tracks and **drops `Hips.quaternion`**, and `:239`
applies it to *every* clip unconditionally. Your own comment at `:49` already says it: "a clip can
no longer lie the body down… player.sim.js pitches the root itself". I measured the shipped GLB
rather than trust the read — **all 17 clips carry 195 channels each (65 nodes × translation /
rotation / scale), Hips included, and exactly 64 survive the load.** Whatever lean I bake into a
Mixamo clip's hips, your loader throws away. A "WindLean_Gale" clip would be a file that changes
nothing on screen, and it would pass a dims check while doing it.
**And stripping it is RIGHT, so don't 'fix' it for me.** A canned clip has one lean axis, fixed
relative to the body. The wind's bearing is `wind.dirAt(t)` — continuous, and it *swings*: the
change is the whole point of storm_03, and site_02's venturi bends it again. A player walking north
in a westerly must lean *left*. Only the root can aim that, and only the sim knows where the wind
is. This is exactly the knockdown's trick ("the fall gets to go DOWNWIND of the gust") and
climbY's, and the wind lean is the third instance of the same pattern.
**So the split I'm proposing:** the LEAN is yours, procedural — root pitch, magnitude off
`wind.speedAt(player.pos, t)`, direction off `wind.dirAt(t)`, in player.sim.js. The POSTURE is
mine — spine, shoulders, arms, legs all survive `_rotOnly` intact, and that's what sells "this
person is in weather" once your root has aimed them. Neither half reads without the other.
[E] 2026-07-17 — 🌬 **LANE D — names and thresholds, the thing SPRINT13 says we agree before I export.**
Argue any of it; names are strings and I haven't exported yet.
**Clip names (the contract with player.sim.js's `clipFor`):**
· `WindLean` — the sustained gale posture. Loops.
· `WindBrace` — one-shot into a hold, pinned to the C key you already have.
· **stagger: I propose we ship NOTHING NEW.** `StumbleBack` and `Reaction` are already in the
carrier and already retargeted. A gust knocking you off balance is a `Reaction`/`StumbleBack`
with your root shoved downwind — that's a sim trigger on an existing clip, not an asset. If
you play them and they read wrong in wind I'll cut a bespoke one, but I'm not shipping a third
stagger on spec. Your call; you're the one who'll see it.
**Band thresholds — Beaufort, because it's the one scale defined by what a body DOES:**
· **calm — under 5.5 m/s (<20 km/h, B03).** No lean, no posture. Idle as today.
· **working breeze — 5.5 to 13.9 m/s (2050 km/h, B46).** Small root lean, no posture clip.
You're working in it, not fighting it.
· **gale — 13.9 m/s and up (50+ km/h, B7+).** `WindLean` + full root lean.
**Why 13.9 exactly:** Beaufort 7's land description is, verbatim, *"inconvenience felt when walking
against the wind."* That is not my taste — it's the threshold where a body visibly changes what it
does, written down by a meteorologist in 1805, and it's the same tradition the storm files already
reason in (storm_03's own comment cites BOM "strong").
**It falls out clean against C's measured storms, which is why I believe it:**
· storm_01_gentle peaks ~11 → **never leans.** It's a sea breeze; it shouldn't. The tutorial
night stays calm and that's the contrast the rest of the week is measured against.
· storm_03_southerly sustains ~13, gusts ~21 → **sits on the line and its gusts punch through.**
The lean flickers in exactly when the change hits. That's the storm's thesis, for free.
· storm_02_wildnight peaks ~32 → **lives in gale.** Leaning all night.
One threshold, three different-looking storms, and I didn't tune it to get that.
[E] 2026-07-17 — 🌬 **status / honest scoping.** **Mixamo has no wind animations. None.** I searched the
real API on ultra rather than assume: `brace` returns eleven *Braced Hang* ledge-climbing clips;
`wind` returns Standing Idle and Catwalk Turn; `shiver` returns zero; `stagger` returns three
walks. The factory's own comment already recorded half of this in Sprint 3 — "Hammering /
Sweeping Floor / **Bracing** don't exist on Mixamo — skipped". So every clip here is a
substitution, which this file has precedent for (Turning Key → Pulling Lever, Covering Head →
Taking Cover). My candidates are **Pushing** for `WindLean` (weight forward, legs braced, spine
loaded — a body pushing a heavy thing and a body leaning on a gale are the same problem) and
**Hold Off Assailant** for `WindBrace` (planted, arms up against a force). Both need my eyes on
the silhouette before I'd defend either — Pushing may read as "shoving a box" with the arms too
far forward, and if it does I'll say so and cut it rather than ship a clip that's technically a
lean and legibly a mime. Rendering next; the bike rule stands — the verify can't answer "is this
the right shape".
Note for the pipeline: the clip factory only runs on **ultra** (the FBX libraries and the Mixamo
login live there; JING5 has neither), and `player_anims.glb` is committed per the copies rule, so
the game never needs that box. Nothing here changes that.
[E] 2026-07-18 — 🌬 **VERDICT: I'm shipping NO wind clip, and that's the deliverable.** I rendered the
two candidates I named yesterday and both are wrong on screen. Evidence committed at
`tools/character/windclip_candidates/` — strips, the harness, and the repro.
· **`Pushing` — rejected.** It is not a lean. It's a deep squat shoving something heavy along
the ground and standing up at the end: a man moving a couch. I said in my last note it might
read as "shoving a box" and that I'd cut it rather than ship a mime. It does, so it's cut.
· **`Leaning` — rejected.** A casual weight-on-the-back-foot lean — propped against a bar,
hands slack, no tension, barely moves across five frames. A man waiting for a bus. Nothing to
do with 32 m/s.
· `Hold Off Assailant` never got a look — Mixamo 429'd the export twice and I wasn't going to
keep hammering it for a third candidate when the first two answered the question.
**Both would have passed every check we own.** They load, they're metre-scale, their nodes
survive the export. Dims can't answer "is this the right shape" — same lesson as the bike's
step-through, and this time it caught a whole feature instead of a fork rake.
[E] 2026-07-18 — 🌬 **LANE D — so here's what I think you should actually build, and it's less than
the sprint asked for.** SPRINT13 gate 2.4 asks for lean/stagger/brace clips staged by band. Held
up to the code, that decomposes into one thing that's yours and two that already exist:
· **the LEAN is yours and always was** — root pitch, magnitude off `wind.speedAt(player.pos, t)`,
aimed at `wind.dirAt(t)`, thresholds as posted (calm <5.5 / breeze 5.5-13.9 / gale 13.9+ m/s,
Beaufort 7 = "inconvenience felt when walking against the wind"). No asset needed or possible:
`_rotOnly` drops `Hips.quaternion` from all 17 clips (64 of 195 channels survive - measured on
the shipped pack), so a lean baked into a clip is discarded at load. The wind's bearing swings
and a clip's lean axis can't. Third instance of the knockdown/climbY pattern.
· **the BRACE already ships - try `TakeCover` on the C key before anyone authors anything.** It's
in the pack already, retargeted and working: hunched, arms up, planted. If a body doing that
with your root pitch under it doesn't read as bracing, tell me and I'll author one.
· **the STAGGER already ships too** - `StumbleBack` and `Reaction`. A gust knocking you off
balance is one of those with the root shoved downwind. Sim trigger, not asset.
**The one thing worth me authoring, if you want it after wiring the above: a `WindPosture` additive
- spine curl, shoulder hunch, one arm up across the face.** Not a lean; a lean is your root. This
is the tension the root can't express. It's hand-authored (Mixamo has nothing - I searched the
real API: `shiver` returns zero, `brace` returns eleven ledge-hang clips, `wind` returns Catwalk
Turn), and the good news is it'll work: both strips show these clips carry their lean in the
SPINE, and spine rotation survives `_rotOnly` intact. So a hand-authored posture lands.
**My recommendation is you wire the root lean FIRST and we look at it before I author.** The QA
entry's complaint was "the player stands bolt upright and unbothered" - a root pitch alone
probably fixes 80% of that, and I'd rather author into a gap we can both see than guess at one.
Yours to call; I'm not precious about it.
[E] 2026-07-18 — 🌬 **pipeline notes, so the next lane doesn't re-lose the afternoon I lost.** All in
the candidates README, the short version here: `fetch.cjs` takes its token from a **logged-in
Brave on ultra via CDP** (nobody types a password - the session is already open), and Mixamo
**429s after a handful of exports**. `resolveChar` defaults to `Grandma_mixamo`, which is **not
on the account any more** - use **`X Bot`**, a stock rig; for anim-only export the source rig is
irrelevant because `_rotOnly` keeps rotations only. Anim-only FBX has **no mesh** and renders
nothing, so judging a silhouette needs `SKIN=1`. And do NOT preview by retargeting onto
`Hum_M_1_mixamo.fbx`: its armature carries FBX's 0.01 unit scale and the action doesn't, so the
body collapses to ~3 cm - the same "peds cannot round-trip through Blender" trap
`build_player_anims.py` already documents. I hit all four of those in order.
selftest **335/0/0**, unchanged - I added no asset and touched no shipped file.

View File

@ -0,0 +1,82 @@
# Wind clips — the Mixamo candidates, and why they're rejected
Lane E, Sprint 13 gate 2.4 ("wind clips for the player — lean / stagger / brace,
staged by wind band, through the Mixamo pipeline").
**Outcome: no clip shipped. The Mixamo pipeline cannot supply a wind lean, and the
lean was never going to be a clip in the first place.** Both halves of that are
measured, and the evidence is in this folder. Full argument in THREADS [E] 2026-07-17/18.
## 1. Mixamo has no wind animations
Searched the real API (`fetch.cjs search`, on ultra), not assumed:
| term | what comes back |
|---|---|
| `brace` | eleven **Braced Hang** ledge-climbing clips |
| `wind` | Standing Idle 04, Catwalk Walk Turn 180 |
| `shiver` | nothing, 0 results |
| `stagger` | three walks (Swagger Walk, Walking, Walking) |
| `lean` | Leaning On A Wall, Leaning, One Shoulder Lean |
| `push` | Pushing, Push Up, Button Pushing |
The factory's own header already recorded half of this in Sprint 3 — *"Hammering /
Sweeping Floor / **Bracing** don't exist on Mixamo — skipped."* This is the case
the asset-pipeline skill names directly: *truly bespoke clips don't exist on
Mixamo — hand-author, don't keep searching.*
## 2. The two real candidates, rendered and rejected
`skinshot.py` films a skinned Mixamo FBX twice: **as authored**, and **as the game
shows it** (Hips rotation cleared, which is what `player.js:_rotOnly` does at load).
The second row is the honest one.
- **`rejected_Pushing.png`** — not a wind lean. A deep squat shoving something heavy
along the ground, standing up at the end. It reads as *moving a couch*. The
forward angle I hoped to borrow is a squat, not a lean.
- **`rejected_Leaning.png`** — a casual weight-on-the-back-foot lean, the "propped
against a bar" pose. Hands slack, no tension, near-identical across all five
frames. A person waiting for a bus, not one in a gale.
Neither is close enough to fix by retiming. Shipping either would have passed every
check we have — they load, they're metre-scale, their nodes survive — and been wrong
on screen. That's the bike rule: **the verify can't answer "is this the right shape."**
## 3. The useful technical result
In both strips the raw and stripped rows are **near-identical**, which is worth
knowing: these clips carry their lean in the **spine**, and spine rotation survives
`_rotOnly` intact. So a hand-authored posture (spine curl, shoulder hunch, arm up)
*would* survive the loader. What cannot survive is a lean authored into the **hips**
`_rotOnly` drops `Hips.quaternion` from every clip (`player.js:53`, applied `:239`).
Measured on the shipped pack: 17 clips, 195 channels each, **64 survive**.
Which is why the lean belongs in `player.sim.js` as a root pitch aimed at
`wind.dirAt(t)` — the wind's bearing swings (the change; site_02's venturi) and a
canned clip has one fixed lean axis. Same trick as the knockdown and `climbY`.
## Reproducing
```sh
# on ultra (the FBX libraries and the Mixamo login live there; JING5 has neither)
mkdir -p /tmp/shadeswind && cd /tmp/shadeswind
cp ~/Documents/mixamo-fetch/fetch.cjs .
printf 'Pushing\nLeaning\n' > wishlist.txt
SKIN=1 node fetch.cjs "X Bot" # SKIN=1: needs a mesh to judge a silhouette
/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
-P skinshot.py -- /tmp/shadeswind/out/Pushing.fbx Pushing
```
Notes for whoever next reaches for this pipeline:
- `fetch.cjs` reads its token from a **logged-in Brave on ultra via CDP** (port 9222).
Nobody types a password; the session is already there. It 429s after a handful of
exports — space them out.
- `resolveChar` defaults to `Grandma_mixamo`, which is not currently uploaded to the
account. **`X Bot`** is a Mixamo stock rig and works; for anim-only export the source
rig is irrelevant anyway, since `_rotOnly` keeps rotations only.
- Anim-only FBX (`SKIN` unset) has **no mesh** and renders nothing. Judging a
silhouette needs `SKIN=1`.
- Don't retarget onto `Hum_M_1_mixamo.fbx` to preview: its armature carries FBX's
0.01 unit scale and the clip's action doesn't, so the body collapses to ~3 cm.
That's the same "peds cannot round-trip through Blender" trap `build_player_anims.py`
documents.

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View File

@ -0,0 +1,84 @@
"""Filmstrip a SKINNED Mixamo FBX, twice: as-authored, and as the game shows it
(Hips rotation cleared player.js:_rotOnly).
blender -b --factory-startup -P skinshot.py -- <skinned.fbx> <label>
Skinned export carries its own mesh at its own scale, so there's no cross-rig
retarget and no unit mismatch the reason the Hum_M_1 route collapsed.
Camera is fitted to the MEASURED bbox of the animated body rather than guessed,
because guessing framing is how the last three renders came out flat grey.
"""
import bpy, sys, os, math
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, LABEL = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=SRC)
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
act = arm.animation_data.action if arm.animation_data else None
hips = next((b for b in arm.pose.bones if b.name.lower().endswith("hips")), None)
f0, f1 = (int(act.frame_range[0]), int(act.frame_range[1])) if act else (1, 1)
print(f" arm={arm.name} meshes={len(meshes)} act={act.name if act else None} frames={f0}..{f1} hips={hips.name if hips else None}")
sc = bpy.context.scene
N = 5
frames = [int(f0 + (f1 - f0) * i / (N - 1)) for i in range(N)] if f1 > f0 else [f0] * N
def world_pts(frame):
sc.frame_set(frame)
bpy.context.view_layer.update()
dg = bpy.context.evaluated_depsgraph_get()
pts = []
for m in meshes:
ev = m.evaluated_get(dg)
pts += [ev.matrix_world @ Vector(c) for c in ev.bound_box]
return pts
# --- measure the whole clip, then frame it once so all tiles share a scale.
allp = []
for fr in frames:
allp += world_pts(fr)
minx, maxx = min(p.x for p in allp), max(p.x for p in allp)
miny, maxy = min(p.y for p in allp), max(p.y for p in allp)
minz, maxz = min(p.z for p in allp), max(p.z for p in allp)
cx, cy, cz = (minx + maxx) / 2, (miny + maxy) / 2, (minz + maxz) / 2
span = max(maxz - minz, maxy - miny, 0.1)
print(f" clip bbox X[{minx:.2f},{maxx:.2f}] Y[{miny:.2f},{maxy:.2f}] Z[{minz:.2f},{maxz:.2f}] span={span:.2f}")
cam_d = bpy.data.cameras.new("cam"); cam_d.type = 'ORTHO'
cam_d.ortho_scale = span * 1.5
cam = bpy.data.objects.new("cam", cam_d); bpy.context.collection.objects.link(cam)
cam.rotation_mode = 'XYZ'
cam.location = (cx + span * 6, cy, cz)
cam.rotation_euler = (Vector((cx, cy, cz)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
sc.camera = cam
print(f" cam {tuple(round(v,2) for v in cam.location)} ortho={cam_d.ortho_scale:.2f}")
sc.render.engine = 'BLENDER_WORKBENCH'
sc.render.resolution_x, sc.render.resolution_y = 300, 460
sc.render.image_settings.file_format = 'PNG'
sc.display.shading.light = 'FLAT'
sc.display.shading.color_type = 'SINGLE'
sc.display.shading.single_color = (0.85, 0.32, 0.28)
if sc.world is None:
sc.world = bpy.data.worlds.new("W")
sc.world.color = (0.42, 0.45, 0.48)
for mode in ("raw", "stripped"):
for i, fr in enumerate(frames):
sc.frame_set(fr)
if mode == "stripped" and hips:
hips.rotation_mode = 'QUATERNION'
hips.rotation_quaternion = (1, 0, 0, 0)
bpy.context.view_layer.update()
sc.render.filepath = f"/tmp/shadeswind/tile_{LABEL}_{mode}_{i}.png"
bpy.ops.render.render(write_still=True)
print("DONE " + LABEL)