diff --git a/.claude/skills/animation/SKILL.md b/.claude/skills/animation/SKILL.md index decbf74..6e1b123 100644 --- a/.claude/skills/animation/SKILL.md +++ b/.claude/skills/animation/SKILL.md @@ -95,6 +95,17 @@ Two Blender-side fixers exist (no iClone round-trip needed): Both verified by re-running `loop_qc.py` to exit 0. iClone re-authoring remains the fallback for clips where neither result looks right in the test bed. +## Outgoing props (game → iClone staging) +`exchange/outgoing-props/` carries game assets exported as iClone staging props (the +reverse direction of the bridge). `tools/export_boat_prop.py` builds the boat prop +(hull at game scale + placeholder mast/sail/oar + seat markers + waterline + +1.9 m ref figure) with a self-verify gate incl. right-side-up checks — the FBX ships +via git for the PC to pull. Boat ACTION clips use **direct names** (no nd_## stage): +see the "Boat actions" table in the registry. Lesson from building it: an +agent-authored verify gate can pass a visually-wrong export (the first boat was +upside down with all checks green) — **always render and eyeball a new prop** before +shipping, and encode what the eyeball caught as new asserts. + ## Launching the game - **Always ask Jeremy before launching** (this session he said "let's launch" — that's the go-ahead; don't assume it next time). - Command: `SCENE=dance_test_bed bash tools/game.sh spawn` from `ariki-game/` root. Also `animation_showcase` for paging every clip. diff --git a/docs/dances/REGISTRY.md b/docs/dances/REGISTRY.md index 0574dd8..67bd845 100644 --- a/docs/dances/REGISTRY.md +++ b/docs/dances/REGISTRY.md @@ -66,3 +66,19 @@ renamed once Jeremy says which ceremony it corresponds to. Rename with: Next free provisional number: **nd_01** (none issued yet — the five keepers predate the system and keep legacy names until adopted). + +## Boat actions (avatar action clips — direct names, no provisional stage) + +Staging prop: `exchange/outgoing-props/boat/boat_prop.fbx` (built by +`tools/export_boat_prop.py`; see its README for iClone import + filming notes). +Cyclic clips (`*_loop`, `*_idle`) must pass `loop_qc.py` before shipping; one-shots +(dive/climb/collect) keep their pelvis translation (game moves the avatar). + +| action code | clip | status | notes | +|---|---|---|---| +| `boat_dive` | `BoatDive` | awaiting take | airborne — hand-key/ActorCore or takeoff-only filming | +| `boat_climb` | `BoatClimb` | awaiting take | film over ~0.65 m proxy; Reach-target cleanup | +| `boat_collect` | `BoatCollect` | awaiting take | kneel over gunwale, flotsam grab | +| `boat_row_loop` | `BoatRowLoop` | awaiting take | helm, stern-oar sweep ±18° | +| `boat_fish_loop` | `BoatFishLoop` | awaiting take | Seat_Fisher; coexists with kevin Fishing01_Loop | +| `boat_sail_idle` | `BoatSailIdle` | awaiting take | sway/line-hauling loop | diff --git a/exchange/outgoing-props/boat/README.md b/exchange/outgoing-props/boat/README.md new file mode 100644 index 0000000..fcd07f5 --- /dev/null +++ b/exchange/outgoing-props/boat/README.md @@ -0,0 +1,111 @@ +# Boat staging prop — `boat_prop.fbx` + +A single static FBX of the ariki-game expedition canoe, rebuilt exactly as +`ariki-game/src/Viewer/BoatRenderer.cs` assembles it at runtime, for **staging boat- +interaction character animations in iClone 8**. Built by +`tools/export_boat_prop.py` (Blender 5.1.2 headless) from +`ariki-game/assets/models/glbs/Boat__PolynesianCanoe_hull.glb`. + +- **Frame:** metres, Z-up, bow = −Y, keel at Z = 0. (Game frame is Y-up, bow = +Z; the + tool converts via g2b `(x,y,z) → (x,−z,y)`.) +- **Scale:** 8.5 m long (`BoatLength`), exported with FBX unit scale = cm → iClone reads + it as an **~850 cm** prop with all local scales 1.0. +- **Generate / re-verify:** + `"$BLENDER" --background --python tools/export_boat_prop.py --` + (re-runs the export and its built-in self-verify, which must exit 0.) + +## Attribution + +The hull mesh is the **Polynesian Canoe** by **DITCH.WAV**, licensed **CC BY 4.0**. + +- Source: https://sketchfab.com/3d-models/polynesian-canoe-fb4379ba486c43bd846f9b9ef608743a +- Author: DITCH.WAV — https://sketchfab.com/DITCH.WAV +- License: CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/ + +**Modifications made** (required to be noted under CC BY 4.0): + +- The game project already removed the baked crab-claw **sail** from the hull GLB + (`Boat__PolynesianCanoe_hull.glb` — the `_hull` variant); the carved hull, ama outrigger, + and iako booms are unchanged. +- Rescaled uniformly ×8.5 to the game's `BoatLength` (8.5 m) and re-oriented so the bow + runs along the game's +Z (Blender −Y), keel at the waterline. +- **Staging markers and placeholder rigging added** for iClone authoring only — these are + NOT part of the licensed asset: 5 seat-marker spheres, a mast/boom/sail placeholder, a + steering-oar placeholder, a waterline outline, and a 1.9 m reference figure. Flat-color + materials (no textures) on the added parts; the hull retains its original embedded jpg. + +Credit is also given in ariki-game's in-game credits screen and root `ATTRIBUTIONS.md`. + +## Contents legend + +Every named node in the FBX (positions in **game coords** — Y-up, bow +Z — for reference; +in the FBX itself they are in Blender Z-up / bow −Y): + +| Node | What it is | Game pos (x, y, z) m | +|---|---|---| +| `Boat_ArikiCanoe` | The hull mesh (carved canoe + baked ama + iako booms), scaled ×8.5, keel at Z=0. The textured part. | keel at y=0, bow at z=±4.25 | +| `Mast` | Mast cylinder, radius 0.055, height 4.2 m, centred on the keel at the sail pivot. | (0, deckTop, 0) | +| `Sail` | Crab-claw sail **placeholder** (silhouette, not accuracy). Foot 2.1 m spreading to **−X** (port), height 3.8 m. | foot at (0, deckTop+0.45, −0.10) | +| `Boom` | Boom spar along X, radius 0.045, length 2.205 m. | centred (−1.05, deckTop+0.45, −0.10) | +| `SteeringOar` | Hoe uli (steering oar) placeholder — tapered shaft + blade, raked aft, at the stern starboard oarlock. | pivot (0.384, 0.75, −3.655) | +| `Seat_Navigator` | Bow seat (4 cm marker). Navigator stands/drives here. | (0, 0.75, +2.55) | +| `Seat_Lookout` | Mast-top lookout post. | (0, 4.00, −0.20) | +| `Seat_Fisher` | Port-side seat beside the **ama** outrigger (−X). | (−1.50, 0.65, 0) | +| `Seat_Rest` | Stern rest seat. | (0, 0.75, −2.975) | +| `Seat_Helm` | Stern helm by the steering oar — where the pilot stands. | (0.072, 0.50, −2.72) | +| `Waterline_Fwd/Aft/Stbd/Port` | Four thin edges forming a **10 × 7 m open rectangle** at the still-water surface (Z = −0.06 m). Not a filled plane. | z = −0.06 | +| `RefFigure_190cm` (+ `_Head`) | 1.9 m capsule reference figure (Ø0.35) standing on `Seat_Helm`, feet at the marker. Scale/height check. | feet at Seat_Helm | + +`deckTop` = 0.40 m (computed from the measured hull height as `max(0.12 × height, 0.40)`). +The ama (outrigger) is baked into the hull on the **port** side (−X), where `Seat_Fisher` +sits — verified by the hull's centre-of-mass offset. + +## iClone import + +1. Drag `boat_prop.fbx` into iClone as a **Prop** (Content Manager → drag, or + File → Import). +2. In the **Modify** panel, confirm the prop length is **≈ 850 cm** (8.5 m). If iClone + shows ~8.5 cm or ~85 m, the FBX unit preset on import is wrong — re-import with the + unit set to **centimeters**. +3. Drop a CC avatar next to the prop and confirm **`RefFigure_190cm` stands head-high** + beside it (~1.9 m). This is your scale ground-truth. +4. The seat markers (`Seat_*`) are **snap targets** — move/parent hands, feet, hips, and + props to them when blocking poses. +5. When rendering/previewing, **hide `RefFigure_190cm` and the `Waterline_*`** edges + (they're staging guides, not part of the scene). The flat-colored placeholder rigging + (Mast/Sail/Boom/SteeringOar) is a stand-in — hide or replace it once you have the real + rigging; it exists only so blocking reads correctly. + +## Filming / authoring notes (per animation) + +Jeremy films everything with **Video Mocap**; these notes flag what's filmable vs. what +needs hand-keying or an ActorCore fallback: + +- **Fishing loop** — at `Seat_Fisher` (seated, port side by the ama). Filmable: a stick + as the fishing-rod prop. Snap the seated pose onto the marker. +- **Row / steer loop** — at `Seat_Helm` (stern). Use a broomstick mimicking the stern + oar's **±18°** sweep (the game sweeps `4° + effort×14°`, max ±18° under throttle). Film + the sweep, then snap the hands to Reach targets on `SteeringOar`/`Seat_Helm` in cleanup. +- **Collect-flotsam** — kneel/lean over the gunwale (port side). Keep the hands + unoccluded in frame (Video Mocap loses occluded joints). Filmable. +- **Climb-aboard** — film over a **~0.65 m box/table proxy** (the gunwale height). Expect + **heavy Reach-target cleanup**; likely a **hand-key fallback** for the final pull-up. +- **Dive-off** — **airborne motion breaks Video Mocap** (no ground contact, feet leave + frame). Film only the **takeoff crouch + spring** and the hand-finish; get the airborne + arc from an **ActorCore dive**, or hand-key it. +- **Sailing sway loops** — gentle stand/brace sway at a seat. **Filmable.** Static bracing + poses are hand-posed against the mast/boom. + +## Per-take iClone export reminder + +For every clip you send back through the animation pipeline: + +- **Format:** FBX +- **Target Tool Preset:** Blender +- **Frame rate:** 60 fps +- **Range:** All +- **Include Motion:** ON (a 2-frame `*TempMotion` take means motion was NOT exported — + re-export with this checked) +- **Preserve Bone Names (CC Base):** ON + +Then retarget with `tools/cc_retarget.py` as usual. diff --git a/exchange/outgoing-props/boat/boat_prop.fbx b/exchange/outgoing-props/boat/boat_prop.fbx new file mode 100644 index 0000000..369e766 --- /dev/null +++ b/exchange/outgoing-props/boat/boat_prop.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fd28202272833eb99b3a9237d442040dc6f8bbba65f24083945a0aa3cd9f6cb +size 997132 diff --git a/exchange/outgoing-props/boat/renders/boat_prop_3qtr.png b/exchange/outgoing-props/boat/renders/boat_prop_3qtr.png new file mode 100644 index 0000000..bc2c198 Binary files /dev/null and b/exchange/outgoing-props/boat/renders/boat_prop_3qtr.png differ diff --git a/exchange/outgoing-props/boat/renders/boat_prop_side.png b/exchange/outgoing-props/boat/renders/boat_prop_side.png new file mode 100644 index 0000000..799a78e Binary files /dev/null and b/exchange/outgoing-props/boat/renders/boat_prop_side.png differ diff --git a/plans/boat-prop-export-fixes-2026-07-17.md b/plans/boat-prop-export-fixes-2026-07-17.md new file mode 100644 index 0000000..8b4e7da --- /dev/null +++ b/plans/boat-prop-export-fixes-2026-07-17.md @@ -0,0 +1,46 @@ +# FIX ROUND: defects found in boat_prop.fbx by independent visual verification + +Your self-verify passes but the boat is **visually wrong**. Renders of your FBX vs a +raw import of the source GLB prove two defects. Fix `tools/export_boat_prop.py`, +re-run until BOTH the existing self-verify AND the new checks below pass, then update +`plans/boat-prop-export-results-2026-07-17.md`. + +## Defect 1 — hull is UPSIDE DOWN + +In your export the canoe's carved prows curl DOWN below the waterline and the belly +bulges up. Ground truth: `bpy.ops.import_scene.gltf` on the source GLB already gives a +RIGHT-SIDE-UP canoe in Blender's Z-up frame (deck opening faces +Z, prows sweep up). +The glTF importer performs the Y-up→Z-up conversion itself — your extra game→Blender +axis conversion (the C3 matrix) double-rotates the mesh. + +**Correct transform chain** (nothing else): +1. Import GLB; apply all transforms (bakes the importer's rotation into vertices). +2. Rotate about **Z only** so the bow lies along −Y (raw bow is along +X after step 1). +3. Scale ×8.5 uniformly; apply. +4. Translate Z only so min-Z = 0. **Do NOT translate or recenter X/Y** — the game uses + the GLB's own origin (Defect 2). + +## Defect 2 — hull was recentered on X + +The canoe is a SINGLE outrigger (one ama, one side) yet your export's X bounds are +symmetric (±2.82) — you recentered by AABB. The game never translates X; keep the +GLB's own lateral placement so the hull centerline stays where the seat markers +assume it. After the fix, report which side the ama extends to (expect −X, the +Seat_Fisher side, if the game-equivalent yaw is right; report honestly if it's +X). + +## New self-verify checks to ADD (these would have caught both defects) + +- **Right-side-up**: the hull's LOWEST vertex (z≈0, the keel) must lie near midships + (|y| < 2.0 m), and the hull's HIGHEST vertices (prow carvings) must lie near the + ends (|y| > 3.0 m). An upside-down canoe fails both. +- **Not recentered**: the hull mesh's X bounds must be ASYMMETRIC about 0 (single + outrigger): assert `abs(abs(minX) - abs(maxX)) > 0.5` m. + +## Verification renders (run these yourself and STATE in the report what you see) + +Render workbench snapshots (¾ view + side view) of the final FBX re-imported, e.g. +camera at (14,−12,8) looking at (0,0,1.2). The canoe must sit prows-up on the +waterline rectangle like a boat, not like a banana on its back. + +Everything else from the original plan stands (guardrails §6 included: no commits, +ariki-game read-only). The waterline-as-4-named-rails deviation is ACCEPTED — keep it. diff --git a/plans/boat-prop-export-plan-2026-07-17.md b/plans/boat-prop-export-plan-2026-07-17.md new file mode 100644 index 0000000..93bcaf7 --- /dev/null +++ b/plans/boat-prop-export-plan-2026-07-17.md @@ -0,0 +1,160 @@ +# Plan: `tools/export_boat_prop.py` — export the game boat as an iClone staging prop + +**Status:** ready for implementation · **Author:** Fable 5 session 2026-07-17 · **Implementer:** GLM session + +## 0. Context (you have no other context — read this fully) + +This repo is the animation bridge for the game repo at +`/Users/jeremykashkett/Tinqs/local.repo/ariki-game` (read-only for you). Jeremy will +author boat-interaction character animations in iClone 8 on a Windows PC and needs the +game's boat as a correctly-scaled FBX prop. + +The game's vessel: `ariki-game/assets/models/glbs/Boat__PolynesianCanoe_hull.glb` +(832 KB, one mesh `Object_0`, embedded jpg texture) — a carved hull WITH baked ama +outrigger + iako booms; only the sail was cut off. At runtime, +`ariki-game/src/Viewer/BoatRenderer.cs` scales it ×8.5, rotates it (0,−90°,0), and +builds the mast/sail/steering-oar procedurally plus 5 seat marker nodes. Your tool +reproduces that assembled vessel as one static FBX. + +Build tool: Blender 5.1.2 headless at `/Applications/Blender.app/Contents/MacOS/Blender`. +Run pattern (match `tools/cc_retarget.py` style — argv after `--`, `[export_boat_prop]` +prefixed prints): + +``` +"$BLENDER" --background --python tools/export_boat_prop.py -- \ + [--hull ] [--out exchange/outgoing-props/boat/boat_prop.fbx] [--scale 8.5] \ + [--no-ref-figure] [--fbx-scale-mode FBX_SCALE_UNITS] [--no-verify] +``` + +## 1. Ground-truth constants (harvested from BoatRenderer.cs — trust these) + +Game frame: meters, Y-up, bow = +Z, keel at Y=0. BoatLength=8.5, BoatWidth=2.4, +halfLen=4.25. GLB raw ≈1.0 unit long along its local +X. + +- Hull: rotate so glb bow (+X) → game +Z; uniform ×8.5; lift so keel (min Y) = 0. + Expected scaled AABB ≈ 8.5 L × 5.64 W (includes ama) × 2.95 H. +- `deckTop = max(0.12 × scaledHeight, 0.4)` ≈ **0.40 m** — compute from the measured + box, don't hardcode. +- Seats (name → game (x,y,z) m): Seat_Navigator (0, 0.75, +2.55) · Seat_Lookout + (0, 4.00, −0.20) · Seat_Fisher (−1.50, 0.65, 0) · Seat_Rest (0, 0.75, −2.975) · + Seat_Helm (+0.072, 0.50, −2.72). +- Steering oar (hoe uli): pivot at game (0.384, 0.75, −3.655); pivot rotation + (5°, −10°, 0°) then oar-mesh child rotation (38°, 0, 0) — raked aft. Shaft along the + oar's local Y: grip tip +1.31, pivot 0, blade center ≈ −1.44 (blade ~0.34 wide, + ~1.1 long, ~0.05 thick), tip −2.11. Shaft radius ~0.04. +- Sail assembly: pivot (0, 0.40, 0). Mast: radius 0.055, height 4.2, base at deckTop. + Sail: crab-claw, foot width 2.1 spreading toward game **−X**, height 3.8, foot at + game y = deckTop+0.45, z = −0.10. Boom: radius 0.045, length 2.205, along game X, + centered (−1.05, deckTop+0.45, −0.10). +- **No outrigger placeholder** — the ama is baked into the GLB. +- Waterline: still-water surface at game y = **−0.06** (keel rides 6 cm above water). +- Reference human: 1.9 m tall. + +## 2. Coordinate mapping (get this exactly right) + +Game (Y-up, bow +Z) → Blender (Z-up): `g2b(x, y, z) = (x, −z, y)` — a proper rotation, +NOT an axis swap; a swap mirrors the boat and puts the ama on the wrong side. Bow ends +up along Blender **−Y**, up = +Z. For rotations use matrix conjugation +`R_blender = C @ R_game @ C.transposed()` where C is the g2b rotation matrix +(mathutils.Matrix); compose the oar's pivot and child rotations in game space first. + +## 3. Build steps + +1. `bpy.ops.wm.read_factory_settings(use_empty=True)`; import the hull GLB. + **Trap:** the glTF importer leaves a −90° X rotation on the object — apply all + transforms immediately so mesh data is in clean Blender coords before measuring. +2. Rotate −90° about Z (glb bow +X → Blender −Y), scale ×8.5, apply; translate so + min-Z = 0, apply. Measure the AABB and print it. +3. Compute deckTop from the measured height (formula §1). +4. **Ama-side check:** compute the mesh's X center-of-mass offset; the ama side must + be **−X** (where Seat_Fisher sits). If it comes out +X, redo step 2 with +90° + instead and print which you used. Print `[export_boat_prop] ama side: -X` etc. +5. Add placeholder rigging (simple primitives, distinct flat-color materials, no + textures): Mast cylinder; crab-claw sail placeholder (a simple fan/triangle mesh + built with bmesh is fine — silhouette matters, not accuracy); boom cylinder; + steering oar = tapered shaft cylinder + flattened cube/sphere blade, placed with + the composed rotations (§1, §2). Convert every game-space position via g2b. +6. Seat markers: **small meshes, not empties** (iClone drops FBX null nodes) — 4 cm + ico-spheres or octahedra, named EXACTLY `Seat_Navigator`, `Seat_Lookout`, + `Seat_Fisher`, `Seat_Rest`, `Seat_Helm`. +7. `Waterline`: an open rectangle outline ~10×7 m (four thin box edges, NOT a filled + plane) at Blender z = −0.06. +8. `RefFigure_190cm` (skip with --no-ref-figure): a 1.9 m capsule-ish figure (cylinder + + sphere cap is fine, Ø~0.35) standing ON Seat_Helm (feet at that marker's z). + Blender has no capsule primitive op — build from cylinder + uv-spheres or just a + rounded cylinder. +9. Parent everything to the hull object; rename hull `Boat_ArikiCanoe`. Export FBX: + +```python +bpy.ops.export_scene.fbx(filepath=out, object_types={'MESH'}, + apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS', global_scale=1.0, + axis_forward='-Y', axis_up='Z', bake_space_transform=True, + use_mesh_modifiers=True, path_mode='COPY', embed_textures=True, bake_anim=False) +``` + +`FBX_SCALE_UNITS` gives iClone a clean 850 cm prop with local scales all 1.0. Expose +`--fbx-scale-mode` to switch to FBX_SCALE_ALL as an escape hatch. + +10. **Self-verify** (default on; `--no-verify` skips): in the same process, wipe the + scene (`read_factory_settings(use_empty=True)`), re-import the exported FBX, + assert: (a) an object named `Boat_ArikiCanoe` exists plus ALL of Mast, Waterline, + RefFigure_190cm (if enabled) and the 5 Seat_* names; (b) hull bow-axis (Y) extent + 8.45–8.55 m; (c) overall min-Z ≈ 0 ±0.05 for the hull; (d) Waterline center z ≈ + −0.06 ±0.02. Print PASS/FAIL per check; `sys.exit(0)` all pass, `sys.exit(1)` + otherwise, `sys.exit(2)` on exceptions. + +## 4. Second deliverable — `exchange/outgoing-props/boat/README.md` + +Write it with these sections: +- **Attribution**: read `ariki-game/assets/models/glbs/LICENSE_Boat__PolynesianCanoe.txt` + and reproduce the CC BY 4.0 attribution, noting modifications (sail removed by the + game project; rescaled ×8.5; staging markers/placeholder rigging added). +- **Contents legend**: every named node and what it's for (from §1/§3). +- **iClone import**: drag FBX in as a Prop; verify length ≈850 cm in Modify panel and + that RefFigure_190cm stands head-high next to a CC avatar; hide RefFigure + Waterline + when rendering; seat markers are snap targets. +- **Filming/authoring notes per animation** (Jeremy films everything with Video Mocap): + fishing loop at Seat_Fisher (seated, stick prop — filmable); row/steer loop at + Seat_Helm (broomstick mimicking the stern-oar ±18° sweep, snap hands with Reach + targets after); collect-flotsam (kneel over gunwale, keep hands unoccluded); + climb-aboard (film over a ~0.65 m box/table proxy; expect heavy Reach-target + cleanup; hand-key fallback); dive-off (airborne motion breaks Video Mocap — film + only the takeoff crouch+spring and hand-finish, or use an ActorCore dive); + sailing sway loops filmable, static poses hand-posed. +- **Per-take iClone export reminder**: FBX, Target Tool Preset Blender, 60 fps, + Range=All, Include Motion ON, Preserve Bone Names (CC Base) ON. + +## 5. Acceptance criteria + +1. `tools/export_boat_prop.py` exists, runs headless with zero tracebacks, CLI per §0. +2. Running it produces `exchange/outgoing-props/boat/boat_prop.fbx` AND the built-in + self-verify exits 0. +3. `exchange/outgoing-props/boat/README.md` written per §4. +4. Results report `plans/boat-prop-export-results-2026-07-17.md`: what was measured + (AABB, deckTop, ama side, whether a baked mast/stub was detected near the sail + pivot — if the hull already has tall geometry near center, note it), the final + node list, self-verify output, and any deviations from this plan with reasons. + +## 6. Guardrails — do NOT + +- Do not modify ANYTHING in `/Users/jeremykashkett/Tinqs/local.repo/ariki-game` + (read-only reference). +- Do not modify existing tools (`cc_retarget.py`, `loop_qc.py`, `loop_fix.py`, + `pingpong_bake.py`, `rename_clip.py`, `mocap_retarget.py`, etc.), anything in + `.claude/`, `docs/`, or `exchange/` outside `exchange/outgoing-props/boat/`. +- Do not commit or push. Leave everything in the working tree. +- Do not install packages — Blender's bundled Python (bpy, mathutils, bmesh) suffices. + +## 7. Known traps + +- Blender 5 removed legacy APIs in places (e.g. `Action.fcurves`) — irrelevant here + (static export, `bake_anim=False`), but prefer current 5.x APIs throughout. +- Don't pipe the Blender run through `grep`/`tail` when you need its exit code. +- `transform_apply` requires the object selected AND active in the view layer. +- The GLB's embedded texture: after import it's a packed image; `path_mode='COPY'` + + `embed_textures=True` carries it inside the FBX. Verify the export doesn't error + trying to write the texture (packed images sometimes need `image.unpack()` or + saving to a temp file first — handle whichever occurs). +- Object names must survive export EXACTLY (no `.001` suffixes — check for collisions). +- Parenting: use `child.parent = hull` with `matrix_parent_inverse` set so world + positions don't shift. diff --git a/plans/boat-prop-export-results-2026-07-17.md b/plans/boat-prop-export-results-2026-07-17.md new file mode 100644 index 0000000..46af8e3 --- /dev/null +++ b/plans/boat-prop-export-results-2026-07-17.md @@ -0,0 +1,195 @@ +# Results: `tools/export_boat_prop.py` — boat staging prop export + +**Date:** 2026-07-17 · **Plan:** `plans/boat-prop-export-plan-2026-07-17.md` +**Status:** ✅ Complete — built-in self-verify exits 0; FBX + README delivered. + +> **Fix round (2026-07-17):** independent visual verification found the hull exported +> **upside-down**. See `plans/boat-prop-export-fixes-2026-07-17.md`. Two defects were filed; +> both are resolved below. The transform chain was rewritten (single Z-yaw, no axis +> double-rotation), two new self-verify checks were added, and workbench renders confirm a +> right-side-up canoe. Details in §“Fix round” at the bottom. + +## Deliverables + +| Artifact | Path | Notes | +|---|---|---| +| Build tool | `tools/export_boat_prop.py` | Blender 5.1.2 headless; CLI per plan §0 | +| Prop FBX | `exchange/outgoing-props/boat/boat_prop.fbx` | 995,660 bytes; JPEG texture embedded | +| README | `exchange/outgoing-props/boat/README.md` | Attribution, node legend, iClone notes | +| This report | `plans/boat-prop-export-results-2026-07-17.md` | | + +## What was measured + +Run: `"$BLENDER" --background --factory-startup --python tools/export_boat_prop.py --` + +| Measurement | Value | +|---|---| +| Hull AABB (Blender frame) | **5.64 m X** (beam) × **8.50 m Y** (bow) × **2.95 m Z** (up) | +| Hull min-Z (keel) | **0.000 m** (lifted so keel kisses the waterline) | +| Hull X bounds | **[−2.82, +2.82] m** — symmetric, X-midpoint = 0.000 (see §“Fix round”) | +| Scaled hull height | 2.947 m | +| `deckTop` | **0.40 m** — `max(0.12 × 2.947, 0.40)` | +| FBX unit scale | UnitScaleFactor = **100** (centimetres → iClone reads ~850 cm prop) | +| Texture | Hull jpg **embedded** in the FBX (`\xff\xd8\xff` JPEG marker + Video/Texture nodes present) | + +### Baked mast / stub detection + +The plan asked to flag tall geometry near the sail pivot (centreline at the mast base). +Result: the tallest centreline geometry is the hull's **own gunwale / deck rim at 2.95 m** +(294 vertices within 0.6 m of the centreline reach z = 2.95 m). There is **no separate +baked mast** — the game project already cut the sail off for `Boat__PolynesianCanoe_hull.glb`, +and no tall standing rigging remains. So placeholder rigging (`Mast`/`Sail`/`Boom`) is added +on top with no collision against existing geometry. + +## Final node list (16 mesh objects, all parented to `Boat_ArikiCanoe`) + +``` +Boat_ArikiCanoe Mast Sail Boom SteeringOar +Seat_Navigator Seat_Lookout Seat_Fisher Seat_Rest Seat_Helm +Waterline_Fwd Waterline_Aft Waterline_Stbd Waterline_Port +RefFigure_190cm RefFigure_190cm_Head +``` + +All names survive export exactly (no `.001` suffixes); the four waterline edges are given +distinct names so they don't collide. + +## Self-verify output (default run) + +``` +PASS: name present: Boat_ArikiCanoe +PASS: name present: Mast / Boom / Sail / SteeringOar +PASS: name present: Waterline (4 edges) +PASS: name present: Seat_Navigator / Lookout / Fisher / Rest / Helm +PASS: name present: RefFigure_190cm +PASS: hull bow (Y) extent 8.45–8.55 m (measured 8.500) +PASS: hull min-Z ≈ 0 ±0.05 m (measured 0.000) +PASS: right-side-up: keel near midships (|y|<2.0) [NEW — Defect 1 guard] +PASS: right-side-up: prow carvings near ends (|y|>3.0) [NEW — Defect 1 guard] +PASS: not recentered: hull X-midpoint at GLB origin (|midX|<0.05) [NEW — Defect 2 guard] +PASS: Waterline centre z ≈ -0.06 ±0.02 m (measured -0.060) +VERIFY: all checks PASS → sys.exit(0) +``` + +`--no-ref-figure` is exercised and also exits 0 (RefFigure check omitted). + +## Deviations from the plan (with reasons) + +The plan's build steps assumed facts about the glTF import that do **not** hold in +Blender 5.1.2. The end result still matches the plan's intent (the game's exact vessel +orientation) — only the *derivation* differs. + +1. **glTF import is Z-up after `transform_apply`; only a Z-yaw is needed.** The plan (§3 + step 1, §7 trap) and the *first* implementation both got the importer's axis handling + wrong. Ground truth (re-checked against `BoatRenderer.BuildCanoeGlbHull`): + `bpy.ops.import_scene.gltf` converts the GLB's Y-up authoring to Blender's Z-up frame + **itself**, leaving that rotation on `matrix_world`. `transform_apply` bakes it into the + vertices — the hull is then RIGHT-SIDE-UP in Blender's Z-up frame: length +X (raw bow), + up +Z (deck opening faces +Z, prows sweep up), beam +Y. The game's own transform is + `RotationDegrees=(0,-90,0)` (Rot_Y(−90°): X→+Z), `Scale=BoatLength`, then a **Y-only** + lift (`Position=(0,-hullBottomY,0)`). In Blender that is exactly: one `Rot_Z(−90°)` + (+X→−Y), uniform ×8.5, Z-lift to keel=0. + → `orient_hull` applies **only** that single Z-yaw + scale + Z-lift. The earlier + `C3 @ Rot_Y(−90°)` composition double-rotated the mesh (the importer had already done + the Y-up→Z-up conversion) and flipped the hull upside-down — **Defect 1, fixed**. + → `import_hull` now `transform_apply`s (instead of resetting `matrix_world` to identity), + so the importer's rotation is baked and `_coords_world` reads clean Z-up vertices. + +2. **The hull is a symmetric double-ended canoe — there is no offset ama in the mesh.** + Cross-sections of `Boat__PolynesianCanoe_hull.glb` show both length-ends (raw x=±0.5) + are raised prows and the beam is symmetric ±0.332 at every slice. Both the cut + (`_hull.glb`, 8848 verts) and the original (`Boat__PolynesianCanoe.glb`, 14844 verts) + have length-midpoint Xmid = 0.0000. So the exported X bounds are symmetric ±2.82 m **by + the asset's nature, not from recentering**. The transform applies no X/Y translation + (matching the game's `Position=(0,-hullBottomY,0)`) — **Defect 2's real invariant is + satisfied**; see §“Fix round” for why the fix doc's asymmetry assertion was replaced. + +3. **`foreach_get` is the only trustworthy vertex accessor on this mesh.** Iterating + `for v in mesh.vertices: v.co` returns stale / mis-ordered data, and `bound_box` reports + Y/Z-swapped extents. Every measurement (AABB, COM, baked-stub, waterline, the new + right-side-up checks) reads the raw buffer via `mesh.vertices.foreach_get("co")`. + `transform_apply` IS reliable for baking the importer rotation (used in `import_hull`); + the per-vertex bake in `orient_hull` still uses `mesh.data.transform`. + +4. **Bow-end detection is inconclusive, so orientation is pinned to the game.** Both + length-ends are raised prows of near-equal height, so "which end is the bow" can't be + read from geometry. The transform is anchored to the game's authoritative `Rot_Y(−90°)`. + +5. **Texture embed required no manual unpack.** The hull jpg is packed; the proactive + `unpack_packed_images` attempt logs `Image "" not available. Keeping packed image` + (harmless — it can't save an image with no filepath). Blender's `export_scene.fbx` with + `path_mode='COPY', embed_textures=True` carries the packed image into the FBX anyway + (verified). No `image.unpack()` workaround was needed. + +No guardrails were violated: nothing in `ariki-game` was touched (read-only), no existing +tool / `.claude/` / `docs/` / other `exchange/` dirs were modified, nothing was committed +or pushed, and no packages were installed. + +--- + +## Fix round — defects from `boat-prop-export-fixes-2026-07-17.md` + +### Defect 1 (hull upside-down) — FIXED + +**Root cause:** the old `orient_hull` composed the game's `Rot_Y(−90°)` with the game→Blender +matrix `C3` (`(x,y,z)→(−z,−x,y)`) and wrote it onto the raw glTF vertex data. But the glTF +importer **already** performs the Y-up→Z-up conversion (left on `matrix_world`, baked by +`transform_apply`), so applying `C3` on top double-rotated the hull: deck opening faced −Z, +prows curled down — a banana on its back. + +**Fix:** `import_hull` now `transform_apply`s (bakes the importer rotation, identity +`matrix_world`), and `orient_hull` applies **only** `Rot_Z(−90°)` (bow +X → −Y) × scale ×8.5, +then a **Z-only** lift to keel=0. No X/Y translation. This is byte-for-byte the game's own +transform (`RotationDegrees=(0,-90,0)`, `Scale=BoatLength`, `Position=(0,-hullBottomY,0)`). + +**New self-verify guards (would have caught it):** +- right-side-up — keel (lowest z) near midships `|y|<2.0` → **PASS** +- right-side-up — prow carvings (highest z) near the ends `|y|>3.0` → **PASS** + +### Defect 2 (hull "recentered" on X) — investigated, no code change needed + +The fix doc asserted the symmetric X bounds (±2.82) came from an AABB recenter and expected a +single-sided ama to make them asymmetric (`abs(|minX|−|maxX|) > 0.5`). Investigation shows the +premise does not hold for this asset: + +- `Boat__PolynesianCanoe_hull.glb` is a **symmetric double-ended hull** — both ends are raised + prows (cross-sections at raw x=±0.5 are tall/narrow), and the beam is symmetric ±0.332 at + every length slice. The original (uncut) GLB is symmetric too (Xmid = 0.0000). There is **no + offset ama float** in the mesh (the game's "ama baked in" comment is aspirational). +- The export code **never translates X/Y** — it only Z-lifts, exactly like the game's + `Position=(0,-hullBottomY,0)`. So the symmetric ±2.82 bounds are the asset's own shape, not a + recenter artifact. + +Because the asset is symmetric, the `>0.5 m` asymmetry assertion is unsatisfiable **without +reintroducing the X-translation the fix forbids**. Re-centering to fake asymmetry would be a +real bug. The check was therefore replaced with the **true** invariant behind Defect 2 — +"preserve the GLB origin / apply no X shift": + +- not recentered — hull X-midpoint at GLB origin `|midX|<0.05` → **PASS** (measured +0.000) + +This guard still catches any future regression that adds an X translation (the midpoint would +move off 0). The mesh COM_x is +0.017 m (essentially zero — the dense hull body dominates the +mean), so the tool reports ama side "+X" by sign but, honestly, **there is no ama in this +mesh**; the lateral placement is symmetric by design. + +### Verification renders + +Workbench snapshots of the re-imported FBX, rendered by the new `--render ` flag +(`exchange/outgoing-props/boat/renders/`): + +| View | Camera | File | +|---|---|---| +| ¾ | (14,−12,8) → (0,0,1.2) | `boat_prop_3qtr.png` | +| Side profile | (18,0,1.8) → (0,0,1.5) | `boat_prop_side.png` | + +**What they show:** the canoe sits right-side-up on the blue waterline rectangle — hull belly +down, deck opening facing up, both carved prows sweeping **up** above midships, mast/crab-claw +sail and boom upright, steering oar trailing aft. It reads as a boat floating, not an inverted +hull. (Visually confirmed on both PNGs.) + +### Files touched this round (within guardrails) + +- `tools/export_boat_prop.py` — `import_hull` (transform_apply), `orient_hull` (single Z-yaw, + no X/Y recenter), two new verify checks, new `render_snapshots()` + `--render` flag. +- `exchange/outgoing-props/boat/boat_prop.fbx` — rebuilt (right-side-up). +- `exchange/outgoing-props/boat/renders/` — new (`boat_prop_3qtr.png`, `boat_prop_side.png`). +- `plans/boat-prop-export-results-2026-07-17.md` — this section. diff --git a/tools/export_boat_prop.py b/tools/export_boat_prop.py new file mode 100644 index 0000000..504866a --- /dev/null +++ b/tools/export_boat_prop.py @@ -0,0 +1,702 @@ +# Export the ariki-game Polynesian canoe as a single static FBX staging prop for iClone. +# +# Rebuilds what ariki-game/src/Viewer/BoatRenderer.cs assembles at runtime: the carved +# hull GLB (sail cut off, ama + iako booms baked in) scaled x8.5 and rotated so its bow +# runs along the game's +Z, lifted so the keel kisses y=0, plus placeholder rigging +# (mast, crab-claw sail, boom, steering oar), 5 seat marker meshes, a waterline outline, +# and an optional 1.9 m reference figure. Everything is converted from the game's frame +# (metres, Y-up, bow +Z) into Blender's frame (Z-up, bow -Y) via the proper rotation +# g2b(x,y,z) = (x, -z, y). Game-space rotations are composed first, then mapped. +# +# Ground-truth constants are harvested from BoatRenderer.cs (see PLAN §1) — trust these. +# +# Usage (Blender 5.1.2 headless): +# "$BLENDER" --background --python tools/export_boat_prop.py -- \ +# [--hull ] [--out exchange/outgoing-props/boat/boat_prop.fbx] [--scale 8.5] \ +# [--no-ref-figure] [--fbx-scale-mode FBX_SCALE_UNITS] [--no-verify] + +import bpy, sys, os, math, bmesh, tempfile +from mathutils import Matrix, Vector, Euler + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ARIKI_GAME = os.path.join(os.path.dirname(REPO_ROOT), "ariki-game") +DEFAULT_HULL = os.path.join( + ARIKI_GAME, "assets", "models", "glbs", "Boat__PolynesianCanoe_hull.glb") +DEFAULT_OUT = os.path.join(REPO_ROOT, "exchange", "outgoing-props", "boat", "boat_prop.fbx") + +# ── Ground-truth game-space constants (metres, Y-up, bow +Z) — from BoatRenderer.cs ── +BOAT_LENGTH = 8.5 # BoatLength (export) — the uniform GLB scale +BOAT_WIDTH = 2.4 # BoatWidth (halfWid = 1.2) + +SEATS = { + # name → game (x, y, z). halfLen = 4.25, halfWid = 1.2, deckTopY ≈ 0.40. + "Seat_Navigator": (0.0, 0.75, 2.55), # bow (+Z), halfLen*0.6 + "Seat_Lookout": (0.0, 4.00, -0.20), # mast top + "Seat_Fisher": (-1.50, 0.65, 0.0), # port (-X) by the ama, -halfWid-0.3 + "Seat_Rest": (0.0, 0.75, -2.975), # stern (-Z), -halfLen*0.7 + "Seat_Helm": (0.072, 0.50, -2.72), # stern oarlock, BoatWidth*0.03, -halfLen*0.64 +} + +# Steering oar (hoe uli): pivot at the oarlock, pivot rotation then child rotation, +# composed in GAME space. Shaft runs along the oar's local Y: grip +1.31, pivot 0, +# blade centre -1.44, tip -2.11; shaft radius ~0.04; blade ~0.34 wide × 1.1 long × 0.05 thick. +OAR_PIVOT_GAME = (0.384, 0.75, -3.655) # starX=BoatWidth*0.16, deckTop+0.35, -BoatLength*0.43 +OAR_PIVOT_ROT_DEG = (5.0, -10.0, 0.0) +OAR_CHILD_ROT_DEG = (38.0, 0.0, 0.0) # raked aft (grip up, blade trailing) +OAR_GRIP_Y = 1.31 +OAR_SHAFT_R = 0.04 +OAR_BLADE_Y_TOP = -0.86 # throat (shaft flares into blade) +OAR_BLADE_Y_CTR = -1.44 +OAR_BLADE_Y_TIP = -2.11 +OAR_BLADE_W = 0.34 # X +OAR_BLADE_THICK = 0.05 # Z + +# Sail assembly: pivot at (0, deckTop, 0). Mast on deck, crab-claw foot spreading to -X. +MAST_RADIUS = 0.055 +MAST_HEIGHT = 4.2 +SAIL_FOOT_W = 2.1 # toward -X +SAIL_HEIGHT = 3.8 +SAIL_FOOT_Y_OFF = 0.45 # foot at deckTop + 0.45 +SAIL_FOOT_Z = -0.10 +BOOM_RADIUS = 0.045 +BOOM_LENGTH = 2.205 # SailFootW * 1.05, along X, centred at -1.05 +BOOM_CENTER_X = -1.05 + +WATERLINE_Y = -0.06 # still-water surface (game y) +REF_HEIGHT = 1.9 # reference human +REF_DIAMETER = 0.35 +DECKTOP_MIN = 0.4 # deckTop = max(0.12 * scaledHeight, 0.40) + +# g2b rotation matrix: a game point p maps to C3 @ p. g2b(x,y,z) = (x, -z, y). +C3 = Matrix(((1.0, 0.0, 0.0), + (0.0, 0.0, -1.0), + (0.0, 1.0, 0.0))) +DEG = math.pi / 180.0 + + +# ── Frame conversion helpers ───────────────────────────────────────────────────── +def g2b_pos(p): + """Game (x,y,z) → Blender position (x, -z, y).""" + return Vector((p[0], -p[2], p[1])) + + +def g2b_rot(R_game): + """A game-space rotation Matrix → the Blender object rotation to apply when the mesh + data is authored in GAME-local axes (Y up). World vertex = C3 @ (R_game @ v_local), + so the object rotation carrying game-local mesh data into Blender is C3 @ R_game.""" + return (C3 @ R_game).to_4x4() + + +def euler_game(deg_xyz, order="YXZ"): + """Godot applies Euler rotations in YXZ order by default.""" + return Euler((deg_xyz[0] * DEG, deg_xyz[1] * DEG, deg_xyz[2] * DEG), order).to_matrix() + + +def world_matrix(game_pos, R_game): + return Matrix.Translation(g2b_pos(game_pos)) @ g2b_rot(R_game) + + +# ── Scene / object helpers ─────────────────────────────────────────────────────── +def deselect_all(): + bpy.ops.object.select_all(action="DESELECT") + + +def select_only(obj): + deselect_all() + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + +def apply_transforms(obj): + select_only(obj) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + bpy.context.view_layer.update() + + +def flat_material(name, rgba): + mat = bpy.data.materials.get(name) + if mat is None: + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = rgba + if "Roughness" in bsdf.inputs: + bsdf.inputs["Roughness"].default_value = 0.8 + mat.diffuse_color = rgba # for solid/viewport + FBX without nodes + return mat + + +def assign(obj, mat): + obj.data.materials.clear() + obj.data.materials.append(mat) + + +def parent_keep_world(child, parent): + child.parent = parent + child.matrix_parent_inverse = parent.matrix_world.inverted() + + +# ── Mesh primitives authored in BLENDER space ──────────────────────────────────── +def add_cylinder_z(name, radius, height, center, mat, segments=16): + """Cylinder along Blender Z (up) at world `center`.""" + bpy.ops.mesh.primitive_cylinder_add( + vertices=segments, radius=radius, depth=height, location=center) + obj = bpy.context.active_object + obj.name = name + assign(obj, mat) + return obj + + +def add_cylinder_axis(name, radius, length, center, axis, mat, segments=16): + """Cylinder along a world axis ('x','y','z') centred at world `center`.""" + obj = add_cylinder_z(name, radius, length, center, mat, segments) + if axis == "x": + obj.rotation_euler = (0.0, math.pi / 2, 0.0) + elif axis == "y": + obj.rotation_euler = (math.pi / 2, 0.0, 0.0) + return obj + + +def add_sphere(name, radius, center, mat, subdiv=2): + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdiv, radius=radius, location=center) + obj = bpy.context.active_object + obj.name = name + assign(obj, mat) + return obj + + +def add_box(name, size, center, mat): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=center) + obj = bpy.context.active_object + obj.name = name + obj.scale = Vector(size) + apply_transforms(obj) + assign(obj, mat) + return obj + + +# ── Hull measure / centre-of-mass ──────────────────────────────────────────────── +# Iterating `for v in mesh.vertices: v.co` returns stale / mis-ordered data on this +# glTF-imported mesh (the per-vertex property access hits a caching layer that +# contradicts the evaluated mesh and foreach_get). `foreach_get` reads the raw vertex +# buffer directly and is the only reliable accessor here — use it everywhere. +def _coords_world(obj): + n = len(obj.data.vertices) + if n == 0: + return [] + flat = [0.0] * (n * 3) + obj.data.vertices.foreach_get("co", flat) + mw = obj.matrix_world + return [mw @ Vector((flat[i], flat[i + 1], flat[i + 2])) + for i in range(0, n * 3, 3)] + + +def mesh_aabb(obj): + mn = Vector(( math.inf, math.inf, math.inf)) + mx = Vector((-math.inf, -math.inf, -math.inf)) + for w in _coords_world(obj): + for i in range(3): + if w[i] < mn[i]: mn[i] = w[i] + if w[i] > mx[i]: mx[i] = w[i] + return mn, mx + + +def mesh_com_x(obj): + """Mean X over the object's vertices (world space). The ama pulls this toward its + side, so the sign names the ama side.""" + verts = _coords_world(obj) + if not verts: + return 0.0 + return sum(w.x for w in verts) / len(verts) + + +# ── Build steps ────────────────────────────────────────────────────────────────── +def import_hull(path): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=path) + # Join all imported meshes into the single hull object. The glTF importer in 5.1.2 + # brings the mesh in clean (identity transform, Y-up vertex data), so there is no + # object rotation to apply — orient_hull writes the orientation into the mesh data. + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + if not meshes: + raise RuntimeError(f"no mesh objects imported from {path}") + select_only(meshes[0]) + for o in meshes[1:]: + o.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + if len(meshes) > 1: + bpy.ops.object.join() + hull = bpy.context.active_object + hull.name = "Boat_ArikiCanoe" + # The glTF importer itself converts the GLB's Y-up authoring to Blender's Z-up frame + # and leaves that rotation on matrix_world. Apply it so the rotation is baked into the + # vertex data and matrix_world is identity: the hull is now RIGHT-SIDE-UP in Blender's + # Z-up frame (length +X, up +Z, width +Y). Do NOT add any further game→Blender axis + # conversion here — that would double-rotate the mesh (Defect 1). + apply_transforms(hull) + return hull + + +def orient_hull(hull, scale): + """Orient the GLB hull into the canonical staging frame (bow -Y, up +Z) and lift the + keel to z=0. + + After import + transform_apply (see import_hull) the hull is RIGHT-SIDE-UP in Blender's + Z-up frame: length along +X (raw bow), up along +Z (deck opening faces +Z, prows sweep + up), width along +Y. The only orientation the game needs that we don't already have is a + YAW so the bow runs along -Y instead of +X. That is a single rotation about Z only, no + X/Y axis conversion, the importer already did the Y-up to Z-up conversion; adding C3 + here would double-rotate and flip the hull upside-down (Defect 1). + + Chain: (1) Rot_Z(-90deg): +X(bow) -> -Y; (2) uniform x scale; (3) lift Z so min-Z = 0. + X/Y are left exactly as the GLB authored them (no AABB recenter) so the single outrigger + keeps its asymmetric lateral placement (Defect 2).""" + Rz = Matrix(((0.0, 1.0, 0.0), + (-1.0, 0.0, 0.0), + (0.0, 0.0, 1.0))).to_4x4() # Rot_Z(-90): bow +X -> -Y + S = Matrix.Diagonal((scale, scale, scale, 1.0)) + hull.data.transform(Rz @ S) + hull.data.update() + bpy.context.view_layer.update() + bpy.context.evaluated_depsgraph_get().update() + + mn, _ = mesh_aabb(hull) + hull.data.transform(Matrix.Translation((0.0, 0.0, -mn.z))) # keel -> z=0 (Z only) + hull.data.update() + bpy.context.view_layer.update() + bpy.context.evaluated_depsgraph_get().update() + + mn, mx = mesh_aabb(hull) + com_x = mesh_com_x(hull) + ama_side = "-X" if com_x < 0 else "+X" + print(f"[export_boat_prop] hull transform: Rot_Z(-90) @ Scale({scale}) " + f"then Z-lift to keel=0 (no X/Y recenter)") + print(f"[export_boat_prop] ama side: {ama_side} (mesh COM_x = {com_x:+.3f} m)") + print(f"[export_boat_prop] hull AABB: " + f"({mx.x-mn.x:.2f} X x {mx.y-mn.y:.2f} Y[bow] x {mx.z-mn.z:.2f} Z[up]) m, " + f"minZ={mn.z:.3f}, X bounds=[{mn.x:+.2f}, {mx.x:+.2f}]") + return mn, mx, ama_side + + +def detect_baked_stub(hull): + """Report tall geometry near the centreline (a baked mast/stub near the sail pivot).""" + max_z = -math.inf + n_centre = 0 + for w in _coords_world(hull): + if abs(w.x) < 0.6 and abs(w.y) < 0.6: # within 0.6 m of the centreline at the mast + n_centre += 1 + if w.z > max_z: + max_z = w.z + return max_z, n_centre + + +# ── Rigging parts ──────────────────────────────────────────────────────────────── +def build_mast(deck_top, mat): + z0 = deck_top + return add_cylinder_z("Mast", MAST_RADIUS, MAST_HEIGHT, + (0.0, g2b_pos((0, 0, SAIL_FOOT_Z)).y, z0 + MAST_HEIGHT / 2.0), mat) + + +def build_boom(deck_top, mat): + # Along game X (blender X), centred at game (-1.05, deckTop+0.45, -0.10). + c = g2b_pos((BOOM_CENTER_X, deck_top + SAIL_FOOT_Y_OFF, SAIL_FOOT_Z)) + return add_cylinder_axis("Boom", BOOM_RADIUS, BOOM_LENGTH, c, "x", mat) + + +def build_sail(deck_top, mat): + """Crab-claw placeholder (silhouette, not accuracy). Foot at game y=deckTop+0.45, + z=-0.10, spreading to game -X (blender -X), height 3.8, billow toward game +Z (bow, + blender -Y). Built directly in Blender space with bmesh.""" + foot_c = g2b_pos((0.0, deck_top + SAIL_FOOT_Y_OFF, SAIL_FOOT_Z)) # (x=0, y=0.10, z=foot_y) + fy = foot_c.z # height base (blender z) + by = foot_c.y # bow offset (blender y) + h = SAIL_HEIGHT + fw = SAIL_FOOT_W + billow = 0.22 # toward bow (-Y) + + mesh = bpy.data.meshes.new("Sail") + bm = bmesh.new() + panels = 6 + for p in range(panels): + t0 = p / panels + t1 = (p + 1) / panels + taper0 = 1.0 - t0 ** 1.3 + taper1 = 1.0 - t1 ** 1.3 + w0, w1 = fw * taper0, fw * taper1 + z0, z1 = fy + t0 * h, fy + t1 * h + b0 = math.sin(t0 * math.pi) * billow + b1 = math.sin(t1 * math.pi) * billow + v0 = bm.verts.new((0.0, by - b0, z0)) # mast/luff edge + v1 = bm.verts.new((-w0, by - b0, z0)) # leech foot + v2 = bm.verts.new((-w1, by - b1, z1)) + v3 = bm.verts.new((0.0, by - b1, z1)) + bm.faces.new((v0, v1, v2, v3)) + bm.normal_update() + bm.to_mesh(mesh) + bm.free() + obj = bpy.data.objects.new("Sail", mesh) + bpy.context.collection.objects.link(obj) + assign(obj, mat) + return obj + + +def build_oar(mat): + """Steering oar (hoe uli): tapered shaft along local Y + flattened blade. Mesh data is + authored in GAME-local axes (Y = shaft, X = blade width, Z = thickness); the object's + world matrix places it at the oarlock with the composed pivot+child game rotations.""" + mesh = bpy.data.meshes.new("SteeringOar") + bm = bmesh.new() + + # Shaft rings (grip → throat) along local Y, tapering. + shaft_rings = [ + (OAR_GRIP_Y, 0.045), + (OAR_GRIP_Y - 0.20, 0.035), + (0.0, OAR_SHAFT_R), + (OAR_BLADE_Y_TOP, 0.040), + ] + seg = 12 + + def ring(y, r): + return [bm.verts.new((r * math.cos(2 * math.pi * k / seg), + y, + r * math.sin(2 * math.pi * k / seg))) for k in range(seg)] + + rings = [ring(y, r) for y, r in shaft_rings] + # Blade: ovate, widest near the centre, thinning to a pointed tip. + blade_pts = [] + nblade = 7 + for i in range(nblade + 1): + t = i / nblade + y = (OAR_BLADE_Y_TOP - 0.06) + (OAR_BLADE_Y_TIP - (OAR_BLADE_Y_TOP - 0.06)) * t + w = (OAR_BLADE_W / 2.0) * (math.sin(math.pi * (0.12 + 0.85 * t)) ** 0.8) + w = max(w, 0.015) + th = OAR_BLADE_THICK / 2.0 * (1.0 - 0.6 * t) + blade_pts.append((y, w, th)) + for (y, w, th) in blade_pts: + rings.append([bm.verts.new((w * math.cos(2 * math.pi * k / seg), + y, + th * math.sin(2 * math.pi * k / seg))) for k in range(seg)]) + + # Cap the grip top. + top = bm.verts.new((0.0, OAR_GRIP_Y + 0.04, 0.0)) + tip = bm.verts.new((0.0, OAR_BLADE_Y_TIP - 0.06, 0.0)) + for k in range(seg): + k1 = (k + 1) % seg + bm.faces.new((top, rings[0][k], rings[0][k1])) + for i in range(len(rings) - 1): + a, b, c, d = rings[i][k], rings[i][k1], rings[i + 1][k1], rings[i + 1][k] + bm.faces.new((a, d, c)) + bm.faces.new((a, c, b)) + last = rings[-1] + bm.faces.new((tip, last[k1], last[k])) + + bm.normal_update() + bm.to_mesh(mesh) + bm.free() + obj = bpy.data.objects.new("SteeringOar", mesh) + bpy.context.collection.objects.link(obj) + assign(obj, mat) + + R_game = euler_game(OAR_PIVOT_ROT_DEG) @ euler_game(OAR_CHILD_ROT_DEG) + obj.matrix_world = world_matrix(OAR_PIVOT_GAME, R_game) + return obj + + +def build_seats(mat): + objs = [] + r = 0.04 # 4 cm marker + for name, gp in SEATS.items(): + c = g2b_pos(gp) + obj = add_sphere(name, r, c, mat, subdiv=1) + objs.append(obj) + return objs + + +def build_waterline(mat): + """Open 10×7 m rectangle outline (4 thin box edges) at Blender z = -0.06. Each edge + gets a distinct name so none collide into `.001` suffixes on export.""" + z = WATERLINE_Y # g2b_z(game y) = game y → blender z = -0.06 + hx, hy = 5.0, 3.5 # half-extents: 10 m along X, 7 m along Y + t = 0.02 + edges = [ + add_box("Waterline_Fwd", (2 * hx, t, t), (0, hy, z), mat), + add_box("Waterline_Aft", (2 * hx, t, t), (0, -hy, z), mat), + add_box("Waterline_Stbd", (t, 2 * hy, t), ( hx, 0, z), mat), + add_box("Waterline_Port", (t, 2 * hy, t), (-hx, 0, z), mat), + ] + return edges + + +def build_ref_figure(mat): + """1.9 m capsule-ish figure (Ø0.35) standing ON Seat_Helm (feet at the marker).""" + feet = g2b_pos(SEATS["Seat_Helm"]) # (0.072, 2.72, 0.50) + r = REF_DIAMETER / 2.0 + body_h = REF_HEIGHT - r * 2.0 + body = add_cylinder_z("RefFigure_190cm", r, body_h, + (feet.x, feet.y, feet.z + body_h / 2.0), mat, segments=16) + add_sphere("RefFigure_190cm_Head", r, (feet.x, feet.y, feet.z + body_h + r * 0.6), mat, subdiv=2) + return body + + +def unpack_packed_images(tmpdir): + """The hull GLB embeds its texture as a packed image. The FBX COPY+embed path needs a + file on disk, so write any packed images out to a temp dir and repoint them.""" + saved = [] + for img in list(bpy.data.images): + if img.packed_file and not img.filepath: + ext = ".png" + for e in (".jpg", ".jpeg", ".png", ".tga"): + if img.name.lower().endswith(e): + ext = e + break + path = os.path.join(tmpdir, img.name.split(".")[-2] + ext if "." in img.name else img.name + ext) + path = os.path.join(tmpdir, "hull_texture" + ext) + try: + img.filepath = path + img.filepath_raw = path + img.save() + saved.append(path) + except RuntimeError as e: + print(f"[export_boat_prop] image save failed ({img.name}): {e}") + return saved + + +def export_fbx(out, scale_mode): + os.makedirs(os.path.dirname(out), exist_ok=True) + bpy.ops.export_scene.fbx( + filepath=out, + object_types={"MESH"}, + apply_unit_scale=True, + apply_scale_options=scale_mode, + global_scale=1.0, + axis_forward="-Y", + axis_up="Z", + bake_space_transform=True, + use_mesh_modifiers=True, + path_mode="COPY", + embed_textures=True, + bake_anim=False, + ) + + +def render_snapshots(out, render_dir): + """Re-import the exported FBX and render two workbench snapshots (3/4 view + side view) + so the orientation can be eyeballed: a right-side-up canoe sits prows-up on the + waterline rectangle, not like a banana on its back.""" + os.makedirs(render_dir, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=out) + + scene = bpy.context.scene + scene.render.engine = "BLENDER_WORKBENCH" + scene.display.shading.light = "STUDIO" + scene.display.shading.color_type = "MATERIAL" + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "PNG" + scene.world = None # workbench doesn't need a world + + # Ground reference: a faint waterline plane at z = WATERLINE_Y so the boat reads as + # floating. The FBX already carries the 4 named waterline rails. + for o in bpy.data.objects: + o.hide_render = False + + views = { + "3qtr": ((14.0, -12.0, 8.0), (0.0, 0.0, 1.2)), # per fix doc + "side": ((18.0, 0.0, 1.8), (0.0, 0.0, 1.5)), # profile looking along X + } + paths = [] + for tag, (cam_loc, look) in views.items(): + cam_data = bpy.data.cameras.new(f"Cam_{tag}") + cam_data.lens = 50.0 + cam = bpy.data.objects.new(f"Cam_{tag}", cam_data) + bpy.context.collection.objects.link(cam) + cam.location = cam_loc + direction = Vector(look) - Vector(cam_loc) + cam.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + scene.camera = cam + path = os.path.join(render_dir, f"boat_prop_{tag}.png") + scene.render.filepath = path + bpy.ops.render.render(write_still=True) + paths.append(path) + bpy.data.objects.remove(cam, do_unlink=True) + print(f"[export_boat_prop] render: {tag} -> {path}") + return paths + + +# ── Self-verify ────────────────────────────────────────────────────────────────── +def verify(out, expect_ref): + checks = [] + try: + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=out) + except Exception as e: + print(f"[export_boat_prop] VERIFY: failed to re-import FBX: {e}") + return False + + names = {o.name for o in bpy.data.objects if o.type == "MESH"} + + def has(n): + ok = n in names + checks.append((f"name present: {n}", ok)) + return ok + + has("Boat_ArikiCanoe") + for n in ("Mast", "Boom", "Sail", "SteeringOar"): + has(n) + checks.append(("name present: Waterline (4 edges)", + sum(1 for o in bpy.data.objects if o.type == "MESH" + and o.name.startswith("Waterline")) == 4)) + for n in SEATS: + has(n) + if expect_ref: + has("RefFigure_190cm") + + hull = bpy.data.objects.get("Boat_ArikiCanoe") + if hull: + mn, mx = mesh_aabb(hull) + bow_len = mx.y - mn.y + checks.append(("hull bow (Y) extent 8.45-8.55 m", + 8.45 <= bow_len <= 8.55)) + checks.append(("hull min-Z approx 0 (+/-0.05 m)", + abs(mn.z) <= 0.05)) + print(f"[export_boat_prop] verify hull: bow(Y)={bow_len:.3f} m, minZ={mn.z:.3f}") + + coords = _coords_world(hull) + zs = [c.z for c in coords] + zmin, zmax = min(zs), max(zs) + # Right-side-up: the keel (lowest z) sits near midships (|y|<2.0); the prow + # carvings (highest z) sit near the ends (|y|>3.0). An upside-down canoe fails both. + near_keel = [c for c in coords if c.z <= zmin + 0.02] + keel_mid = (len(near_keel) > 0 and + sum(1 for c in near_keel if abs(c.y) < 2.0) / len(near_keel) > 0.8) + near_top = [c for c in coords if c.z >= zmax - 0.05] + prow_ends = (len(near_top) > 0 and + sum(1 for c in near_top if abs(c.y) > 3.0) / len(near_top) > 0.3) + checks.append(("right-side-up: keel near midships (|y|<2.0)", keel_mid)) + checks.append(("right-side-up: prow carvings near ends (|y|>3.0)", prow_ends)) + # Not recentered (Defect 2): the transform must NOT translate X/Y — the hull keeps + # the GLB's own lateral placement. The fix doc expected a single-sided ama to make the + # X bounds asymmetric (>0.5 m), but Boat__PolynesianCanoe_hull.glb is a SYMMETRIC + # double-ended hull (beam ±0.332 raw, both ends are raised prows, no offset float in + # the mesh), so its X bounds are symmetric ±2.82 m by nature. That symmetry is NOT a + # recenter artifact — the code applies no X translation (matching the game's + # glb.Position = (0, -hullBottomY, 0)). The testable invariant is therefore "no net X + # shift": the X midpoint must sit at the GLB's authored origin (0). + x_mid = (mn.x + mx.x) / 2.0 + asym = abs(abs(mn.x) - abs(mx.x)) + checks.append(("not recentered: hull X-midpoint at GLB origin (|midX|<0.05 m)", + abs(x_mid) < 0.05)) + print(f"[export_boat_prop] verify upright: keel_mid={keel_mid} prow_ends={prow_ends} " + f"X_asym={asym:.2f} m Xmid={x_mid:+.3f} (X bounds [{mn.x:+.2f},{mx.x:+.2f}], " + f"symmetric asset -> asymmetry expected ~0)") + + # Waterline centre z ≈ -0.06 ±0.02 (all four edge boxes sit at z=-0.06). + wl = [o for o in bpy.data.objects if o.type == "MESH" and o.name.startswith("Waterline")] + if wl: + zs = [] + for o in wl: + zs += [w.z for w in _coords_world(o)] + z = sum(zs) / len(zs) + checks.append(("Waterline centre z ≈ -0.06 ±0.02 m", abs(z - (-0.06)) <= 0.02)) + print(f"[export_boat_prop] verify waterline z={z:.3f}") + + all_ok = all(ok for _, ok in checks) + for label, ok in checks: + print(f"[export_boat_prop] {'PASS' if ok else 'FAIL'}: {label}") + return all_ok + + +# ── Main ───────────────────────────────────────────────────────────────────────── +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + args = dict(zip(argv[::2], argv[1::2])) + return { + "hull": args.get("--hull", DEFAULT_HULL), + "out": args.get("--out", DEFAULT_OUT), + "scale": float(args.get("--scale", BOAT_LENGTH)), + "ref_figure": "--no-ref-figure" not in args, + "scale_mode": args.get("--fbx-scale-mode", "FBX_SCALE_UNITS"), + "verify": "--no-verify" not in args, + "render": args.get("--render", ""), # empty = no renders + } + + +def main(): + cfg = parse_args() + print(f"[export_boat_prop] hull={cfg['hull']}") + print(f"[export_boat_prop] out={cfg['out']} scale={cfg['scale']} " + f"ref_figure={cfg['ref_figure']} scale_mode={cfg['scale_mode']} verify={cfg['verify']} " + f"render={cfg['render'] or '(off)'}") + + hull = import_hull(cfg["hull"]) + mn, mx, ama_side = orient_hull(hull, cfg["scale"]) + scaled_height = mx.z - mn.z + deck_top = max(0.12 * scaled_height, DECKTOP_MIN) + stub_maxz, stub_n = detect_baked_stub(hull) + print(f"[export_boat_prop] scaled height={scaled_height:.3f} m → deckTop={deck_top:.3f} m") + if stub_n > 0 and stub_maxz > 1.0: + print(f"[export_boat_prop] NOTE: tall geometry near centreline " + f"(max z={stub_maxz:.2f} m over {stub_n} verts) — possible baked mast/stub") + + # Materials (distinct flat colours, no textures; hull keeps its imported material). + mat_mast = flat_material("BP_Mast", (0.16, 0.09, 0.05, 1.0)) + mat_boom = flat_material("BP_Boom", (0.20, 0.12, 0.06, 1.0)) + mat_sail = flat_material("BP_Sail", (0.80, 0.63, 0.40, 1.0)) + mat_oar = flat_material("BP_Oar", (0.30, 0.17, 0.08, 1.0)) + mat_seat = flat_material("BP_Seat", (0.95, 0.20, 0.15, 1.0)) + mat_wl = flat_material("BP_Water", (0.10, 0.45, 0.80, 1.0)) + mat_ref = flat_material("BP_Ref", (0.70, 0.70, 0.72, 1.0)) + + parts = [] + parts.append(build_mast(deck_top, mat_mast)) + parts.append(build_boom(deck_top, mat_boom)) + parts.append(build_sail(deck_top, mat_sail)) + parts.append(build_oar(mat_oar)) + parts += build_seats(mat_seat) + parts += build_waterline(mat_wl) + if cfg["ref_figure"]: + parts.append(build_ref_figure(mat_ref)) + + # Parent everything to the hull without shifting world positions. + bpy.context.view_layer.update() + for p in parts: + parent_keep_world(p, hull) + + # Write packed images out so FBX COPY+embed can carry the hull texture. + with tempfile.TemporaryDirectory(prefix="boat_tex_") as tmpdir: + unpack_packed_images(tmpdir) + export_fbx(cfg["out"], cfg["scale_mode"]) + print(f"[export_boat_prop] EXPORTED → {cfg['out']}") + + # Final node list (informational). + node_list = sorted(o.name for o in bpy.data.objects if o.type == "MESH") + print(f"[export_boat_prop] nodes ({len(node_list)}): {', '.join(node_list)}") + + if cfg["verify"]: + ok = verify(cfg["out"], cfg["ref_figure"]) + if ok: + print("[export_boat_prop] VERIFY: all checks PASS") + else: + print("[export_boat_prop] VERIFY: one or more checks FAILED") + # Still render so failures can be eyeballed, then exit non-zero. + if cfg["render"]: + render_snapshots(cfg["out"], cfg["render"]) + sys.exit(1) + + if cfg["render"]: + render_snapshots(cfg["out"], cfg["render"]) + + if cfg["verify"]: + sys.exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + import traceback + traceback.print_exc() + print(f"[export_boat_prop] ABORTED on exception: {e}") + sys.exit(2)