"""Collision hulls, FBX files and RobotCell_layout.json, headless, in place: blender.exe -b ../RobotCell.blend --python rc_export.py blender.exe -b ../RobotCell_DoorArm.blend --python rc_export.py -- variant=DoorArm (the copy with the door arm: only its skeletal mesh, an assembled FBX and RobotCell_DoorArm_layout.json; the cell's own meshes and hulls are the same as RobotCell's and are not written again) The canister station's export (cs_export.py): one FBX per static/moving mesh at its own pivot with its UCX_ hulls, the robot as a skeletal mesh (armature + skinned mesh + its work cycle), and RobotCell_Assembled.fbx (everything in the picture's pose) for a quick look. Data is turned -90 degrees about Z before export, so in Unreal ue_cm = (x, -y, z) * 100 (the project's FBX pair). Hulls: convex boxes for the cell (base and floor, the column, the pod, the posts, the roof, the walls, the pedestal, the turntable's drive); the door openings stay open; each leaf and the side door have their own box, the plate a 16-sided disc, the e-stop a small one. The robot gets its physics asset in Unreal, not hulls. """ import bpy, bmesh, json, math, os, sys 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') exec(open(os.path.join(HERE, "rc_lib.py")).read()) exec(open(os.path.join(HERE, "rc_robot.py")).read()) STATIC = ["SM_RobotCell_Body"] MOVING = ["SM_RobotCell_DoorLeft", "SM_RobotCell_DoorRight", "SM_RobotCell_DoorSide", "SM_RobotCell_TurntablePlate", "SM_RobotCell_EStop"] W2, D2 = W_BOX / 2.0, D_BOX / 2.0 PX, COL_X, COL_Y, FRY = 1.386, -1.036, -0.94, -1.218 TT = Vector((0.58, -0.12)) ARGS = dict(a.split("=", 1) for a in (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) if "=" in a) VARIANT = ARGS.get("variant", "") ARM_NAME = "RC_DoorArm_Rig" if VARIANT else "RC_Robot_Rig" SK_NAME = "SK_RobotCell_DoorArm" if VARIANT else "SK_RobotCell_Robot" def coll(): top = bpy.data.collections["RobotCell"] c = bpy.data.collections.get("RC | collision") if c is None: c = bpy.data.collections.new("RC | 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(x0, x1, y0, y1, z0, z1): return [Vector((x, y, z)) for x in (x0, x1) for y in (y0, y1) for z in (z0, z1)] def disc(c, r, z0, z1, n=16): rr = r / math.cos(math.pi / n) return [Vector((c.x + rr * math.cos(2 * math.pi * k / n), c.y + rr * math.sin(2 * math.pi * k / n), z)) for k in range(n) for z in (z0, z1)] def body_hulls(): rx, ry = ROB.x, ROB.y return [ box(-W2, W2, -D2, D2, 0.0, 0.314), # base and floor (on the feet) box(-W2, COL_X, -D2, COL_Y, 0.314, 2.49), # the left column box(-1.505, COL_X, -1.59, -D2, 0.83, 1.75), # the control pod box(PX, W2, -D2, FRY, 0.314, 2.28), # front-right post box(PX, W2, 1.298, D2, 0.314, 2.28), # back-right post box(-W2, -PX, 1.298, D2, 0.314, 2.28), # back-left post box(-W2, W2, -D2, D2, 2.28, 2.49), # roof, beams, ceiling, the leaves' track box(-PX, PX, 1.356, D2, 0.314, 2.28), # back wall box(-W2, -1.496, COL_Y, 1.298, 0.314, 2.28), # left glass wall box(1.446, W2, 0.085, 1.298, 0.314, 2.28), # hinge post and the side's rear panel box(rx - 0.38, rx + 0.38, ry - 0.38, ry + 0.38, 0.314, 0.62), # the robot's pedestal box(TT.x - 0.34, TT.x + 0.34, TT.y - 0.34, TT.y + 0.34, 0.314, 0.657), # the turntable's drive ] def make_hulls(): C = coll() body = bpy.data.objects["SM_RobotCell_Body"] for i, pts in enumerate(body_hulls()): hull("UCX_SM_RobotCell_Body_%02d" % i, pts, body, C) for n in ("SM_RobotCell_DoorLeft", "SM_RobotCell_DoorRight", "SM_RobotCell_DoorSide"): ob = bpy.data.objects[n] ws = [ob.matrix_world @ v.co for v in ob.data.vertices] mats = [m.name for m in ob.data.materials] 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)] # the leaf's own slab (handles left out: they would thicken it to the next track) if n == "SM_RobotCell_DoorSide": pts = box(1.486, 1.516, lo[1], hi[1] - 0.015, 0.34, 2.215) else: y0, y1 = (-1.400, -1.370) if n.endswith("Left") else (-1.285, -1.255) pts = box(lo[0], hi[0], y0, y1, 0.32, 2.345) hull("UCX_%s_00" % n, pts, ob, C) pl = bpy.data.objects["SM_RobotCell_TurntablePlate"] hull("UCX_SM_RobotCell_TurntablePlate_00", disc(TT, 0.45, 0.66, 0.72), pl, C) es = bpy.data.objects["SM_RobotCell_EStop"] c = es.matrix_world.translation pts = [Vector((c.x + 0.036 * math.cos(2 * math.pi * k / 12), c.y - t, c.z + 0.036 * math.sin(2 * math.pi * k / 12))) for k in range(12) for t in (0.010, 0.052)] hull("UCX_SM_RobotCell_EStop_00", pts, es, C) return C def select(objs): 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] def _fbx(path, objs, types, anim=False): select(objs) 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=anim, bake_anim_use_all_actions=False, bake_anim_use_nla_strips=False, bake_anim_force_startend_keying=True, bake_anim_simplify_factor=0.0, use_armature_deform_only=True, 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 rest_sliders(): root = bpy.data.objects["RobotCell_Root"] act = root.animation_data.action if root.animation_data else None if root.animation_data: root.animation_data.action = None for k in ("door_left", "door_right", "side_door", "turntable", "estop"): root[k] = 0.0 root.update_tag() bpy.context.scene.frame_set(2) bpy.context.scene.frame_set(1) return act def export(C): os.makedirs(FBX, exist_ok=True) root = bpy.data.objects["RobotCell_Root"] act = rest_sliders() tmpc = bpy.data.collections.new("_rc_export") bpy.context.scene.collection.children.link(tmpc) lc = bpy.context.view_layer.layer_collection.children["RobotCell"].children.get("RC | collision") was_excluded = lc.exclude if lc else None if lc: lc.exclude = False written, temps = [], [] try: for name in ([] if VARIANT else STATIC + MOVING): # each mesh at its own pivot, turned -90 about Z 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 # the robot: skeleton at its own base (origin on A1 at the base's underside), turned -90, with its work cycle arm = bpy.data.objects[ARM_NAME] rob = bpy.data.objects[SK_NAME] par, mw = arm.parent, arm.matrix_world.copy() arm.parent = None arm.matrix_world = PRE bpy.context.view_layer.update() sc = bpy.context.scene scn = sc.name act_name = arm.animation_data.action.name if arm.animation_data and arm.animation_data.action else "WORK_CYCLE" sc.name = act_name.split("| ")[-1] # the FBX take is named "|": WORK_CYCLE / WELD_CYCLE _fbx(os.path.join(FBX, SK_NAME + ".fbx"), [arm, rob], {"ARMATURE", "MESH"}, anim=True) sc.name = scn written.append(SK_NAME + ".fbx") arm.matrix_world = mw arm.parent = par arm.matrix_parent_inverse = Matrix.Identity(4) arm.location = ROB # everything in the picture's pose, for a quick look bpy.context.scene.frame_set(1) 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 in ('MESH', 'ARMATURE') and not o.name.startswith("UCX_")] asm = "RobotCell%s_Assembled.fbx" % ("_" + VARIANT if VARIANT else "") _fbx(os.path.join(FBX, asm), objs, {"MESH", "EMPTY", "ARMATURE"}) written.append(asm) 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 root.animation_data.action = act return written def ue(v): return [round(v[0] * 100, 2), round(-v[1] * 100, 2), round(v[2] * 100, 2)] def ue_dir(v): return [round(v[0], 4), round(-v[1], 4), round(v[2], 4)] def layout(): obs = {n: bpy.data.objects[n] for n in STATIC + MOVING} 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 arm = bpy.data.objects[ARM_NAME] tri = lambda o: sum(len(p.vertices) - 2 for p in o.data.polygons) d = { "asset": "Robot cell: a glazed industrial cell with a six-axis robot, a turntable, a copper drain tray and a control " "pod, after the user's picture (2026-10-22, reference/ref_user.jpg). Working name.", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: on the ground (the feet's soles) under the middle " "of the cell's footprint.", "front": "the picture's front (sliding glass leaves, control pod on the left) faces Unreal +Y (Blender -Y); " "the side door is on Unreal +X", "size_cm": {"footprint": [round(W_BOX * 100, 1), round(D_BOX * 100, 1)], "height": round((FOOT + HB) * 100, 1), "floor_top": 31.4, "ceiling": 228.0, "door_opening_front": "2 x about 112 wide, 198 high", "side_door": "129 wide, 188 high"}, "static_meshes": {"SM_RobotCell_Body": {"location": [0, 0, 0], "materials": mats(obs["SM_RobotCell_Body"]), "triangles": tri(obs["SM_RobotCell_Body"]), "screen_slot": "M_RC_Screen: UV0 spans the screen 0..1 (T_RC_Screen_UI); swap it " "for a live screen", "lights": "M_RC_LED: five ceiling strips and the downlight (emissive); put point/" "rect lights at them in Unreal"}}, "moving": { "SM_RobotCell_DoorLeft": {"pivot_at_rest": ue(obs["SM_RobotCell_DoorLeft"].location), "pivot": "the leaf's bottom middle", "motion": "slides along Unreal +X by 110 cm (front track, in front of the right leaf)", "materials": mats(obs["SM_RobotCell_DoorLeft"])}, "SM_RobotCell_DoorRight": {"pivot_at_rest": ue(obs["SM_RobotCell_DoorRight"].location), "pivot": "the leaf's bottom middle", "motion": "slides along Unreal -X by 111 cm (back track, behind the left leaf)", "materials": mats(obs["SM_RobotCell_DoorRight"])}, "SM_RobotCell_DoorSide": {"pivot_at_rest": ue(obs["SM_RobotCell_DoorSide"].location), "pivot": "on the hinge axis, at the leaf's bottom", "motion": "swings out about Unreal +Z by 100 degrees (yaw -100 in Unreal's left-handed frame: " "its free edge moves out toward Unreal +X)", "materials": mats(obs["SM_RobotCell_DoorSide"])}, "SM_RobotCell_TurntablePlate": {"pivot_at_rest": ue(obs["SM_RobotCell_TurntablePlate"].location), "pivot": "on its axis, at its underside", "motion": "turns about Unreal Z, free", "materials": mats(obs["SM_RobotCell_TurntablePlate"])}, "SM_RobotCell_EStop": {"pivot_at_rest": ue(obs["SM_RobotCell_EStop"].location), "pivot": "on its axis, on the pod's face", "motion": "presses 0.7 cm along Unreal +Y (into the pod)", "materials": mats(obs["SM_RobotCell_EStop"])}, }, "robot": { "skeletal_mesh": "SK_RobotCell_Robot (FBX with its skeleton and the action Robot | WORK_CYCLE, 460 frames at 30 fps)", "place_in_cell": ue(ROB), "place_note": "the skeleton's origin: on the A1 axis at the base's underside, on the pedestal", "bones": {nm: dict(parent=par, head_ue_cm=ue(Vector(h)), axis=AXIS.get(nm), limits_deg=list(LIMITS.get(nm, ())) or None) for nm, par, h, t, r, dfm in BONES}, "joint_axes_ue_bind_pose": {"A1": [0, 0, 1], "A2": [0, -1, 0], "A3": [0, -1, 0], "A4": [1, 0, 0], "A5": [0, -1, 0], "A6": [1, 0, 0], "Tool_Spin": [1, 0, 0]}, "bind_pose": "upper arm vertical, forearm and tool straight forward (Unreal +X)", "picture_pose_deg": json.loads(arm["poses_deg"])["picture"], "work_cycle_poses_deg": json.loads(arm["poses_deg"]), "triangles": tri(bpy.data.objects[SK_NAME]), }, "demo_timeline_30fps": "robot: picture pose 1-30, tray 60-115, turntable 160-215, back by 260; turntable half a turn " "195-260; left leaf open 270-320, shut 360-410; side door open 280-330, shut 370-420; " "e-stop pressed 430-450", "checked": {"travel_clash_steps": {k: v.get("clash_steps") for k, v in chk.get("travel", {}).items()}, "robot_cycle_clash_frames": chk.get("robot_cycle", {}).get("clash_frames"), "robot_joint_sweeps_self_clash": {k: v.get("self_clash_angles") for k, v in chk.get("robot_sweep", {}).items()}}, "textures": ["T_RC_Body_Masks, T_RC_Robot_Masks (4096), T_RC_Moving_Masks (2048): UV0, non-colour, R occlusion G edges " "B crevices", "T_RC_Decals (UV0 of the decal quads, sRGB + alpha, masked)", "T_RC_Screen_UI (UV0 of the screen, sRGB, emissive)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/BrushedMetal, Base/Glass (UV1, metres)", "Poly Haven metal_plate normal / displacement / roughness (UV1, the floor, 1.10 m a repeat)"], } if VARIANT: rob = bpy.data.objects[SK_NAME] ax = json.loads(arm["axes"]) lim = json.loads(arm["limits_deg"]) d["asset"] = ("Robot cell, copy with the moon pool's door robot arm (version B, models/door-robot-arm) in place of the " "cell's robot, scaled %.2f, on a base ring on the pedestal (2026-10-22). Working name." % arm["scale_of_door_arm_B"]) d["static_meshes"] = "the same as RobotCell_layout.json (SM_RobotCell_Body and its hulls)" d["moving"] = "the same as RobotCell_layout.json (the leaves, the side door, the plate, the e-stop)" d["robot"] = { "skeletal_mesh": "SK_RobotCell_DoorArm (FBX with its skeleton and the take RC_DoorArm_Rig|WELD_CYCLE, 460 frames at 30 fps)", "tool": "a MIG welding torch of mine in place of the arm's plate: mount, insulator, collision sensor, neck clamp, " "a swan neck bent 45 degrees, copper nozzle, contact tip, 12 mm of wire; its hose to the forearm", "place_in_cell": ue(arm.matrix_world.translation), "place_note": "the skeleton's origin: on J1's axis at the turret's face, 6 cm over the pedestal (the base ring between)", "bones": {b.name: dict(parent=b.parent.name if b.parent else None, head_ue_cm=ue(b.head_local), axis=ax.get(b.name), limits_deg=lim.get(b.name), deform=b.use_deform) for b in arm.data.bones}, "j3_cylinder": "J3_Cylinder aims at J3_Pin (a helper on J3, not exported), J3_Piston's rod eye sits on it; the " "FBX take carries their baked motion; in Unreal drive them with a Look At and a Copy Bone from J3", "joint_axes_ue_bind_pose": {"J1": [0, 0, 1], "J2": [0, -1, 0], "J3": [0, -1, 0], "J4": [1, 0, 0], "J5": [0, -1, 0], "J6": [1, 0, 0]}, "bind_pose": "the door arm's rest pose: lower arm leaning back, upper arm and forearm forward, plate facing Unreal +X", "picture_pose_deg": json.loads(arm["poses_deg"])["picture"], "work_cycle_poses_deg": json.loads(arm["poses_deg"]), "triangles": tri(rob), } try: chk = json.load(open(os.path.join(HERE, "check_report_doorarm.json"))) d["checked"] = {"robot_cycle_clash_frames": chk["robot_cycle"]["clash_frames"], "robot_joint_sweeps_self_clash": {k: v["self_clash_angles"] for k, v in chk["robot_sweep"].items()}, "j3_cylinder_stroke": chk.get("j3_cylinder_stroke")} except Exception: pass d["textures"] = ["the door arm's own: models/door-robot-arm/textures, source_polyhaven (read in place)"] d["demo_timeline_30fps"] = ("torch: the picture's pose 1-30, over the weld 70, down 85, welds 85-205 while the turntable " "turns once (0 to 360 degrees), up 225, back by 260; the leaves, the side door and the " "e-stop as in RobotCell") p = os.path.join(FOLDER, "RobotCell_DoorArm_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p p = os.path.join(FOLDER, "RobotCell_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p def main(): C = bpy.data.collections.get("RC | collision") if VARIANT else make_hulls() lc = bpy.context.view_layer.layer_collection.children["RobotCell"].children.get("RC | collision") if lc: lc.exclude = True written = export(C) p = layout() bpy.context.scene.frame_set(1) bpy.ops.wm.save_mainfile() print("EXPORT OK", written, p) main()