Files
animation/tools/export_boat_prop.py
jeremy 4d6a4f6006 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>
2026-07-17 10:40:37 -07:00

703 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <path>] [--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)