"""Collision hulls, FBX files and NutritionVat_layout.json, headless, in place: blender.exe -b ../NutritionVat.blend --python nv_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 NutritionVat_Assembled.fbx (everything, both canisters and the hoses) 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). """ 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') exec(open(os.path.join(HERE, "nv_lib.py")).read()) PARTS = ["SM_NutritionVat_Body", "SM_NutritionVat_Fluid", "SM_NutritionVat_Canister", "SM_NutritionVat_Hoses"] CAN_X, CAN_U = 47.0, 580.0 ARM_C, ARM_RC, ARM_HW, ARM_T = (480.0, 330.0), 122.0, 27.0, 30.0 def coll(): top = bpy.data.collections["NutritionVat"] c = bpy.data.collections.get("NV | collision") if c is None: c = bpy.data.collections.new("NV | 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, u0, u1, v0, v1): return [P(x, u, v) for x in (x0, x1) for u in (u0, u1) for v in (v0, v1)] def prism(cu, r, v0, v1, n=12, cx=0.0): return [P(cx + r * math.cos(2 * math.pi * k / n), cu + r * math.sin(2 * math.pi * k / n), v) for k in range(n) for v in (v0, v1)] def side_prism(uv_pts, hx): return [P(x, u, v) for (u, v) in uv_pts for x in (-hx, hx)] def arm_pt(th, r): t = math.radians(th) return (ARM_C[0] + r * math.cos(t), ARM_C[1] + r * math.sin(t)) def body_hulls(): H = [box(-55, 55, 408, 563, 70, 111), # head box(-55.5, 55.5, 408, 493, 111, 148), # lip prism(471, 52, 148, 393, 12), # the tank, its bands and bezel box(-56, 56, 470, 563, 111, 393), # the body behind the tank, its side plates side_prism([(410, 393), (551, 393), arm_pt(53.1, 95), arm_pt(59, 95), (431, 470), (410, 470)], 55), # lower box(-62, 62, 400, 637, 470, 503), # plinth side_prism([arm_pt(59, 149), arm_pt(44, 149), arm_pt(33, 149), (630, 424), (634, 470), (536, 470)], 32), prism(495, 70, 34, 72, 12)] # the ports' crown for a, b in ((-62, -40), (-40, -20), (-20, 0), (0, 20), (20, 40), (40, 59)): # the arm, in pieces m = 0.5 * (a + b) H.append(side_prism([arm_pt(a, 95), arm_pt(b, 95), arm_pt(b, 149), arm_pt(m, 149), arm_pt(a, 149)], ARM_T)) i59, o59 = arm_pt(59, 95), arm_pt(59, 149) d = (-math.sin(math.radians(59)), math.cos(math.radians(59))) H.append(side_prism([i59, o59, (o59[0] + d[0] * 30, o59[1] + d[1] * 30), (i59[0] + d[0] * 84, i59[1] + d[1] * 84)], ARM_T)) # handles: the U at the head's front left corner, the rail round the lower housing's front left corner H.append([P(x, u, v) for x in (-30, -86) for u in (370, 432) for v in (40, 84)]) H.append([P(x, u, v) for x in (38, -78) for u in (380, 412) for v in (446, 472)] + [P(x, u, v) for x in (-78, -52) for u in (412, 450) for v in (446, 472)]) return H def make_hulls(): C = coll() body = bpy.data.objects["SM_NutritionVat_Body"] n = 0 for pts in body_hulls(): hull("UCX_SM_NutritionVat_Body_%02d" % n, pts, body, C) n += 1 can = bpy.data.objects["NutritionVat_Canister_R"] hull("UCX_SM_NutritionVat_Canister_00", prism(CAN_U, 17.6, 186, 300, 12, cx=CAN_X), can, C) return C, n 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 export(C): os.makedirs(FBX, exist_ok=True) tmpc = bpy.data.collections.new("_nv_export") bpy.context.scene.collection.children.link(tmpc) lc = bpy.context.view_layer.layer_collection.children["NutritionVat"].children.get("NV | collision") was_excluded = lc.exclude if lc else None if lc: lc.exclude = False written, temps = [], [] src_of = {"SM_NutritionVat_Canister": "NutritionVat_Canister_R"} try: for name in PARTS: src = bpy.data.objects[src_of.get(name, 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["NutritionVat_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, "NutritionVat_Assembled.fbx"), objs, {"MESH", "EMPTY"}) written.append("NutritionVat_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 bbox(objs): lo, hi = [1e9] * 3, [-1e9] * 3 for o in objs: for v in o.data.vertices: w = o.matrix_world @ v.co for i in range(3): lo[i], hi[i] = min(lo[i], w[i]), max(hi[i], w[i]) return lo, hi def layout(n_hulls): ob = bpy.data.objects 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 pod = [ob["SM_NutritionVat_Body"], ob["SM_NutritionVat_Fluid"], ob["NutritionVat_Canister_R"], ob["NutritionVat_Canister_L"]] lo, hi = bbox(pod) hlo, hhi = bbox([ob["SM_NutritionVat_Hoses"]]) fl = ob["SM_NutritionVat_Fluid"] flo, fhi = bbox([fl]) d = { "asset": "Nutrition vat: a standing pod with a two-chamber glass tank of nutrient liquid, six hose ports on its head, " "a C-shaped arm behind it, two cartridges at the rear corners, after the user's picture (2026-10-21, " "reference/ref_user.jpg, three views of one pod)", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: on the floor under the body's middle.", "front": "the tank faces Unreal +Y (Blender -Y); the side the picture shows (label, knobs, grille) is Unreal +X", "size_cm": {"width_body": 50.8, "width_plinth": round((hi[0] - lo[0]) * 100, 1), "depth_without_hoses": round((hi[1] - lo[1]) * 100, 1), "height_head": 200.0, "height_with_ports": round(hi[2] * 100, 1), "hoses_reach_behind_cm": round((hhi[1] - hi[1]) * 100, 1)}, "static_meshes": { "SM_NutritionVat_Body": {"location": [0, 0, 0], "materials": mats(ob["SM_NutritionVat_Body"]), "collision": "%d UCX hulls (head, lip, tank, body, lower housing, plinth, rear block, " "ports' crown, the arm in seven pieces, two handles)" % n_hulls}, "SM_NutritionVat_Fluid": {"location": ue(fl.location), "materials": mats(fl), "note": "the liquid, its pivot at its bottom on the tank's axis. Fill level: keep the mesh " "and cut it in the material by world height (opacity mask), from %.1f cm (empty) " "to %.1f cm (full) above the origin; scaling it in Z would push its rounded " "bottom through the glass" % (flo[2] * 100, fhi[2] * 100)}, "SM_NutritionVat_Canister": {"instances": [ {"name": "Canister_R", "location": ue(ob["NutritionVat_Canister_R"].location), "yaw_deg": 0}, {"name": "Canister_L", "location": ue(ob["NutritionVat_Canister_L"].location), "yaw_deg": 180}], "materials": mats(ob["NutritionVat_Canister_R"]), "note": "a cartridge at each rear corner, held by clamp rings that are part of the body; its own collision " "(UCX_SM_NutritionVat_Canister_00); the label faces out on both"}, "SM_NutritionVat_Hoses": {"location": [0, 0, 0], "optional": True, "materials": mats(ob["SM_NutritionVat_Hoses"]), "note": "the picture's middle view only: 18 hoses out of the six ports, over the head and " "down behind the pod onto the floor; no collision"}, }, "lights": "M_NV_Glow: the disc in the tank's lid, a ring under the mid band, a ring at the glass's foot, a strip " "along the lower housing's front", "translucent": "M_NV_Glass (the two chambers, thin walls), M_NV_Fluid (dark teal, bubbles emissive from " "T_NV_Bubbles on UV0: round the tank, up)", "decals": "M_NV_Decal: T_NV_Decals with alpha on UV0 (side label, warning sticker, keypad digits): masked or " "translucent, 0.25 mm off the surface", "checked": {"clash_pairs_total": chk.get("clash_pairs_total"), "smallest_gaps_mm": chk.get("gaps_mm")}, "textures": ["T_NV_Masks (UV0, non-colour: R occlusion, G edges, B crevices)", "T_NV_Decals (UV0 of the decals, sRGB + alpha)", "T_NV_CanLabel (UV0 of the canister's glass, sRGB)", "T_NV_Bubbles (UV0 of the liquid, non-colour)", "T_NV_Perforation (UV1 of the grille, 4 mm repeat)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/BrushedMetal, Base/Glass (UV1, metres)"], } p = os.path.join(FOLDER, "NutritionVat_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p def main(): C, n = make_hulls() lc = bpy.context.view_layer.layer_collection.children["NutritionVat"].children.get("NV | collision") if lc: lc.exclude = True written = export(C) p = layout(n) bpy.ops.wm.save_mainfile() print("EXPORT OK", written, p) main()