# 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)