feat(props): export game boat as iClone staging prop (boat_prop.fbx)

tools/export_boat_prop.py (GLM-implemented, 2 rounds): hull at game scale
(8.5 m, keel 0, no recenter) + placeholder mast/sail/steering oar + 5 seat
markers + waterline + 1.9 m ref figure; self-verify incl. right-side-up
gates added after round 1 shipped an upside-down hull with all checks
green. Registry: boat actions table (6 slots, direct names). README
carries CC BY 4.0 attribution + iClone import/filming notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:40:37 -07:00
parent 17ff1f748e
commit 4d6a4f6006
10 changed files with 1244 additions and 0 deletions
@@ -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.
+160
View File
@@ -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 <path>] [--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.458.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.
@@ -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.458.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 <dir>` 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.