"""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 three pipes) for a quick look. The pipes' pieces are exported once each (the three pipes share them; SM_NutritionVat_PipeRun is a 1 m piece). 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_PipeFitting", "SM_NutritionVat_PipeRun", "SM_NutritionVat_PipeCeiling"] SRC_OF = {"SM_NutritionVat_Canister": "NutritionVat_Canister_R", "SM_NutritionVat_PipeFitting": "NutritionVat_PipeFitting_A", "SM_NutritionVat_PipeRun": "NutritionVat_PipeRun_A", "SM_NutritionVat_PipeCeiling": "NutritionVat_PipeCeiling_A"} CAN_X, CAN_U = 47.0, 580.0 def _mouths(): out = [] for k in (1, 3, 5): ph = math.radians(30 + 60 * k) base = P(45 * math.cos(ph) * 0.92, 495 + 45 * math.sin(ph), 70) ax = Vector((math.cos(ph) * math.sin(math.radians(22)), math.sin(ph) * math.sin(math.radians(22)), math.cos(math.radians(22)))) out.append((k, tuple(base + ax * (24.6 * S)))) return out OPEN_MOUTHS = _mouths() 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) for mesh in ("SM_NutritionVat_PipeFitting", "SM_NutritionVat_PipeRun", "SM_NutritionVat_PipeCeiling"): o = bpy.data.objects[SRC_OF[mesh]] # one hull round the piece, in its own frame hull("UCX_%s_00" % mesh, [o.matrix_world @ v.co for v in o.data.vertices], o, 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 = SRC_OF 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 PIPE_CEIL = 3.46 # nv_build.CEIL_Z def pipes_layout(objs, mats): """the three pipes: where each piece stands (Unreal), and how a run is made to any ceiling""" by = {} for o in objs: kind, name = o.name[len("NutritionVat_Pipe"):].rsplit("_", 1) by.setdefault(name, {})[kind] = o yaw = lambda o: round((-math.degrees(o.rotation_euler.z) + 180.0) % 360.0 - 180.0, 2) # Unreal yaw = - Blender's turn out = [] port_names = {0: "right rear", 2: "left rear", 4: "front"} for name in sorted(by): f, r, c = by[name]["Fitting"], by[name]["Run"], by[name]["Ceiling"] k = int(round((math.degrees(f.rotation_euler.z) - 30.0) / 60.0)) % 6 out.append({"pipe": name, "port": "%d (%s)" % (k, port_names.get(k, "")), "fitting": {"location": ue(f.location), "yaw_deg": yaw(f)}, "run": {"location": ue(r.location), "yaw_deg": yaw(r), "scale_z": round(r.scale.z, 4), "length_cm": round(r.scale.z * 100, 1)}, "ceiling": {"location": ue(c.location), "yaw_deg": yaw(c)}}) run0 = by["A"]["Run"] start_z, top_gap = run0.location.z, PIPE_CEIL - (run0.location.z + run0.scale.z) return { "what": "the docking arm's clear ore duct (models/docking-arm, 'Ore hose'), as wide as the port (20.7 cm across the " "film), on three of the six ports; up to a coupling in the ceiling, the vat's connection to the ship's main " "line (biomass from the Tenders' cargo, as ore goes). The other three ports stay open. The ducts are empty.", "pieces": { "SM_NutritionVat_PipeFitting": {"pivot": "the port's foot on the head's top, x out from the vat's middle", "materials": mats(by["A"]["Fitting"]), "what": "film over the port's rim, a clamp band there, the 22-degree bend to " "vertical, a band at its top that also holds the run's end"}, "SM_NutritionVat_PipeRun": {"pivot": "its bottom on the duct's axis", "length_cm": 100.0, "materials": mats(by["A"]["Run"]), "what": "a straight 1 m piece of film: stretch it along Z to the length (see rule); " "its UV0 V runs along it, so use a world-height V in Unreal if the rib " "must not stretch"}, "SM_NutritionVat_PipeCeiling": {"pivot": "on the ceiling's plane on the duct's axis: the socket for the main line", "materials": mats(by["A"]["Ceiling"]), "what": "flange with six bolts, collar, the spigot the film slides over; a band " "clamps it there"}}, "placements": out, "rule_cm": {"run_starts_over_floor": round(start_z * 100, 2), "run_ends_under_ceiling": round(top_gap * 100, 2), "run_length": "ceiling height - %.2f - %.2f" % (top_gap * 100, start_z * 100), "default_ceiling": PIPE_CEIL * 100}, "open_ports": [{"port": k, "mouth": ue(Vector(m))} for k, m in OPEN_MOUTHS], "yaw": "Unreal yaw = - (Blender's turn about Z)", } 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) pipe_objs = sorted((o for o in ob if o.name.startswith("NutritionVat_Pipe")), key=lambda o: o.name) 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 ports on its head (three " "carry pipes to the ceiling), 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": round((hi[1] - lo[1]) * 100, 1), "height_head": 200.0, "height_with_ports": round(hi[2] * 100, 1), "pipes_reach_cm": round(PIPE_CEIL * 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"}, }, "pipes": pipes_layout(pipe_objs, mats), "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()