# 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