# Topology-first helper library (no booleans, quads, merged meshes with material slots). import bpy, bmesh, math, os from mathutils import Vector, Matrix, Euler SP = r"D:\HADAL - The Descent\models\dnr-cnc\build" OUT = r"D:\HADAL - The Descent\models\dnr-cnc" def get_coll(name, parent=None): c = bpy.data.collections.get(name) if c is None: c = bpy.data.collections.new(name) (parent or bpy.context.scene.collection).children.link(c) return c def MAT(name, color=(0.5, 0.5, 0.5), metal=0.0, rough=0.5, emit=None, emit_str=0.0, alpha=1.0, coat=0.0): m = bpy.data.materials.get(name) if m is not None: return m m = bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") c = tuple(color) + (1.0,) if len(color) == 3 else tuple(color) b.inputs["Base Color"].default_value = c b.inputs["Metallic"].default_value = metal b.inputs["Roughness"].default_value = rough if "Coat Weight" in b.inputs: b.inputs["Coat Weight"].default_value = coat if emit is not None: b.inputs["Emission Color"].default_value = tuple(emit) + (1.0,) b.inputs["Emission Strength"].default_value = emit_str if alpha < 1.0: b.inputs["Alpha"].default_value = alpha try: m.surface_render_method = 'BLENDED' except Exception: pass m.diffuse_color = c return m def clean_scene(keep=("DNR_CNC_Root", "Camera", "Light")): for o in list(bpy.data.objects): if o.name in keep or o.name.startswith(("Key_", "Fill_", "Rim_", "Top_", "Floor")): continue bpy.data.objects.remove(o) for me in list(bpy.data.meshes): if me.users == 0: bpy.data.meshes.remove(me) for cu in list(bpy.data.curves): if cu.users == 0: bpy.data.curves.remove(cu) # ------------------------------------------------------------------ transforms def _M(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)): S = Matrix.Diagonal(Vector(scale).to_4d()) return Matrix.Translation(loc) @ Euler(rot).to_matrix().to_4x4() @ S def bevel_edges(bm, edges, w, seg=1): if w > 0 and edges: bmesh.ops.bevel(bm, geom=edges, offset=w, offset_type='OFFSET', segments=seg, profile=0.5, affect='EDGES') _AX = {'+x': (0, 1), '-x': (0, -1), '+y': (1, 1), '-y': (1, -1), '+z': (2, 1), '-z': (2, -1)} def _drop_faces(bm, drop, size): """delete the large flat face(s) of a box on the named sides (before bevel).""" fs = [] for d in drop: ax, sg = _AX[d] for f in bm.faces: n = f.normal if abs(n[ax] - sg) < 1e-4: fs.append(f) return fs class MB: """Mesh builder: accumulates primitives (quad topology) into one mesh with material slots.""" def __init__(self, name, mats): self.name = name self.bm = bmesh.new() self.mats = list(mats) @classmethod def load(cls, name): o = bpy.data.objects[name] mb = cls(name, list(o.data.materials)) mb.bm.from_mesh(o.data) mb._old = (o.parent, o.location.copy(), o.users_collection[0], o) return mb def dome(self, r, ctr, mat, segs=16, rings=8, rot=(0, 0, 0)): bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=segs, v_segments=rings * 2, radius=r) low = [v for v in bm.verts if v.co.z < -1e-6] bmesh.ops.delete(bm, geom=low, context='VERTS') self.merge(bm, mat, _M(ctr, rot)) def mi(self, m): if m not in self.mats: self.mats.append(m) return self.mats.index(m) def merge(self, bm2, mat, xf=None): if xf is not None: bm2.transform(xf) if mat is not None: mi = self.mi(mat) for f in bm2.faces: f.material_index = mi tmp = bpy.data.meshes.new("_t") bm2.to_mesh(tmp) bm2.free() self.bm.from_mesh(tmp) bpy.data.meshes.remove(tmp) # ---- primitives def box(self, size, ctr, mat, rot=(0, 0, 0), bevel=0.0, seg=1, drop=()): bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) for v in bm.verts: v.co.x *= size[0]; v.co.y *= size[1]; v.co.z *= size[2] bm.normal_update() dfaces = _drop_faces(bm, drop, size) if bevel > 0: es = [e for e in bm.edges if not any(f in dfaces for f in e.link_faces)] bevel_edges(bm, es, bevel, seg) bm.normal_update() dfaces = _drop_faces(bm, drop, size) if dfaces: bmesh.ops.delete(bm, geom=dfaces, context='FACES_ONLY') self.merge(bm, mat, _M(ctr, rot)) def cyl(self, r1, r2, h, ctr, mat, rot=(0, 0, 0), segs=24, caps='both', bevel=0.0): bm = bmesh.new() bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=segs, radius1=r1, radius2=r2, depth=h) bm.normal_update() if bevel > 0: es = [e for e in bm.edges if len(e.link_faces) == 2 and abs(e.verts[0].co.z - e.verts[1].co.z) < 1e-6] bevel_edges(bm, es, bevel, 1) bm.normal_update() rm = [] for f in bm.faces: if f.normal.z > 0.999 and caps in ('bottom', 'none'): rm.append(f) if f.normal.z < -0.999 and caps in ('top', 'none'): rm.append(f) if rm: bmesh.ops.delete(bm, geom=rm, context='FACES_ONLY') self.merge(bm, mat, _M(ctr, rot)) def sphere(self, r, ctr, mat, segs=20, rings=12, scale=(1, 1, 1), rot=(0, 0, 0)): bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=segs, v_segments=rings, radius=r) self.merge(bm, mat, _M(ctr, rot, scale)) def torus(self, R, r, ctr, mat, rot=(0, 0, 0), segs=40, rsegs=8): bm = bmesh.new() rows = [] for i in range(segs): a = 2 * math.pi * i / segs rows.append([bm.verts.new(((R + r * math.cos(2 * math.pi * j / rsegs)) * math.cos(a), (R + r * math.cos(2 * math.pi * j / rsegs)) * math.sin(a), r * math.sin(2 * math.pi * j / rsegs))) for j in range(rsegs)]) for i in range(segs): for j in range(rsegs): bm.faces.new((rows[i][j], rows[(i + 1) % segs][j], rows[(i + 1) % segs][(j + 1) % rsegs], rows[i][(j + 1) % rsegs])) bm.normal_update() self.merge(bm, mat, _M(ctr, rot)) def quad(self, p0, p1, p2, p3, mat): bm = bmesh.new() bm.faces.new([bm.verts.new(p) for p in (p0, p1, p2, p3)]) self.merge(bm, mat) def prism(self, pts, thick, ctr, mat, rot=(0, 0, 0), bevel=0.0, drop=()): """convex/simple polygon (u,v) in XZ plane, extruded along +Y by thick (front face at y=0).""" bm = bmesh.new() vs = [bm.verts.new((u, 0, v)) for u, v in pts] f = bm.faces.new(vs) res = bmesh.ops.extrude_face_region(bm, geom=[f]) for v in [e for e in res['geom'] if isinstance(e, bmesh.types.BMVert)]: v.co.y += thick bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) if bevel > 0: bevel_edges(bm, bm.edges[:], bevel, 1) self.merge(bm, mat, _M(ctr, rot)) def sweep(self, pts, radii, mat, sides=8, caps=True, up=(0, 0, 1)): """tube along a polyline with per-point radius (quad rings, tri-fan caps).""" bm = bmesh.new() P = [Vector(p) for p in pts] n = len(P) if not isinstance(radii, (list, tuple)): radii = [radii] * n # parallel transport frame t0 = (P[1] - P[0]).normalized() ref = Vector(up) if abs(ref.dot(t0)) > 0.95: ref = Vector((1, 0, 0)) nrm = (ref - ref.dot(t0) * t0).normalized() rings = [] prev_t = t0 for i in range(n): if i == 0: t = t0 elif i == n - 1: t = (P[i] - P[i - 1]).normalized() else: t = ((P[i] - P[i - 1]).normalized() + (P[i + 1] - P[i]).normalized()).normalized() # rotate nrm from prev_t to t axis = prev_t.cross(t) if axis.length > 1e-7: ang = math.acos(max(-1, min(1, prev_t.dot(t)))) nrm = (Matrix.Rotation(ang, 3, axis.normalized()) @ nrm) nrm = (nrm - nrm.dot(t) * t).normalized() bn = t.cross(nrm) prev_t = t ring = [] for k in range(sides): a = 2 * math.pi * k / sides ring.append(bm.verts.new(P[i] + radii[i] * (math.cos(a) * nrm + math.sin(a) * bn))) rings.append(ring) uvl = bm.loops.layers.uv.new("UVMap") acc = [0.0] for i in range(1, n): acc.append(acc[-1] + (P[i] - P[i - 1]).length) vs = 1.0 / (2 * math.pi * (sum(radii) / n)) for i in range(n - 1): for k in range(sides): f = bm.faces.new((rings[i][k], rings[i + 1][k], rings[i + 1][(k + 1) % sides], rings[i][(k + 1) % sides])) uvs = ((k / sides, acc[i] * vs), (k / sides, acc[i + 1] * vs), ((k + 1) / sides, acc[i + 1] * vs), ((k + 1) / sides, acc[i] * vs)) for l, uv in zip(f.loops, uvs): l[uvl].uv = uv if caps: for ring, c in ((rings[0], P[0]), (rings[-1], P[-1])): cv = bm.verts.new(c) for k in range(sides): f = bm.faces.new((ring[k], ring[(k + 1) % sides], cv)) for l in f.loops: l[uvl].uv = (0.5, 0.5) bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) self.merge(bm, mat) def add_bm(self, bm2, mat=None, xf=None): self.merge(bm2, mat, xf) # ---- finish def build(self, coll, loc=(0, 0, 0), parent=None, sharp_deg=35.0, tri_ngons=True, weld=1e-5, recalc=False, origin=None): bm = self.bm bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld) bm.faces.ensure_lookup_table() zf = [f for f in bm.faces if f.calc_area() < 1e-10] self.zero_removed = len(zf) if zf: bmesh.ops.delete(bm, geom=zf, context='FACES') if recalc: bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) # drop loose verts/edges loose = [v for v in bm.verts if not v.link_faces] if loose: bmesh.ops.delete(bm, geom=loose, context='VERTS') if tri_ngons: ng = [f for f in bm.faces if len(f.verts) > 4] if ng: bmesh.ops.triangulate(bm, faces=ng, quad_method='BEAUTY', ngon_method='BEAUTY') lim = math.radians(sharp_deg) bm.normal_update() for e in bm.edges: if len(e.link_faces) == 2: e.smooth = e.link_faces[0].normal.angle(e.link_faces[1].normal) < lim else: e.smooth = True for f in bm.faces: f.smooth = True if origin is not None: bmesh.ops.translate(bm, verts=bm.verts[:], vec=-Vector(origin)) loc = origin me = bpy.data.meshes.new(self.name) bm.to_mesh(me) bm.free() for m in self.mats: me.materials.append(m) old = getattr(self, '_old', None) if old is not None: parent = parent or old[0] if origin is None: loc = old[1] kids = list(old[3].children) oldme = old[3].data bpy.data.objects.remove(old[3]) bpy.data.meshes.remove(oldme) else: kids = [] o = bpy.data.objects.new(self.name, me) coll.objects.link(o) o.location = loc for k in kids: k.parent = o k.matrix_parent_inverse = o.matrix_world.inverted() if parent is not None: bpy.context.view_layer.update() o.parent = parent o.matrix_parent_inverse = parent.matrix_world.inverted() return o # ------------------------------------------------------------------ box/loop-cut utilities for the housing def cube_bm(size, ctr, bevel=0.0): bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) for v in bm.verts: v.co.x = v.co.x * size[0] + ctr[0]; v.co.y = v.co.y * size[1] + ctr[1]; v.co.z = v.co.z * size[2] + ctr[2] if bevel > 0: bevel_edges(bm, bm.edges[:], bevel, 1) return bm def loopcut(bm, axis, value): """cut every face crossing the plane axis=value (like a loop cut through the whole mesh).""" n = Vector((1 if axis == 'x' else 0, 1 if axis == 'y' else 0, 1 if axis == 'z' else 0)) bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], plane_co=n * value, plane_no=n, dist=1e-6, clear_inner=False, clear_outer=False) def faces_where(bm, nrm, plane_ax, plane_val, rng_a, rng_b, tol=1e-4): """faces with normal ~ nrm lying on plane (axis index, value) whose centre is inside the (a,b) ranges.""" a_ax, b_ax = [i for i in range(3) if i != plane_ax] out = [] for f in bm.faces: if f.normal.dot(Vector(nrm)) < 0.999: continue c = f.calc_center_median() if abs(c[plane_ax] - plane_val) > tol: continue if rng_a[0] < c[a_ax] < rng_a[1] and rng_b[0] < c[b_ax] < rng_b[1]: out.append(f) return out def punch_and_extrude(bm, faces, vec): """delete faces and extrude the hole boundary along vec (a clean reveal tunnel).""" cand = set(e for f in faces for e in f.edges) bmesh.ops.delete(bm, geom=faces, context='FACES_ONLY') edges = [e for e in cand if e.is_valid and len(e.link_faces) == 1] res = bmesh.ops.extrude_edge_only(bm, edges=edges) vs = [g for g in res['geom'] if isinstance(g, bmesh.types.BMVert)] for v in vs: v.co += Vector(vec) bm.normal_update() return vs # ------------------------------------------------------------------ audits def audit(obj): me = obj.data bm = bmesh.new(); bm.from_mesh(me) tris = quads = ngons = 0 for f in bm.faces: n = len(f.verts) if n == 3: tris += 1 elif n == 4: quads += 1 else: ngons += 1 nonman = sum(1 for e in bm.edges if len(e.link_faces) != 2) ntri = sum(max(1, len(f.verts) - 2) for f in bm.faces) zero = sum(1 for f in bm.faces if f.calc_area() < 1e-9) loose = sum(1 for v in bm.verts if not v.link_faces) bm.free() return dict(name=obj.name, verts=len(me.vertices), tris_total=ntri, tri=tris, quad=quads, ngon=ngons, open_edges=nonman, zero_area=zero, loose=loose, uv=len(me.uv_layers)) def pocket(bm, face, inset, depth): """inset a face and sink the inner part with vertical walls (inner face is the floor).""" n = face.normal.copy() bmesh.ops.inset_individual(bm, faces=[face], thickness=inset, use_even_offset=True) res = bmesh.ops.extrude_face_region(bm, geom=[face]) nf = [g for g in res['geom'] if isinstance(g, bmesh.types.BMFace)] nv = [g for g in res['geom'] if isinstance(g, bmesh.types.BMVert)] for v in nv: v.co -= n * depth bmesh.ops.delete(bm, geom=[face], context='FACES') bm.normal_update() return nf def _mb_transform(self, M): bmesh.ops.transform(self.bm, matrix=M, verts=self.bm.verts[:]) MB.transform = _mb_transform def catmull(pts, per=6): """Catmull-Rom resample of control points (list of 3-tuples) -> list of Vectors.""" P = [Vector(p) for p in pts] P = [P[0] * 2 - P[1]] + P + [P[-1] * 2 - P[-2]] out = [] for i in range(1, len(P) - 2): p0, p1, p2, p3 = P[i - 1], P[i], P[i + 1], P[i + 2] for k in range(per): t = k / per t2, t3 = t * t, t * t * t out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3)) out.append(P[-2]) return out def sink(bm, face, depth): """sink a face with vertical walls (no inset); returns the new floor face.""" n = face.normal.copy() res = bmesh.ops.extrude_face_region(bm, geom=[face]) nf = [g for g in res['geom'] if isinstance(g, bmesh.types.BMFace)] for v in [g for g in res['geom'] if isinstance(g, bmesh.types.BMVert)]: v.co -= n * depth bmesh.ops.delete(bm, geom=[face], context='FACES') bm.normal_update() return nf[0]