"""Geometry helpers for the pressure door (models/pressure-door). Run inside Blender (exec'd by pd_build.py). Everything is built as quads: surfaces of revolution (lathe), tubes swept along paths, rounded boxes made like a cube-sphere (no bevel corner patches), and slabs with a rounded front edge. Only caps are triangle fans; flat n-gon caps are triangulated in finish(). No booleans anywhere. Each face is oriented as it is made, against an 'outward' direction the builder knows, so open surfaces are right too. """ import bpy, bmesh, math from mathutils import Vector, Matrix TAU = 2.0 * math.pi I4 = Matrix.Identity(4) def V(*a): return Vector(a) def face(bm, verts, mat=0, out=None, ref=None): """make a face and turn it so its normal agrees with `out` (a direction) or points away from `ref` (a point)""" uq = [] for v in verts: if v not in uq: uq.append(v) if len(uq) < 3: return None try: f = bm.faces.new(uq) except ValueError: return None f.material_index = mat f.normal_update() if out is None and ref is not None: out = f.calc_center_median() - Vector(ref) if out is not None and f.normal.dot(out) < 0: f.normal_flip() return f # ------------------------------------------------------------------ 2D profiles def fillet2(pts, radii, seg=3, closed=False): """round the corners of a 2D polyline. radii: one number or one per point (0 = keep the corner sharp)""" P = [Vector(p) for p in pts] n = len(P) if not isinstance(radii, (list, tuple)): radii = [radii] * n out = [] for i in range(n): if not closed and (i == 0 or i == n - 1): out.append(P[i].copy()) continue r = radii[i] p0, p1, p2 = P[i - 1], P[i], P[(i + 1) % n] if r <= 0: out.append(p1.copy()) continue d1, d2 = p0 - p1, p2 - p1 l1, l2 = d1.length, d2.length d1.normalize() d2.normalize() ang = math.acos(max(-1.0, min(1.0, d1.dot(d2)))) if ang < 1e-3 or ang > math.pi - 1e-3: out.append(p1.copy()) continue t = min(r / math.tan(ang / 2), l1 * 0.5, l2 * 0.5) re = t * math.tan(ang / 2) a, b = p1 + d1 * t, p1 + d2 * t c = p1 + (d1 + d2).normalized() * (re / math.sin(ang / 2)) va, vb = a - c, b - c sweep = math.atan2(va.x * vb.y - va.y * vb.x, va.dot(vb)) for k in range(seg + 1): out.append(c + Matrix.Rotation(sweep * k / seg, 2) @ va) clean = [out[0]] for p in out[1:]: if (p - clean[-1]).length > 1e-6: clean.append(p) if closed and len(clean) > 2 and (clean[0] - clean[-1]).length < 1e-6: clean.pop() return clean def prof(spec, seg=3): """spec: [(r, h, fillet)] -> filleted 2D profile""" return fillet2([(a, b) for a, b, _ in spec], [c for _, _, c in spec], seg) # ------------------------------------------------------------------ lathe AX = {'Y': (V(1, 0, 0), V(0, 0, 1), V(0, 1, 0)), # angle 0 = +X, 90 deg = +Z (seen from the front, -Y, counter-clockwise) 'Z': (V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)), 'X': (V(0, 1, 0), V(0, 0, 1), V(1, 0, 0))} def lathe(bm, profile, n, center=(0, 0, 0), axis='Y', a0=0.0, a1=TAU, mat=0, M=None): """revolve profile [(r, h)] about the axis through center. The outside must lie on the LEFT of the direction of travel in (r, h), i.e. along (-dh, dr). Points with r == 0 sit on the axis (fans).""" u, v, w = AX[axis] C = Vector(center) full = abs((a1 - a0) - TAU) < 1e-9 ncol = n if full else n + 1 angs = [a0 + (a1 - a0) * j / n for j in range(ncol)] R3 = M.to_3x3() if M else None rows = [] for (r, h) in profile: if r < 1e-7: p = C + w * h vv = bm.verts.new(M @ p if M else p) rows.append([vv] * ncol) else: row = [] for a in angs: p = C + (u * math.cos(a) + v * math.sin(a)) * r + w * h row.append(bm.verts.new(M @ p if M else p)) rows.append(row) faces = [] for i in range(len(profile) - 1): (r0, h0), (r1, h1) = profile[i], profile[i + 1] dr, dh = r1 - r0, h1 - h0 if abs(dr) < 1e-9 and abs(dh) < 1e-9: continue for j in range(n): j2 = (j + 1) % ncol am = a0 + (a1 - a0) * (j + 0.5) / n rad = u * math.cos(am) + v * math.sin(am) out = rad * (-dh) + w * dr if R3: out = R3 @ out f = face(bm, [rows[i][j], rows[i + 1][j], rows[i + 1][j2], rows[i][j2]], mat, out=out) if f: faces.append(f) return faces, rows # ------------------------------------------------------------------ tubes def arc_pts(center, radius, a0, a1, n, axis='Y'): u, v, w = AX[axis] C = Vector(center) return [C + (u * math.cos(a0 + (a1 - a0) * k / n) + v * math.sin(a0 + (a1 - a0) * k / n)) * radius for k in range(n + 1)] def fillet3(pts, r, seg=4): """round the corners of a 3D polyline with arcs of radius r""" P = [Vector(p) for p in pts] out = [P[0]] for i in range(1, len(P) - 1): p0, p1, p2 = P[i - 1], P[i], P[i + 1] d1, d2 = (p0 - p1), (p2 - p1) l1, l2 = d1.length, d2.length d1.normalize() d2.normalize() ang = math.acos(max(-1.0, min(1.0, d1.dot(d2)))) if ang > math.radians(172.0): # nearly straight: keep the point (arcs come already sampled) out.append(p1) continue t = min(r / math.tan(ang / 2), l1 * 0.5, l2 * 0.5) re = t * math.tan(ang / 2) a, b = p1 + d1 * t, p1 + d2 * t c = p1 + (d1 + d2).normalized() * (re / math.sin(ang / 2)) va, vb = a - c, b - c ax = va.cross(vb) th = va.angle(vb) for k in range(seg + 1): out.append(c + Matrix.Rotation(th * k / seg, 3, ax.normalized()) @ va) out.append(P[-1]) return out def _frames(path, closed): n = len(path) T = [] for i in range(n): if closed: t = path[(i + 1) % n] - path[i - 1] else: t = path[min(i + 1, n - 1)] - path[max(i - 1, 0)] T.append(t.normalized()) t0 = T[0] ref = V(0, 0, 1) if abs(t0.z) < 0.9 else V(1, 0, 0) N = [(ref - t0 * ref.dot(t0)).normalized()] for i in range(1, n): b = T[i - 1].cross(T[i]) nn = N[-1].copy() if b.length > 1e-9: nn = Matrix.Rotation(T[i - 1].angle(T[i]), 3, b.normalized()) @ nn nn = (nn - T[i] * nn.dot(T[i])).normalized() N.append(nn) return T, N def tube(bm, path, radius, seg=12, closed=False, caps=('dome', 'dome'), mat=0, cap_mat=None): """sweep a circle along path. caps: 'dome', 'flat' or None for each end""" path = [Vector(p) for p in path] T, N = _frames(path, closed) rings = [] for i, p in enumerate(path): B = T[i].cross(N[i]) rings.append([bm.verts.new(p + (N[i] * math.cos(k * TAU / seg) + B * math.sin(k * TAU / seg)) * radius) for k in range(seg)]) faces = [] nseg = len(path) if closed else len(path) - 1 for i in range(nseg): i2 = (i + 1) % len(path) c = (path[i] + path[i2]) * 0.5 for k in range(seg): k2 = (k + 1) % seg f = face(bm, [rings[i][k], rings[i2][k], rings[i2][k2], rings[i][k2]], mat, ref=c) if f: faces.append(f) if not closed: cm = mat if cap_mat is None else cap_mat for end, sgn in ((0, -1.0), (len(path) - 1, 1.0)): kind = caps[0] if end == 0 else caps[1] if not kind: continue p, t = path[end], T[end] * sgn B = T[end].cross(N[end]) prev = rings[end] if kind == 'dome': steps = 3 for s in range(1, steps): th = (math.pi / 2) * s / steps cc = p + t * radius * math.sin(th) rr = radius * math.cos(th) ring = [bm.verts.new(cc + (N[end] * math.cos(k * TAU / seg) + B * math.sin(k * TAU / seg)) * rr) for k in range(seg)] for k in range(seg): k2 = (k + 1) % seg faces.append(face(bm, [prev[k], ring[k], ring[k2], prev[k2]], cm, ref=p)) prev = ring tip = bm.verts.new(p + t * radius) else: tip = bm.verts.new(p) for k in range(seg): k2 = (k + 1) % seg faces.append(face(bm, [prev[k], tip, prev[k2]], cm, out=t)) return [f for f in faces if f] # ------------------------------------------------------------------ rounded box (cube-sphere layout, all quads) def rbox(bm, size, r, seg=3, M=I4, mat=0, drop=(), flip=False): """box of `size` centred on the origin of M, every edge rounded with r. drop: faces left out, e.g. ('+y',)""" h = [s * 0.5 for s in size] r = max(0.0, min(r, *h)) inner = [hh - r for hh in h] def ticks(ax): hh, inn = h[ax], inner[ax] if r < 1e-7 or seg == 0: return [-hh, hh] band = [inn + r * math.tan(math.pi / 4 * k / seg) for k in range(seg + 1)] t = sorted([-b for b in band] + band) out = [] for x in t: if not out or abs(x - out[-1]) > 1e-7: out.append(x) return out TK = [ticks(0), ticks(1), ticks(2)] cache = {} R3 = M.to_3x3() def vert(p): key = tuple(round(c, 7) for c in p) if key in cache: return cache[key] q = Vector([max(-inner[k], min(inner[k], p[k])) for k in range(3)]) d = Vector(p) - q pp = q + d.normalized() * r if d.length > 1e-9 else Vector(p) vv = bm.verts.new(M @ pp) cache[key] = vv return vv faces = [] for ax in range(3): a1, a2 = [k for k in range(3) if k != ax] for sgn in (-1, 1): if (('-' if sgn < 0 else '+') + 'xyz'[ax]) in drop: continue nrm = Vector((0, 0, 0)) nrm[ax] = sgn * (-1 if flip else 1) out = R3 @ nrm for i in range(len(TK[a1]) - 1): for j in range(len(TK[a2]) - 1): cs = [] for (ii, jj) in ((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)): p = [0.0, 0.0, 0.0] p[ax] = sgn * h[ax] p[a1] = TK[a1][ii] p[a2] = TK[a2][jj] cs.append(vert(p)) f = face(bm, cs, mat, out=out) if f: faces.append(f) return faces # ------------------------------------------------------------------ slab: a 2D outline pushed out with a rounded front edge def _inset(loop, d): """offset a closed CCW 2D loop inward by d (miter)""" n = len(loop) out = [] for i in range(n): p0, p1, p2 = loop[i - 1], loop[i], loop[(i + 1) % n] e1 = (p1 - p0).normalized() e2 = (p2 - p1).normalized() n1 = Vector((-e1.y, e1.x)) n2 = Vector((-e2.y, e2.x)) m = (n1 + n2) if m.length < 1e-9: m = n1 m.normalize() c = max(0.3, m.dot(n1)) out.append(p1 + m * (d / c)) return out def slab(bm, outline, depth, r, seg=3, origin=(0, 0, 0), basis=None, mat=0, back=False, side_mat=None, cap=True): """outline: closed 2D loop, counter-clockwise seen from the front. basis (u, v, w): w = front direction. Pushed from w = 0 to w = depth; the front edge rounded with r. back=True closes the back too.""" u, v, w = basis or (V(1, 0, 0), V(0, 0, 1), V(0, -1, 0)) O = Vector(origin) loop = [Vector(p) for p in outline] r = min(r, depth) levels = [(0.0, 0.0)] + ([(0.0, depth - r)] if depth - r > 1e-6 else []) for k in range(1, seg + 1): th = (math.pi / 2) * k / seg levels.append((r * (1 - math.cos(th)), depth - r + r * math.sin(th))) if r < 1e-6: levels = [(0.0, 0.0), (0.0, depth)] rings = [] for off, ww in levels: L = _inset(loop, off) if off > 1e-9 else loop rings.append([bm.verts.new(O + u * p.x + v * p.y + w * ww) for p in L]) n = len(loop) faces = [] sm = mat if side_mat is None else side_mat for i in range(len(rings) - 1): for k in range(n): k2 = (k + 1) % n e = loop[k2] - loop[k] o2 = Vector((e.y, -e.x)) # outward of a CCW loop out = u * o2.x + v * o2.y + w * (1.0 if i >= 1 else 0.0) * 0.3 f = face(bm, [rings[i][k], rings[i][k2], rings[i + 1][k2], rings[i + 1][k]], sm, out=out) if f: faces.append(f) if cap: f = face(bm, rings[-1], mat, out=w) if f: faces.append(f) if back: f = face(bm, rings[0], mat, out=-w) if f: faces.append(f) return faces def rrect(w, h, r, seg=3, cx=0.0, cy=0.0): """rounded rectangle outline, CCW""" x0, x1, y0, y1 = cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2 return fillet2([(x0, y0), (x1, y0), (x1, y1), (x0, y1)], r, seg, closed=True) # ------------------------------------------------------------------ floor def squash_to_floor(bm, cx, cz, zf=0.0, verts=None): """move every vertex below the floor along its ray from the door axis (cx, cz) in XZ onto the floor""" for v in (verts if verts is not None else bm.verts): x, y, z = v.co if z < zf - 1e-9: t = (zf - cz) / (z - cz) v.co = Vector((cx + (x - cx) * t, y, zf)) def drop_floor_faces(bm, zf=0.0, eps=1e-5): """delete faces that lie in the floor plane (hidden contact) and faces with no area""" kill = [f for f in bm.faces if all(abs(v.co.z - zf) < eps for v in f.verts) or f.calc_area() < 1e-9] bmesh.ops.delete(bm, geom=kill, context='FACES') loose = [v for v in bm.verts if not v.link_faces] bmesh.ops.delete(bm, geom=loose, context='VERTS') # ------------------------------------------------------------------ objects def mat_rot_y(phi): """rotation that takes +X to the direction at angle phi in the XZ plane (counter-clockwise seen from the front)""" return Matrix.Rotation(-phi, 4, 'Y') def finish(bm, name, coll, mats, origin=(0, 0, 0), sharp_deg=38.0, merge=1e-5): bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=merge) bmesh.ops.dissolve_degenerate(bm, edges=list(bm.edges), dist=1e-7) ngons = [f for f in bm.faces if len(f.verts) > 4] if ngons: bmesh.ops.triangulate(bm, faces=ngons, quad_method='BEAUTY', ngon_method='BEAUTY') me = bpy.data.meshes.get(name) if me is None: me = bpy.data.meshes.new(name) else: me.clear_geometry() me.materials.clear() bm.to_mesh(me) bm.free() me.transform(Matrix.Translation(-Vector(origin))) used = sorted({p.material_index for p in me.polygons}) remap = {old: new for new, old in enumerate(used)} for p in me.polygons: p.material_index = remap[p.material_index] for old in used: me.materials.append(mats[old]) me.shade_smooth() me.set_sharp_from_angle(angle=math.radians(sharp_deg)) ob = bpy.data.objects.get(name) if ob is None: ob = bpy.data.objects.new(name, me) else: ob.data = me if ob.name not in coll.objects: for c in list(ob.users_collection): c.objects.unlink(ob) coll.objects.link(ob) ob.location = origin return ob 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) ng = sum(1 for p in me.polygons if len(p.vertices) > 4) tris = sum(len(p.vertices) - 2 for p in me.polygons) return dict(name=ob.name, quads=q, tris=t, ngons=ng, game_tris=tris, verts=len(me.vertices), mats=[m.name for m in me.materials])