"""Collision hulls, FBX files and NutritionFeeder_layout.json, headless, in place: blender.exe -b ../NutritionFeeder.blend --python nf_export.py The pressure hatch's export (models/pressure-hatch/build/ph_export.py): one FBX per mesh at its own pivot with its UCX_ hulls, plus NutritionFeeder_Assembled.fbx (needle away) 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') PARTS = ["SM_NutritionFeeder_Body", "SM_NutritionFeeder_Nozzle", "SM_NutritionFeeder_Needle"] S, U0, V0 = 0.0005, 513.0, 1345.0 def P(u, v, f): return Vector(((u - U0) * S, -f * 0.001, (V0 - v) * S)) def coll(): top = bpy.data.collections["NutritionFeeder"] c = bpy.data.collections.get("NF | collision") if c is None: c = bpy.data.collections.new("NF | 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(u0, v0, u1, v1, f0, f1): return [P(u, v, f) for u in (u0, u1) for v in (v0, v1) for f in (f0, f1)] # the body's hulls: the panel round the cavity, the frame's four bars, the injector. The cavity stays open (the hand # goes in) and nothing reaches into the tunnel. BODY_HULLS = [box(46, 100, 980, 662, 0, 55), # panel above the cavity (with the screen) box(46, 662, 410, 1345, 0, 55), # left of it box(935, 662, 980, 1345, 0, 55), # right of it box(410, 1268, 935, 1345, 0, 55), # under it box(410, 662, 935, 699, 0, 71.3), # frame, top bar box(410, 1228, 935, 1268, 0, 71.3), # frame, bottom bar box(410, 699, 449, 1228, 0, 71.3), # frame, left bar box(898, 699, 935, 1228, 0, 71.3), # frame, right bar box(571, 704, 774, 840, 0, 63)] # injector mount and cylinder def make_hulls(): C = coll() body = bpy.data.objects["SM_NutritionFeeder_Body"] for i, pts in enumerate(BODY_HULLS): hull("UCX_SM_NutritionFeeder_Body_%02d" % i, pts, body, 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_inject(v): root = bpy.data.objects["NutritionFeeder_Root"] if root.animation_data: root.animation_data.action = None root["inject"] = 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_inject(0.0) tmpc = bpy.data.collections.new("_nf_export") bpy.context.scene.collection.children.link(tmpc) lc = bpy.context.view_layer.layer_collection.children["NutritionFeeder"].children.get("NF | 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["NutritionFeeder_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, "NutritionFeeder_Assembled.fbx"), objs, {"MESH", "EMPTY"}) written.append("NutritionFeeder_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(): nz = bpy.data.objects["SM_NutritionFeeder_Nozzle"] nd = bpy.data.objects["SM_NutritionFeeder_Needle"] 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 d = { "asset": "Nutrition feeding device: a wall unit with a status screen and a hand cavity whose injector puts a needle " "into the palm, after the user's picture (2026-10-21, reference/ref_user.png)", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: the panel's bottom edge, centred, on the wall's face.", "front": "the panel faces Unreal +Y (Blender -Y)", "size_cm": {"panel_w": 46.7, "panel_h": 62.25, "proud_of_wall": 7.13, "into_wall": 12.3, "note": "the tunnel and its back reach 12.3 cm behind the wall's face: set the unit into a wall at least that " "thick or behind a lining; nothing else of it is behind the wall"}, "mounting": "for a standing person the cavity's centre at about 103 cm: put the origin about 85 cm above the floor", "static_meshes": {"SM_NutritionFeeder_Body": {"location": [0, 0, 0], "materials": mats(bpy.data.objects["SM_NutritionFeeder_Body"]), "screen_slot": "M_NF_Screen: UV0 spans the glass 0..1 (the UI texture); " "swap this slot's material for a live screen"}}, "moving": { "SM_NutritionFeeder_Nozzle": {"parent": None, "pivot_at_rest": ue(Vector(nz["closed_location"])), "motion": "slides straight down (Unreal -Z) %.1f cm" % (NOZZLE_T * 100), "materials": mats(nz)}, "SM_NutritionFeeder_Needle": {"parent": "SM_NutritionFeeder_Nozzle", "pivot_at_rest_relative_to_nozzle": ue(Vector(nd["closed_location"])), "motion": "slides straight down (Unreal -Z) %.1f cm out of the nozzle" % (NEEDLE_T * 100), "materials": mats(nd)}, }, "sequence_inject_0_to_1": {"nozzle": [0.0, 0.55], "needle": [0.55, 1.0], "easing": "smoothstep inside each span", "blender_timeline": "away 1, out 60, held to 120, away 180 at 30 fps (2 s out)", "picture_pose": "inject 1 (nozzle and needle out, the needle's tip at the palm)"}, "needle_tip_out_cm": ue(P(673, 997, 40)), "lights": "M_NF_Glow: the two bars at the tunnel's sides, the ledges under the shoulders, the rings on the injector and the needle's tip", "checked": {"clashes_through_travel": chk.get("clashes"), "smallest_gap_mm": chk.get("smallest_gap_to_body_mm")}, "textures": ["T_NF_Masks (UV0, non-colour: R occlusion, G edges, B crevices)", "T_NF_Screen_UI (UV0 of the glass, sRGB, emissive)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/BrushedMetal, Base/StainedSteel, Base/Glass (UV1, metres)"], } p = os.path.join(FOLDER, "NutritionFeeder_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p NOZZLE_T, NEEDLE_T = 94.0 * S, 72.0 * S def main(): C = make_hulls() lc = bpy.context.view_layer.layer_collection.children["NutritionFeeder"].children.get("NF | collision") if lc: lc.exclude = True written = export(C) p = layout() bpy.ops.wm.save_mainfile() print("EXPORT OK", written, p) main()