"""Collision hulls and FBX export for the machine parts kit (exec'd through the Blender MCP). make_ucx(): every part gets up to 6 convex boxes, UCX__NN, chosen from the solid primitives its builder made (stored on the object as "mpk_collision": centre + size, item space, metres): the biggest first, skipping any box that lies mostly inside one already taken. Moving parts get one box each (their own bounds). Collection "MPK | Collision (UCX)", parented to the mesh they belong to. export(): one FBX per part in fbx/ (the part at the origin, its moving parts and hulls with it), and MachineParts_Kit_layout.json (sizes, moving parts, pivots, axes). """ import json, os import bpy from mathutils import Vector ROOT = r"D:\HADAL - The Descent\models\machine-parts-kit" UCOLL = "MPK | Collision (UCX)" def _box_mesh(name, c, s): x, y, z = (v / 2 for v in s) vs = [(c[0] + a * x, c[1] + b * y, c[2] + d * z) for a in (-1, 1) for b in (-1, 1) for d in (-1, 1)] fs = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] me = bpy.data.meshes.new(name) me.from_pydata(vs, [], fs) me.update() return me def _inside(a, b): """share of box a's volume inside box b""" (ca, sa), (cb, sb) = a, b v = 1.0 for i in range(3): lo = max(ca[i] - sa[i] / 2, cb[i] - sb[i] / 2) hi = min(ca[i] + sa[i] / 2, cb[i] + sb[i] / 2) v *= max(0.0, hi - lo) va = sa[0] * sa[1] * sa[2] return v / va if va > 0 else 1.0 def make_ucx(max_boxes=6): uc = bpy.data.collections.get(UCOLL) or bpy.data.collections.new(UCOLL) if uc.name not in bpy.context.scene.collection.children: bpy.context.scene.collection.children.link(uc) for o in list(uc.objects): me = o.data bpy.data.objects.remove(o) bpy.data.meshes.remove(me) counts = {} for ob in bpy.data.collections["MPK | Parts"].objects: if ob.type != 'MESH': continue if "mpk_collision" in ob: boxes = [((b[0], b[1], b[2]), (b[3], b[4], b[5])) for b in ob["mpk_collision"]] boxes.sort(key=lambda b: -(b[1][0] * b[1][1] * b[1][2])) take = [] for b in boxes: if min(b[1]) < 0.004: continue if any(_inside(b, t) > 0.6 for t in take): continue take.append(b) if len(take) == max_boxes: break else: # a moving part: its own bounds lo = Vector([min(v.co[i] for v in ob.data.vertices) for i in range(3)]) hi = Vector([max(v.co[i] for v in ob.data.vertices) for i in range(3)]) take = [(tuple((lo + hi) / 2), tuple(hi - lo))] for i, (c, s) in enumerate(take, 1): n = f"UCX_{ob.name}_{i:02d}" u = bpy.data.objects.new(n, _box_mesh(n, c, s)) uc.objects.link(u) u.parent = ob u.matrix_parent_inverse.identity() u.display_type = 'WIRE' u.hide_render = True counts[ob.name] = len(take) # Hidden in the viewport too (the eye of the collection). hide_render alone only keeps them out of F12 renders: # the viewport's Rendered mode draws whatever the viewport shows, so the hulls rendered there as white boxes # (no material) lying on the parts' own faces, which made dark grain where the faces coincide (the user's # screenshot of 2026-10-15). Show the collection to inspect the hulls. _ucx_layer().hide_viewport = True return counts def _ucx_layer(): return bpy.context.view_layer.layer_collection.children[UCOLL] def export(): fdir = os.path.join(ROOT, "fbx") os.makedirs(fdir, exist_ok=True) layout = {"scale": "1 sheet pixel = 3 mm; metres; Z up; each part's origin at its centre on the floor", "texture_atlas": "textures/T_MPK_Masks.png (R AO, G edges, B cavity), textures/T_MPK_BevelNormal.png, on UV0", "tiling": "UV1 (UV_Tile) in metres for the Season 2 pack sets", "parts": {}} lc = _ucx_layer() was_hidden = lc.hide_viewport lc.hide_viewport = False # hidden objects cannot be selected, and the export takes the selection roots = [o for o in bpy.data.collections["MPK | Parts"].objects if o.parent is None and o.type == 'MESH'] for ob in sorted(roots, key=lambda o: o.name): keep = ob.location.copy() ob.location = (0, 0, 0) bpy.context.view_layer.update() sel = [ob] + list(ob.children_recursive) for o in bpy.context.view_layer.objects: o.select_set(o in sel) bpy.context.view_layer.objects.active = ob path = os.path.join(fdir, ob.name + ".fbx") bpy.ops.export_scene.fbx(filepath=path, use_selection=True, object_types={'MESH'}, apply_unit_scale=True, apply_scale_options='FBX_SCALE_UNITS', axis_forward='-Y', axis_up='Z', mesh_smooth_type='FACE', use_tspace=True, bake_anim=False, add_leaf_bones=False) ob.location = keep dims = [round(v, 4) for v in ob.dimensions] layout["parts"][ob.name] = { "key": ob.get("mpk_key"), "fbx": "fbx/" + ob.name + ".fbx", "size_m": dims, "faces": len(ob.data.polygons), "sheet_location_m": [round(v, 4) for v in keep], "moving": {c.name: {"pivot_m": [round(v, 4) for v in c.location], "axis": c.get("mpk_axis"), "faces": len(c.data.polygons)} for c in ob.children if c.type == 'MESH' and not c.name.startswith("UCX_")}, "ucx": sum(1 for c in ob.children_recursive if c.name.startswith("UCX_")), } lc.hide_viewport = was_hidden bpy.context.view_layer.update() with open(os.path.join(ROOT, "MachineParts_Kit_layout.json"), "w", encoding="utf8") as f: json.dump(layout, f, indent=1) return len(roots)