"""Collision hulls, FBX files and CanisterStation_layout.json, headless, in place: blender.exe -b ../CanisterStation.blend --python cs_export.py The feeder's export (models/nutrition-feeder/build/nf_export.py): one FBX per mesh at its own pivot with its UCX_ hulls, plus CanisterStation_Assembled.fbx (canister seated) for a quick look. Mesh data is turned -90 degrees about Z before export, so in Unreal ue_cm = (x, -y, z) * 100 (the project's FBX pair). Hulls stay out of the canister's path: the body's boxes end short of it, the rail's box sits over it, the clamps and the column's cradle under it have none (the canister's own hull meets the player). """ import bpy, bmesh, json, math, os from mathutils import Matrix, Vector HERE = os.path.dirname(os.path.abspath(__file__)) FOLDER = os.path.dirname(HERE) FBX = os.path.join(FOLDER, "fbx") PRE = Matrix.Rotation(-math.pi / 2, 4, 'Z') PARTS = ["SM_CanisterStation_Body", "SM_CanisterStation_Canister"] exec(open(os.path.join(HERE, "cs_lib.py")).read()) fit_ground(700, 1244, -0.20) # the camera of cs_build.py, for pixel boxes _x0 = CAM.on_y(471, 1244, 0.0).x CAM = Camera(CAM.T.z, -_x0) R = json.load(open(os.path.join(HERE, "build_report.json"))) AX0 = Vector(R["axis"]["ring_centre"]) CA = Vector(R["axis"]["direction"]).normalized() CD1 = (Vector((0, 0, 1)) - CA * CA.z).normalized() def coll(): top = bpy.data.collections["CanisterStation"] c = bpy.data.collections.get("CS | collision") if c is None: c = bpy.data.collections.new("CS | collision") top.children.link(c) for o in list(c.objects): bpy.data.objects.remove(o) return c def hull(name, pts, owner, C): """convex hull of world points, stored in the owner's local frame""" bm = bmesh.new() vs = [bm.verts.new(p) for p in pts] bmesh.ops.convex_hull(bm, input=vs) bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS') me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name) me.clear_geometry() bm.to_mesh(me) bm.free() me.transform(owner.matrix_world.inverted()) ob = bpy.data.objects.get(name) or bpy.data.objects.new(name, me) ob.data = me C.objects.link(ob) ob.parent = owner ob.matrix_parent_inverse = Matrix.Identity(4) ob.matrix_basis = Matrix.Identity(4) ob.display_type = 'WIRE' ob.hide_render = True return ob def box_px(u0, v0, u1, v1, y0, y1, ground=False): pts = [Pw(u, v, y) for u in (u0, u1) for v in (v0, v1) for y in (y0, y1)] if ground: pts = [Vector((p.x, p.y, max(p.z, 0.0))) for p in pts] return pts def axis_box(t0, t1, p0, p1, y0, y1): return [AX0 + CA * t + CD1 * p + Vector((0, y, 0)) for t in (t0, t1) for p in (p0, p1) for y in (y0, y1)] def body_hulls(): col_top = lambda x: (AX0 + CD1 * -0.25 + CA * ((x - (AX0 + CD1 * -0.25).x) / CA.x)) xa, xb = CAM.on_y(600, 1240, -0.15).x, CAM.on_y(790, 1240, -0.15).x col = [Vector((x, y, z)) for x in (xa, xb) for y in (-0.15, 0.15) for z in (0.0, col_top(x).z)] return [ box_px(220, 210, 505, 520, -0.20, 0.20), # tower top with the head's root box_px(445, 210, 575, 395, -0.15, 0.15), # the head box_px(200, 520, 440, 870, -0.20, 0.20), # tower column (short of the canister's foot) box_px(140, 870, 480, 1245, -0.225, 0.225, True), # base, left of the socket, with the hubs box_px(480, 905, 612, 1245, -0.225, 0.225, True), # base under the socket col, # screen column core, under the canister's cradle box_px(600, 950, 862, 1245, -0.22, 0.22, True), # green boxes, pillars, plates box_px(80, 1200, 352, 1245, -0.225, 0.225, True), # foot plate axis_box(-0.86, -0.30, 0.222, 0.322, -0.095, 0.095), # cradle rail over the canister ] def make_hulls(): C = coll() body = bpy.data.objects["SM_CanisterStation_Body"] for i, pts in enumerate(body_hulls()): hull("UCX_SM_CanisterStation_Body_%02d" % i, pts, body, C) can = bpy.data.objects["SM_CanisterStation_Canister"] mw = can.matrix_world pts = [mw @ v.co for v in can.data.vertices] hull("UCX_SM_CanisterStation_Canister_00", pts, can, C) # the hull of a 48-sided canister is dense: keep the hull of a 16-sided ring at each end instead me = bpy.data.objects["UCX_SM_CanisterStation_Canister_00"].data seated = Vector(can["closed_location"]) ring = [] rmax = 0.2265 A, d1, d2 = frame_axes(CA) for t in (T_BOT, 0.072 + 0.002): for k in range(16): a = 2 * math.pi * k / 16 ring.append(AX0 + CA * t + (rmax / math.cos(math.pi / 16)) * (math.cos(a) * d1 + math.sin(a) * d2) - (seated - mw.translation)) bpy.data.objects.remove(bpy.data.objects["UCX_SM_CanisterStation_Canister_00"]) bpy.data.meshes.remove(me) hull("UCX_SM_CanisterStation_Canister_00", ring, can, C) return C def _fbx(path, objs, types): for o in bpy.context.view_layer.objects: o.select_set(False) for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] bpy.ops.export_scene.fbx(filepath=path, use_selection=True, object_types=types, use_mesh_modifiers=False, mesh_smooth_type="OFF", add_leaf_bones=False, bake_anim=False, axis_forward="-Y", axis_up="Z", global_scale=1.0, apply_unit_scale=True, apply_scale_options="FBX_SCALE_NONE", use_space_transform=True, bake_space_transform=False, path_mode="STRIP", use_metadata=False, use_tspace=True) def set_eject(v): root = bpy.data.objects["CanisterStation_Root"] if root.animation_data: root.animation_data.action = None root["eject"] = v root.update_tag() bpy.context.scene.frame_set(2) bpy.context.scene.frame_set(1) def export(C): os.makedirs(FBX, exist_ok=True) set_eject(0.0) tmpc = bpy.data.collections.new("_cs_export") bpy.context.scene.collection.children.link(tmpc) lc = bpy.context.view_layer.layer_collection.children["CanisterStation"].children.get("CS | collision") was_excluded = lc.exclude if lc else None if lc: lc.exclude = False written, temps = [], [] try: for name in PARTS: src = bpy.data.objects[name] me = src.data.copy() me.transform(PRE) t = bpy.data.objects.new("_tmp_" + name, me) tmpc.objects.link(t) temps.append(t) objs = [t] for h in [o for o in C.objects if o.name.startswith("UCX_" + name + "_")]: hm = h.data.copy() hm.transform(PRE) ht = bpy.data.objects.new("_tmp_" + h.name, hm) tmpc.objects.link(ht) temps.append(ht) objs.append(ht) real = {} for o in objs: want = name if o is t else o.name.replace("_tmp_", "", 1) holder = bpy.data.objects.get(want) if holder is not None and holder is not o: real[holder] = want holder.name = want + "__hold" o.name = want bpy.context.view_layer.update() path = os.path.join(FBX, name + ".fbx") _fbx(path, objs, {"MESH"}) written.append(os.path.basename(path)) for o in objs: o.name = "_tmp_" + o.name for holder, want in real.items(): holder.name = want root = bpy.data.objects["CanisterStation_Root"] rz = root.rotation_euler.z root.rotation_euler.z = -math.pi / 2 bpy.context.view_layer.update() objs = [root] + [o for o in root.children_recursive if o.type == 'MESH' and not o.name.startswith("UCX_")] _fbx(os.path.join(FBX, "CanisterStation_Assembled.fbx"), objs, {"MESH", "EMPTY"}) written.append("CanisterStation_Assembled.fbx") root.rotation_euler.z = rz bpy.context.view_layer.update() finally: for o in temps: me = o.data bpy.data.objects.remove(o) if me.users == 0: bpy.data.meshes.remove(me) bpy.data.collections.remove(tmpc) if lc is not None and was_excluded is not None: lc.exclude = True return written def ue(v): return [round(v[0] * 100, 2), round(-v[1] * 100, 2), round(v[2] * 100, 2)] def layout(): body = bpy.data.objects["SM_CanisterStation_Body"] can = bpy.data.objects["SM_CanisterStation_Canister"] mats = lambda o: [m.name for m in o.data.materials] chk = {} try: chk = json.load(open(os.path.join(HERE, "check_report.json"))) except Exception: pass ws = [body.matrix_world @ v.co for v in body.data.vertices] lo = [min(p[i] for p in ws) for i in range(3)] hi = [max(p[i] for p in ws) for i in range(3)] cw = [can.matrix_world @ v.co for v in can.data.vertices] top = max(p.z for p in cw) d = { "asset": "Radiation canister station: a dark green and black machine holding a radiation canister in a cradle " "rail at 47 degrees, after the user's picture (2026-10-21, reference/ref_user.png). Working name.", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: on the ground, under the middle of the base's footprint " "(left to right in the picture), in the machine's middle plane.", "front": "the side the picture shows (with the screen) faces Unreal +Y (Blender -Y); the canister leans toward Unreal +X", "size_cm": {"body_bbox_min": [round(lo[0] * 100, 1), round(-hi[1] * 100, 1), round(lo[2] * 100, 1)], "body_bbox_max": [round(hi[0] * 100, 1), round(-lo[1] * 100, 1), round(hi[2] * 100, 1)], "canister_top_cm": round(top * 100, 1), "canister_diameter": 40.0, "canister_length": 103.5}, "static_meshes": {"SM_CanisterStation_Body": {"location": [0, 0, 0], "materials": mats(body), "screen_slot": "M_CS_Screen: UV0 spans the glass 0..1 (the UI texture); " "swap this slot's material for a live screen"}}, "moving": { "SM_CanisterStation_Canister": { "pivot_at_rest": ue(Vector(can["closed_location"])), "pivot": "on the canister's axis at its bottom end", "axis_direction_ue": [round(CA.x, 4), round(-CA.y, 4), round(CA.z, 4)], "motion": "slides out along its own axis (up and toward Unreal +X) %.0f cm; seated it stands 5 mm over the " "socket" % (EJECT_M * 100), "materials": mats(can)}, }, "sequence_eject_0_to_1": {"easing": "smoothstep", "blender_timeline": "seated 1, out 60, held to 120, seated 180 at 30 fps", "picture_pose": "eject 0 (seated)"}, "lights": "M_CS_Glow: the ring between the canister's body and its cap (emissive, colour from T_CS_GlowRing along " "UV0 u); M_CS_Screen: the status screen (emissive)", "checked": {"clashes_through_travel": chk.get("clashes"), "smallest_gap_mm": chk.get("smallest_gap_mm")}, "textures": ["T_CS_Body_Masks, T_CS_Canister_Masks (UV0, non-colour: R occlusion, G edges, B crevices)", "T_CS_Screen_UI (UV0 of the glass, sRGB, emissive)", "T_CS_Radiation (UV0 of the decal patch, sRGB + alpha, masked)", "T_CS_GlowRing (UV0 of the ring, sRGB, emissive)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/BrushedMetal, Base/Glass (UV1, metres)"], } p = os.path.join(FOLDER, "CanisterStation_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p T_BOT = -0.935 EJECT_M = 0.32 def main(): C = make_hulls() lc = bpy.context.view_layer.layer_collection.children["CanisterStation"].children.get("CS | collision") if lc: lc.exclude = True written = export(C) p = layout() bpy.ops.wm.save_mainfile() print("EXPORT OK", written, p) main()