"""Primitives for the hard-surface kit (models/hardsurface-kit). Built for the user's reference sheet of 34 cast mechanical parts (2026-10-14). Everything here is in MILLIMETRES; an item is scaled to metres when it becomes an object. Item space: X = right on the sheet, Y = up on the sheet, Z = towards the camera (the sheet is a top view of parts lying on the floor), the floor at z = 0. Game-asset rules (memory: game-ready-model-standards): every primitive is a closed shell of quads, triangles only at lathe poles and on slab caps; no booleans: recesses and raised panels are loop cuts + an extruded face region; edges are real bevels. Small dark "dots" are proud dark discs, not holes; bores that show (eyes, ports) are real geometry. """ import math import bmesh import bpy from mathutils import Vector, Matrix, Euler PAINT, DARK = 0, 1 AXES = {'X': (1, 0, 0), '-X': (-1, 0, 0), 'Y': (0, 1, 0), '-Y': (0, -1, 0), 'Z': (0, 0, 1), '-Z': (0, 0, -1)} OTHER = {0: (1, 2), 1: (0, 2), 2: (0, 1)} # the two in-plane axes of a box side def vec(a): return Vector(AXES[a]) if isinstance(a, str) else Vector(a).normalized() def frame(at=(0, 0, 0), axis='Z', rot=None, spin=0.0): """matrix taking a part built about local +Z (a lathe axis, a box's height) into item space""" if rot is not None: R = Euler(rot).to_matrix().to_4x4() else: R = Vector((0, 0, 1)).rotation_difference(vec(axis)).to_matrix().to_4x4() return Matrix.Translation(Vector(at)) @ R @ Matrix.Rotation(spin, 4, 'Z') # ------------------------------------------------------------------ polylines def _round(P, R, M, closed, n): out = [] N = len(P) for i in range(N): r = R[i] if r <= 0 or (not closed and i in (0, N - 1)): out.append((P[i].copy(), M[i])) continue da, db = P[i - 1] - P[i], P[(i + 1) % N] - P[i] if da.length < 1e-9 or db.length < 1e-9: out.append((P[i].copy(), M[i])) continue r = min(r, da.length * 0.5, db.length * 0.5) # r = how far the round starts from the corner ua, ub = da.normalized(), db.normalized() if ua.dot(ub) < -0.9999: # straight on: nothing to round out.append((P[i].copy(), M[i])) continue p0, p2 = P[i] + ua * r, P[i] + ub * r # a true circular arc (a quadratic curve bends tighter in its middle, and an inset rim then turned inside out) half = ua.angle(ub) / 2 c = P[i] + (ua + ub).normalized() * (r / math.cos(half)) a, b = p0 - c, p2 - c phi = a.angle(b) for k in range(n + 1): t = k / n if phi < 1e-6: q = p0.lerp(p2, t) else: q = c + (a * math.sin((1 - t) * phi) + b * math.sin(t * phi)) / math.sin(phi) out.append((q, M[i])) ded = [] for p, m in out: # no coincident neighbours (they make zero-area faces) if ded and (ded[-1][0] - p).length < 1e-5: continue ded.append((p, m)) if closed and len(ded) > 2 and (ded[0][0] - ded[-1][0]).length < 1e-5: ded.pop() return ded def fillet(pts, closed=False, n=4): """pts: (x, y[, radius[, mat]]); each corner with a radius is rounded (quadratic arc). -> [(Vector2, mat)]. The segment from a point to the next takes that point's material.""" P = [Vector((p[0], p[1])) for p in pts] R = [p[2] if len(p) > 2 else 0.0 for p in pts] M = [p[3] if len(p) > 3 else PAINT for p in pts] return _round(P, R, M, closed, n) def fillet3d(pts, rad, n=5): P = [Vector(p) for p in pts] return [p for p, _ in _round(P, [rad] * len(P), [PAINT] * len(P), False, n)] # ------------------------------------------------------------------ primitives (each returns a closed bmesh) def lathe(prof, segs=32, closed=False, n=4, phase=0.0): """surface of revolution about +Z; prof = (r, z[, fillet[, mat]])""" pts = fillet(prof, closed, n) bm = bmesh.new() angs = [phase + 2 * math.pi * j / segs for j in range(segs)] rings = [] for p, _ in pts: if p.x < 1e-4: rings.append([bm.verts.new((0.0, 0.0, p.y))]) else: rings.append([bm.verts.new((p.x * math.cos(a), p.x * math.sin(a), p.y)) for a in angs]) L = len(rings) for i in range(L if closed else L - 1): A, B, m = rings[i], rings[(i + 1) % L], pts[i][1] if len(A) == 1 and len(B) == 1: continue for j in range(segs): j2 = (j + 1) % segs if len(A) == 1: vs = (A[0], B[j], B[j2]) elif len(B) == 1: vs = (A[j], A[j2], B[0]) else: vs = (A[j], A[j2], B[j2], B[j]) bm.faces.new(vs).material_index = m bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return bm def _sharp_edges(bm, ang=0.5): return [e for e in bm.edges if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > ang] def rbox(size, bev=2.0, ops=(), rbev=None, segs=3): """box centred on the origin with rounded edges. ops: (side, u0, u1, v0, v1, depth): on side '+z' (or '-x', ...) the faces inside the rectangle (u, v = the side's other two axes in x, y, z order) are extruded by depth: < 0 a recess, > 0 a raised panel. Later ops act on the outermost faces in their rectangle, so panels nest.""" sx, sy, sz = size half = (sx / 2, sy / 2, sz / 2) bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) for v in bm.verts: v.co = Vector((v.co.x * sx, v.co.y * sy, v.co.z * sz)) for (side, u0, u1, v0, v1, d) in ops: ui, vi = OTHER['xyz'.index(side[-1].lower())] for c, ai in ((u0, ui), (u1, ui), (v0, vi), (v1, vi)): if abs(c) >= half[ai] - 1e-3: continue no, co = [0, 0, 0], [0, 0, 0] no[ai], co[ai] = 1.0, c bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=1e-4, plane_co=co, plane_no=no) for (side, u0, u1, v0, v1, d) in ops: ai = 'xyz'.index(side[-1].lower()) sgn = -1.0 if side[0] == '-' else 1.0 ui, vi = OTHER[ai] dv = Vector((0, 0, 0)) dv[ai] = sgn cand = [] for f in bm.faces: if f.normal.dot(dv) < 0.999: continue c = f.calc_center_median() if u0 + 1e-3 < c[ui] < u1 - 1e-3 and v0 + 1e-3 < c[vi] < v1 - 1e-3: cand.append((c[ai] * sgn, f)) if not cand: continue top = max(h for h, _ in cand) faces = [f for h, f in cand if h > top - 1e-3] ret = bmesh.ops.extrude_face_region(bm, geom=faces) nv = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMVert)] bmesh.ops.translate(bm, vec=dv * d, verts=nv) bmesh.ops.delete(bm, geom=faces, context='FACES') # the op keeps the originals bmesh.ops.recalc_face_normals(bm, faces=bm.faces) def on_planes(e): m = (e.verts[0].co + e.verts[1].co) / 2 return sum(1 for a in range(3) if abs(abs(m[a]) - half[a]) < 1e-3) >= 2 if ops: # small bevel on the recess and panel rims first inner = [e for e in _sharp_edges(bm) if not on_planes(e)] if inner: bmesh.ops.bevel(bm, geom=inner, offset=rbev if rbev else min(bev, 1.2), offset_type='OFFSET', segments=2, profile=0.5, affect='EDGES', clamp_overlap=True) outer = [e for e in _sharp_edges(bm, 1.2) if on_planes(e)] if outer and bev > 0: bmesh.ops.bevel(bm, geom=outer, offset=bev, offset_type='OFFSET', segments=segs, profile=0.5, affect='EDGES', clamp_overlap=True) ngons = [f for f in bm.faces if len(f.verts) > 4] # where cut loops meet a bevel if ngons: bm.normal_update() bmesh.ops.triangulate(bm, faces=ngons, quad_method='BEAUTY', ngon_method='BEAUTY') bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return bm def _area(pts): return sum(pts[i - 1].x * pts[i].y - pts[i].x * pts[i - 1].y for i in range(len(pts))) / 2 def slab(outline, z0, z1, bev=2.0, n=4, steps=3): """a flat outline (x, y[, fillet]) extruded from z0 to z1, its rims rounded by bev""" # a corner rounded tighter than the rim's bevel would turn the inset rim inside out (C03's stem corners did) outline = [(p[0], p[1], max(p[2] if len(p) > 2 else 0.0, bev * 1.2)) for p in outline] pts = [p for p, _ in fillet(outline, closed=True, n=n)] if _area(pts) < 0: pts.reverse() N = len(pts) offs = [] for i in range(N): e1, e2 = pts[i] - pts[i - 1], pts[(i + 1) % N] - pts[i] n1, n2 = Vector((e1.y, -e1.x)).normalized(), Vector((e2.y, -e2.x)).normalized() nv = (n1 + n2).normalized() offs.append(nv / max(0.35, nv.dot(n1))) rings = [] for k in range(steps + 1): th = math.pi / 2 * k / steps rings.append((bev * (1 - math.sin(th)), z0 + bev * (1 - math.cos(th)))) for k in range(steps + 1): th = math.pi / 2 * k / steps rings.append((bev * (1 - math.cos(th)), z1 - bev + bev * math.sin(th))) bm = bmesh.new() vr = [[bm.verts.new((p.x - o.x * ins, p.y - o.y * ins, z)) for p, o in zip(pts, offs)] for ins, z in rings] for A, B in zip(vr, vr[1:]): for j in range(N): j2 = (j + 1) % N bm.faces.new((A[j], A[j2], B[j2], B[j])) caps = [bm.faces.new(list(reversed(vr[0]))), bm.faces.new(vr[-1])] for f in caps: f.normal_update() # triangulate projects along it; a new face's is zero bmesh.ops.triangulate(bm, faces=caps, quad_method='BEAUTY', ngon_method='EAR_CLIP') # outlines can be concave # no recalc_face_normals here: the winding above is outward by construction, and the recalc's guess flipped # half the caps of the concave duct outline (C03) return bm def tube(points, r, segs=12, rad=10.0): """a round bar or cable swept along a polyline whose corners are bent by rad""" P = fillet3d(points, rad) T = [] for i in range(len(P)): a, b = P[max(i - 1, 0)], P[min(i + 1, len(P) - 1)] T.append((b - a).normalized()) ref = Vector((0, 0, 1)) if abs(T[0].z) < 0.9 else Vector((1, 0, 0)) nrm = (ref - T[0] * ref.dot(T[0])).normalized() bm = bmesh.new() rings = [] for i, p in enumerate(P): nrm = (nrm - T[i] * nrm.dot(T[i])).normalized() bi = T[i].cross(nrm) rings.append([bm.verts.new(p + r * (math.cos(2 * math.pi * j / segs) * nrm + math.sin(2 * math.pi * j / segs) * bi)) for j in range(segs)]) for A, B in zip(rings, rings[1:]): for j in range(segs): j2 = (j + 1) % segs bm.faces.new((A[j], A[j2], B[j2], B[j])) for ring, p in ((rings[0], P[0]), (rings[-1], P[-1])): c = bm.verts.new(p) for j in range(segs): bm.faces.new((ring[j], ring[(j + 1) % segs], c)) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return bm def recess_faces(bm, pred, depth): """extrude the faces pred(centre, normal) picks by depth along their mean normal (< 0 into the part)""" faces = [f for f in bm.faces if pred(f.calc_center_median(), f.normal)] if not faces: return 0 nrm = sum((f.normal for f in faces), Vector()).normalized() ret = bmesh.ops.extrude_face_region(bm, geom=faces) nv = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMVert)] bmesh.ops.translate(bm, vec=nrm * depth, verts=nv) bmesh.ops.delete(bm, geom=faces, context='FACES') bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return len(faces) def merge(dst, src, mat=None): src.verts.index_update() vm = [dst.verts.new(v.co) for v in src.verts] for f in src.faces: nf = dst.faces.new([vm[v.index] for v in f.verts]) nf.material_index = f.material_index if mat is None else mat # ------------------------------------------------------------------ an item (one object of the kit) class Item: def __init__(self, key, name, sheet_px=None): self.key, self.name = key, name self.sheet_px = sheet_px # its centre on the reference sheet (px), for the layout self.bm = bmesh.new() self.cols = [] # (min, max) boxes for the UCX hulls self.children = [] # moving parts: (Item, origin, axis, stroke_mm) self.props = {} def put(self, part, M=None, mat=None, col=True, cut=()): if M is not None: bmesh.ops.transform(part, matrix=M, verts=part.verts) for pred, d in cut: recess_faces(part, pred, d) if col: xs = [v.co for v in part.verts] mn = Vector((min(c.x for c in xs), min(c.y for c in xs), min(c.z for c in xs))) mx = Vector((max(c.x for c in xs), max(c.y for c in xs), max(c.z for c in xs))) if min(mx - mn) > 10.0: self.cols.append((mn, mx)) merge(self.bm, part, mat) part.free() return self # shorthand builders, all in item millimetres def box(self, c, size, bev=2.0, ops=(), rot=None, mat=None, col=True, rbev=None, cut=()): return self.put(rbox(size, bev, ops, rbev), frame(c, rot=rot) if rot else Matrix.Translation(Vector(c)), mat, col, cut) def lathe(self, prof, at, axis='Z', segs=32, closed=False, mat=None, col=True, cut=(), spin=0.0): return self.put(lathe(prof, segs, closed), frame(at, axis, spin=spin), mat, col, cut) def cyl(self, at, axis, L, r, f=2.0, segs=32, mat=None, col=True, cut=()): """a plain cylinder from at, L long along axis""" return self.lathe([(0, 0), (r, 0, f), (r, L, f), (0, L)], at, axis, segs, mat=mat, col=col, cut=cut) def slab(self, outline, z0, z1, bev=2.0, at=(0, 0, 0), rot=None, mat=None, col=True, cut=()): return self.put(slab(outline, z0, z1, bev), frame(at, rot=rot or (0, 0, 0)), mat, col, cut) def tube(self, points, r, rad=10.0, segs=12, mat=None): return self.put(tube(points, r, segs, rad), None, mat, col=False) def eye(self, at, ri, ro, t, axis='Z', segs=32): """a ring (rod-end eye, mounting lug) about axis""" f = min(1.6, (ro - ri) * 0.3, t * 0.25) prof = [(ri, -t / 2, f), (ro, -t / 2, f), (ro, t / 2, f), (ri, t / 2, f)] return self.put(lathe(prof, segs, closed=True), frame(at, axis)) def cup(self, at, axis, ro, ri, h, d, segs=32, col=True): """a boss of radius ro and height h with a bore ri, d deep, opening at its top""" f = min(1.5, (ro - ri) * 0.3) prof = [(0, 0), (ro, 0, f), (ro, h, f), (ri, h, f * 0.6), (ri, h - d, 0, DARK), (0, h - d, 0, DARK)] return self.put(lathe(prof, segs), frame(at, axis), col=col) def bolt(self, at, n='Z', r=5.0, h=None): """socket-head screw sitting on a surface at 'at' facing n (its foot sinks 1 mm in)""" h = h or r * 0.75 prof = [(0, -1), (r, -1), (r, h, r * 0.35), (r * 0.48, h, 0, DARK), (r * 0.48, h * 0.45, 0, DARK), (0, h * 0.45)] return self.put(lathe(prof, 16), frame(at, n), col=False) def dot(self, at, n='Z', r=3.0): """a small dark port or drilling, a dark disc 0.6 mm proud of the surface""" prof = [(0, -0.6, 0, DARK), (r, -0.6, 0, DARK), (r, 0.6, 0.3, DARK), (0, 0.6, 0, DARK)] return self.put(lathe(prof, 12), frame(at, n), col=False) def child(self, name, origin, axis=(0, -1, 0), stroke=0.0): it = Item(self.key, self.name + "_" + name) self.children.append([it, Vector(origin), Vector(axis), stroke]) return it def ring_profile(*steps): """lathe helper: a run of (r, z) points at the given fillet""" return [s for s in steps]