# Captain's quarters -- geometry helpers. # Everything is built on wall frames: u along the wall, v across it, w = u x v pointing into the room. # Outlines are CCW lists of (u, v) seen from the room, so front faces point at the viewer. # No booleans: openings are rings bridged between two outlines with equal vertex counts. import bpy, bmesh, math from mathutils import Vector class Frame: def __init__(self, origin, u, v): self.o = Vector(origin) self.u = Vector(u).normalized() self.v = Vector(v).normalized() self.w = self.u.cross(self.v) def p(self, u, v, w=0.0): return self.o + self.u * u + self.v * v + self.w * w def at(self, du=0.0, dv=0.0, dw=0.0): return Frame(self.p(du, dv, dw), self.u, self.v) def rect(u0, v0, u1, v1): return [(u0, v0), (u1, v0), (u1, v1), (u0, v1)] def shape(u0, v0, u1, v1, r=0.0, seg=6, bl=None, br=None, tr=None, tl=None): """Rectangle with per-corner treatment: r > 0 round, r < 0 45-degree chamfer, 0 sharp.""" cs = [r if c is None else c for c in (bl, br, tr, tl)] pts = [] def corner(cx, cy, a0, rr, p_in, p_out, sharp): if rr > 0: for i in range(seg + 1): a = math.radians(a0 + 90.0 * i / seg) pts.append((cx + rr * math.cos(a), cy + rr * math.sin(a))) elif rr < 0: pts.extend((p_in, p_out)) else: pts.append(sharp) a = abs(cs[0]); corner(u0 + a, v0 + a, 180, cs[0], (u0, v0 + a), (u0 + a, v0), (u0, v0)) a = abs(cs[1]); corner(u1 - a, v0 + a, 270, cs[1], (u1 - a, v0), (u1, v0 + a), (u1, v0)) a = abs(cs[2]); corner(u1 - a, v1 - a, 0, cs[2], (u1, v1 - a), (u1 - a, v1), (u1, v1)) a = abs(cs[3]); corner(u0 + a, v1 - a, 90, cs[3], (u0 + a, v1), (u0, v1 - a), (u0, v1)) return pts def circle(cu, cv, r, seg=24): return [(cu + r * math.cos(2 * math.pi * i / seg), cv + r * math.sin(2 * math.pi * i / seg)) for i in range(seg)] def offset(P, d): """Miter offset of a CCW outline; d > 0 shrinks it.""" if abs(d) < 1e-9: return list(P) n = len(P) out = [] for i in range(n): p0 = Vector(P[i - 1]); p1 = Vector(P[i]); p2 = Vector(P[(i + 1) % n]) e1 = p1 - p0; e2 = p2 - p1 if e1.length < 1e-9: e1 = e2.copy() if e2.length < 1e-9: e2 = e1.copy() e1.normalize(); e2.normalize() n1 = Vector((-e1.y, e1.x)); n2 = Vector((-e2.y, e2.x)) b = n1 + n2 if b.length < 1e-9: b = n1.copy() b.normalize() k = d / max(b.dot(n1), 0.3) q = p1 + b * k out.append((q.x, q.y)) return out class Part: """One mesh object in the making. Faces carry a material name; UVs are box-projected at build time unless a face was given explicit UVs (decals, screens, the quilt).""" def __init__(self, name): self.name = name self.bm = bmesh.new() self.mats = [] self.uv = self.bm.loops.layers.uv.new('UVMap') self.ex = self.bm.faces.layers.int.new('explicit_uv') def mi(self, m): if m not in self.mats: self.mats.append(m) return self.mats.index(m) # -- low level ------------------------------------------------------------------------------- def _ring(self, fr, P, w): return [self.bm.verts.new(fr.p(u, v, w)) for (u, v) in P] def _band(self, A, B, mat): n = len(A); mi = self.mi(mat) for i in range(n): j = (i + 1) % n f = self.bm.faces.new((A[i], A[j], B[j], B[i])) f.material_index = mi; f.smooth = True def _cap(self, R, mat, flip=False): f = self.bm.faces.new(R[::-1] if flip else R) f.material_index = self.mi(mat); f.smooth = True return f def _bevel(self, fr, P, w_side, w_top, c, segs, sign=1.0): """Rings from the side edge up to the front edge. sign=1: outline shrinks (outer edge); sign=-1: outline grows (edge of a hole).""" rings = [] for k in range(1, segs + 1): t = (math.pi / 2) * k / segs ins = c * (1 - math.cos(t)) * sign w = w_side + (w_top - w_side) * math.sin(t) rings.append(self._ring(fr, offset(P, ins), w)) return rings # -- solids ---------------------------------------------------------------------------------- def prism(self, fr, P, w0, w1, mat, c=0.003, segs=1, back=False, cap=True, side_mat=None, cap_mat=None): """Outline P pushed from w0 to w1 (w1 is the front), chamfer/round c on the front edge.""" side_mat = side_mat or mat c = min(c, abs(w1 - w0) * 0.9) A = self._ring(fr, P, w0) if c > 1e-6: B = self._ring(fr, P, w1 - c) self._band(A, B, side_mat) prev = B for R in self._bevel(fr, P, w1 - c, w1, c, segs): self._band(prev, R, mat); prev = R else: prev = self._ring(fr, P, w1) self._band(A, prev, side_mat) if cap: self._cap(prev, cap_mat or mat) if back: self._cap(A, side_mat, flip=True) return prev def ring(self, fr, Po, Pi, w0, w1, mat, c=0.003, segs=1, reveal_w=None, reveal_mat=None, floor_mat=None, outer=True, ci=None, back=False): """Plate with a hole. Po and Pi need equal vertex counts. The hole's wall runs back to reveal_w; floor_mat closes it there (a pocket).""" assert len(Po) == len(Pi), (self.name, len(Po), len(Pi)) ci = c if ci is None else ci reveal_mat = reveal_mat or mat # outer edge if outer: A = self._ring(fr, Po, w0) B = self._ring(fr, Po, w1 - c) self._band(A, B, mat) prev = B for R in self._bevel(fr, Po, w1 - c, w1, c, segs): self._band(prev, R, mat); prev = R O = prev else: O = self._ring(fr, Po, w1) # hole edge, front to back I = self._ring(fr, offset(Pi, -ci), w1) self._band(O, I, mat) prev = I for k in range(1, segs + 1): t = (math.pi / 2) * k / segs ins = -ci * math.cos(t) w = w1 - ci * math.sin(t) R = self._ring(fr, offset(Pi, ins), w) self._band(prev, R, mat); prev = R if reveal_w is None: reveal_w = w0 if reveal_w < w1 - ci - 1e-6: R = self._ring(fr, Pi, reveal_w) self._band(prev, R, reveal_mat); prev = R if floor_mat: self._cap(prev, floor_mat) if back: A2 = self._ring(fr, Po, w0); B2 = self._ring(fr, Pi, w0) self._band(B2, A2, mat) return prev def tunnel(self, fr, P, w_front, w_back, mat, back_mat=None): """Inward-facing walls of a niche from w_front back to w_back, closed at the back.""" A = self._ring(fr, P, w_front); B = self._ring(fr, P, w_back) self._band(A, B, mat) if back_mat: self._cap(B, back_mat) def bridge(self, fr, Pa, wa, Pb, wb, mat): """Faces between two outlines; front faces look at +w when Pb lies inside Pa.""" A = self._ring(fr, Pa, wa); B = self._ring(fr, Pb, wb) self._band(A, B, mat) def box(self, fr, u0, v0, u1, v1, w0, w1, mat, c=0.003, segs=1, back=False, r=0.0, seg=6, **kw): P = shape(u0, v0, u1, v1, r=r, seg=seg) if r else rect(u0, v0, u1, v1) return self.prism(fr, P, w0, w1, mat, c=c, segs=segs, back=back, **kw) def quad(self, pts, mat, uvs=None): vs = [self.bm.verts.new(p) for p in pts] f = self.bm.faces.new(vs) f.material_index = self.mi(mat); f.smooth = True if uvs: f[self.ex] = 1 for l, t in zip(f.loops, uvs): l[self.uv].uv = t return f def decal(self, fr, u0, v0, u1, v1, w, mat, uvr): """Flat quad with atlas UVs (x0, y0, x1, y1), floating w in front of the frame.""" x0, y0, x1, y1 = uvr return self.quad([fr.p(u0, v0, w), fr.p(u1, v0, w), fr.p(u1, v1, w), fr.p(u0, v1, w)], mat, uvs=[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) def grid(self, fr, u0, v0, u1, v1, nu, nv, hfun, mat, uvfun=None): """Height-field sheet: hfun(a, b) -> w for a, b in 0..1. Explicit UVs from uvfun(a, b).""" vs = [[self.bm.verts.new(fr.p(u0 + (u1 - u0) * i / nu, v0 + (v1 - v0) * j / nv, hfun(i / nu, j / nv))) for i in range(nu + 1)] for j in range(nv + 1)] mi = self.mi(mat) for j in range(nv): for i in range(nu): f = self.bm.faces.new((vs[j][i], vs[j][i + 1], vs[j + 1][i + 1], vs[j + 1][i])) f.material_index = mi; f.smooth = True if uvfun: f[self.ex] = 1 for l, (a, b) in zip(f.loops, ((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1))): l[self.uv].uv = uvfun(a / nu, b / nv) return vs # -- finish ---------------------------------------------------------------------------------- def build(self, coll, sharp_deg=50.0, weighted=True, uv_scale=1.0, origin=(0, 0, 0), tri_ngons=True): bm = self.bm 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') bm.normal_update() for f in bm.faces: if f[self.ex]: continue n = f.normal ax = max(range(3), key=lambda i: abs(n[i])) for l in f.loops: co = l.vert.co if ax == 0: t = (co.y, co.z) elif ax == 1: t = (co.x, co.z) else: t = (co.x, co.y) l[self.uv].uv = (t[0] * uv_scale, t[1] * uv_scale) o = Vector(origin) if o.length > 0: for v in bm.verts: v.co -= o me = bpy.data.meshes.new(self.name) bm.to_mesh(me); bm.free() for m in self.mats: me.materials.append(bpy.data.materials[m]) me.set_sharp_from_angle(angle=math.radians(sharp_deg)) ob = bpy.data.objects.new(self.name, me) ob.location = o coll.objects.link(ob) if weighted: md = ob.modifiers.new('WeightedNormal', 'WEIGHTED_NORMAL') md.keep_sharp = True; md.weight = 50; md.mode = 'FACE_AREA' return ob def get_coll(name, parent=None): c = bpy.data.collections.get(name) if not c: c = bpy.data.collections.new(name) (parent or bpy.context.scene.collection).children.link(c) return c def audit(ob): me = ob.data q = sum(1 for p in me.polygons if len(p.vertices) == 4) t = sum(1 for p in me.polygons if len(p.vertices) == 3) n = sum(1 for p in me.polygons if len(p.vertices) > 4) z = sum(1 for p in me.polygons if p.area < 1e-9) return {'verts': len(me.vertices), 'quads': q, 'tris': t, 'ngons': n, 'zero_area': z, 'triangles': q * 2 + t, 'uv': len(me.uv_layers)}