"""Build the hard-surface kit: 33 parts from the user's reference sheet (2026-10-14) in one .blend. Headless (the live Blender was busy with another session's file when this was built): blender.exe -b --factory-startup --python build_kit.py -- [--stage geo|surf|render|export|all] [--preview] Stages work on models/hardsurface-kit/HardSurfaceKit.blend: geo builds every part from items.py into a fresh file, two UV layers, collision hulls, the audit surf bakes the masks atlas (Cycles) and dresses the materials (surfaces.py) render sheet view (top, like the reference) and a three-quarter view, Cycles export one FBX per part (+ its rod, + UCX hulls) into fbx/ """ import os import sys import json import math import bpy import bmesh from mathutils import Vector, Matrix HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import kitlib as K import items as I OUT = os.path.normpath(os.path.join(HERE, "..")) BLEND = os.path.join(OUT, "HardSurfaceKit.blend") MM = 0.001 SHEET_CENTRE = (1000, 500) # sheet px that becomes the world origin SHARP = math.radians(40) ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] STAGE = ARGS[ARGS.index("--stage") + 1] if "--stage" in ARGS else "all" def coll(name, parent=None): c = bpy.data.collections.get(name) or bpy.data.collections.new(name) p = parent or bpy.context.scene.collection if c.name not in [x.name for x in p.children]: p.children.link(c) return c def materials(): mats = [] for name, col, rough in (("M_Kit_Paint", (0.40, 0.345, 0.26), 0.55), ("M_Kit_Dark", (0.025, 0.024, 0.022), 0.7)): m = bpy.data.materials.get(name) or bpy.data.materials.new(name) m.diffuse_color = col + (1.0,) m.roughness = rough mats.append(m) return mats def audit_bm(bm): return dict(faces=len(bm.faces), tris=sum(len(f.verts) == 3 for f in bm.faces), ngons=sum(len(f.verts) > 4 for f in bm.faces), open_edges=sum(1 for e in bm.edges if not e.is_manifold), zero_faces=sum(1 for f in bm.faces if f.calc_area() < 1e-4)) def make_object(it, name, origin_mm, mats, col): bm = it.bm bmesh.ops.translate(bm, vec=-Vector(origin_mm), verts=bm.verts) bmesh.ops.scale(bm, vec=(MM, MM, MM), verts=bm.verts) a = audit_bm(bm) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() for m in mats: me.materials.append(m) me.shade_smooth() me.set_sharp_from_angle(angle=SHARP) ob = bpy.data.objects.new(name, me) col.objects.link(ob) return ob, a def ucx_boxes(it, name, origin_mm, col, parent, limit=10): boxes = sorted(it.cols, key=lambda b: -((b[1] - b[0]).x * (b[1] - b[0]).y * (b[1] - b[0]).z))[:limit] out = [] for i, (mn, mx) in enumerate(boxes): bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) c, s = (mn + mx) / 2 - Vector(origin_mm), mx - mn for v in bm.verts: v.co = Vector((c.x + v.co.x * s.x, c.y + v.co.y * s.y, c.z + v.co.z * s.z)) * MM me = bpy.data.meshes.new("UCX_%s_%02d" % (name, i)) bm.to_mesh(me) bm.free() o = bpy.data.objects.new(me.name, me) col.objects.link(o) o.parent = parent o.display_type = 'WIRE' o.hide_render = True out.append(o) return out def tile_uv(me, layer="UV_Tile"): """UV1 in metres: each face box-projected on its main axis, for the tiling texture sets""" uv = me.uv_layers.get(layer) or me.uv_layers.new(name=layer) for p in me.polygons: n = p.normal ax = max(range(3), key=lambda a: abs(n[a])) u, v = K.OTHER[ax] for li in p.loop_indices: co = me.vertices[me.loops[li].vertex_index].co uv.data[li].uv = (co[u], co[v]) ATLAS_PX = 4096 CELL_PAD = 8 # px between squares def atlas_cells(meshes): """Give every part a square of the 4096 atlas, its side in proportion to the root of its surface, shelf-packed. One square per part means the masks bake per part into a small image: Cycles bakes walk the whole target image for each object (11 s per part even at 1024 on this PC), so baking 39 parts into one 4K image took over an hour.""" area = {o.name: sum(p.area for p in o.data.polygons) for o in meshes} order = sorted(meshes, key=lambda o: -area[o.name]) def pack(scale): x = y = shelf = 0 cells = {} for o in order: s = max(64, int(math.sqrt(area[o.name]) * scale)) if x + s > ATLAS_PX: x, y, shelf = 0, y + shelf + CELL_PAD, 0 if y + s > ATLAS_PX: return None cells[o.name] = (x, y, s) x += s + CELL_PAD shelf = max(shelf, s) return cells lo, hi = 100.0, 20000.0 for _ in range(40): mid = (lo + hi) / 2 if pack(mid): lo = mid else: hi = mid cells = pack(lo) for o in meshes: x, y, s = cells[o.name] uv = o.data.uv_layers["UVMap"] for d in uv.data: d.uv = ((x + d.uv.x * s) / ATLAS_PX, (y + d.uv.y * s) / ATLAS_PX) o["atlas_cell_px"] = [x, y, s] def stage_geo(): bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene sc.unit_settings.system = 'METRIC' sc.unit_settings.scale_length = 1.0 mats = materials() root = coll("KIT | 33 parts (reference sheet 2026-10-14)") ucxc = coll("UCX | collision", root) rows = {r: coll("Row %s" % r, root) for r in "ABC"} report = {"parts": [], "totals": {}} for key, name, (cx, cy), fn in I.ITEMS: it = K.Item(key, name, (cx, cy)) fn(it) oname = "SM_Kit_%s_%s" % (key, name) ob, a = make_object(it, oname, (0, 0, 0), mats, rows[key[0]]) ob.location = ((cx - SHEET_CENTRE[0]) * I.S * MM, (SHEET_CENTRE[1] - cy) * I.S * MM, 0.0) ob["kit_key"] = key ob["sheet_px"] = [cx, cy] ucx = ucx_boxes(it, oname, (0, 0, 0), ucxc, ob) entry = dict(key=key, name=oname, audit=a, ucx=len(ucx), dims_mm=[round(d / MM) for d in ob.dimensions]) for ch, origin, axis, stroke in it.children: cname = "SM_Kit_%s_%s" % (key, ch.name.split("_")[-1]) co, ca = make_object(ch, cname, origin, mats, rows[key[0]]) co.parent = ob co.location = Vector(origin) * MM co["stroke_mm"] = stroke co["slide_axis"] = list(axis) ob["moving_part"] = cname cu = ucx_boxes(ch, cname, origin, ucxc, co, limit=4) entry.setdefault("moving", []).append(dict(name=cname, audit=ca, stroke_mm=stroke, ucx=len(cu))) report["parts"].append(entry) # UV0: one shared atlas over all parts (masks bake); UV1: metres meshes = [o for o in bpy.data.objects if o.type == 'MESH' and not o.name.startswith("UCX_")] for o in meshes: o.data.uv_layers.new(name="UVMap") tile_uv(o.data) o.data.uv_layers.active = o.data.uv_layers["UVMap"] for o in meshes: # each part unwrapped on its own into 0..1 ... for o2 in bpy.context.view_layer.objects: o2.select_set(o2 == o) bpy.context.view_layer.objects.active = o bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=math.radians(55), island_margin=0.004, scale_to_bounds=False, correct_aspect=True) bpy.ops.object.mode_set(mode='OBJECT') o.data.uv_layers["UVMap"].active_render = True atlas_cells(meshes) # ... then put into its own square of the shared atlas t = report["totals"] t["parts"] = len(I.ITEMS) t["objects"] = len(meshes) t["faces"] = sum(len(o.data.polygons) for o in meshes) t["tris_equiv"] = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in meshes) t["open_edges"] = sum(e["audit"]["open_edges"] for e in report["parts"]) t["ngons"] = sum(e["audit"]["ngons"] for e in report["parts"]) with open(os.path.join(OUT, "build_report.json"), "w") as f: json.dump(report, f, indent=1) bpy.ops.wm.save_as_mainfile(filepath=BLEND) print("GEO", json.dumps(t)) def open_blend(): if os.path.normpath(bpy.data.filepath or "") != os.path.normpath(BLEND): bpy.ops.wm.open_mainfile(filepath=BLEND) if __name__ == "__main__": if STAGE in ("geo", "all"): stage_geo() if STAGE in ("surf", "all"): open_blend() import surfaces surfaces.run(OUT) bpy.ops.wm.save_mainfile() if STAGE in ("render", "all"): open_blend() import render_kit render_kit.run(OUT, preview="--preview" in ARGS) if STAGE in ("export", "all"): open_blend() import export_kit export_kit.run(OUT)