""" A small hard-surface toolkit for the station's own geometry (build/outpost.py, build/outpost_site.py). Everything is made of a few kinds of solid, all in quads, with chamfered edges where they show: prism a flat outline pushed out of a plane, its front edges chamfered: armour plates, blocks, pads loft rings of points joined up: tapered pylons, masts, tanks tube a pipe along a line, with mitred or rounded bends sweep a profile drawn along a path on the ground plan: plinths, cornices, kerbs A Part collects such solids for one object. Every closed solid is checked on its own: if its faces look inwards (its volume comes out negative) they are turned round, so the way a solid was wound never matters. Open solids (plates without a back, lying against a wall) are wound right by construction. No booleans, no modifiers. UVs are a box projection in metres (the pack's painted metals are projected from three sides in space and do not read them; the tiling floor materials do). """ import math import bpy from mathutils import Matrix, Vector UV_TILE = 4.4 # one repeat of the kit's tiling materials, in metres def V(p): return Vector((p[0], p[1], p[2])) class Frame(object): """A plane to model on: origin, u (along it), v (up it), n (out of it).""" def __init__(self, o, u, v, n): self.o, self.u, self.v, self.n = V(o), V(u).normalized(), V(v).normalized(), V(n).normalized() self.flip = self.u.cross(self.v).dot(self.n) < 0.0 def p(self, a, b, h=0.0): return self.o + self.u * a + self.v * b + self.n * h @staticmethod def wall(axis, plane, outward, z=0.0): """A wall in the plane x = plane (axis "x") or y = plane (axis "y"), looking along `outward` (+1 or -1). Its first coordinate is the world coordinate along the wall, its second is height.""" if axis == "x": return Frame((plane, 0.0, z), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0), (float(outward), 0.0, 0.0)) return Frame((0.0, plane, z), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, float(outward), 0.0)) @staticmethod def ground(x, y, z, yaw=0.0): c, s = math.cos(math.radians(yaw)), math.sin(math.radians(yaw)) return Frame((x, y, z), (c, s, 0.0), (-s, c, 0.0), (0.0, 0.0, 1.0)) @staticmethod def facing(o, n, up=(0.0, 0.0, 1.0)): """A plane through o looking along n, its second coordinate as near to `up` as it can be.""" n = V(n).normalized() u = V(up).cross(n) if u.length < 1e-6: u = Vector((1.0, 0.0, 0.0)) u.normalize() return Frame(o, u, n.cross(u), n) # --------------------------------------------------------------------------------------------------------------- class Part(object): """Faces for one object.""" def __init__(self, name): self.name = name self.verts, self.faces, self.fmat, self.fsmooth = [], [], [], [] self.keys = [] # material keys in slot order self._vmap, self._start, self._closed = {}, 0, True self.solids = 0 self.turned = 0 # -- one solid at a time ---------------------------------------------------------------------------------- def begin(self, closed=True): self._vmap, self._start, self._closed = {}, len(self.faces), closed def end(self): self.solids += 1 if not self._closed: return vol = 0.0 for f in self.faces[self._start:]: a = self.verts[f[0]] for k in range(1, len(f) - 1): b, c = self.verts[f[k]], self.verts[f[k + 1]] vol += a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]) if vol < 0.0: for k in range(self._start, len(self.faces)): self.faces[k] = self.faces[k][::-1] self.turned += 1 def _v(self, p): key = (round(p[0], 4), round(p[1], 4), round(p[2], 4)) i = self._vmap.get(key) if i is None: i = len(self.verts) self._vmap[key] = i self.verts.append(key) return i def slot(self, key): if key not in self.keys: self.keys.append(key) return self.keys.index(key) def face(self, pts, mat, smooth=False, rev=False): idx = [self._v(p) for p in pts] out = [] for i in idx: # drop repeated corners (a clipped corner of size nought) if not out or out[-1] != i: out.append(i) if len(out) > 1 and out[0] == out[-1]: out.pop() if len(out) < 3: return self.faces.append(out[::-1] if rev else out) self.fmat.append(self.slot(mat)) self.fsmooth.append(bool(smooth)) def cap(self, loop, mat, rev=False): """A flat convex face of any number of corners, as quads in strips (a triangle is left over when the number is odd).""" n = len(loop) if n <= 4: self.face(loop, mat, rev=rev) return i, j = 0, n - 1 while j - i >= 3: self.face([loop[i], loop[i + 1], loop[j - 1], loop[j]], mat, rev=rev) i, j = i + 1, j - 1 if j - i == 2: self.face([loop[i], loop[i + 1], loop[j]], mat, rev=rev) # -- to Blender ------------------------------------------------------------------------------------------- def stats(self): n3 = sum(1 for f in self.faces if len(f) == 3) return {"faces": len(self.faces), "tris": sum(len(f) - 2 for f in self.faces), "3-gons": n3, "solids": self.solids} def to_mesh(self, material_of, sharp_angle=35.0): """material_of(key) -> a Blender material.""" me = bpy.data.meshes.new(self.name) me.from_pydata([tuple(v) for v in self.verts], [], self.faces) for key in self.keys: me.materials.append(material_of(key)) me.polygons.foreach_set("material_index", self.fmat) me.polygons.foreach_set("use_smooth", self.fsmooth) uv = me.uv_layers.new(name="UVMap") k = 0 for poly in me.polygons: n = poly.normal ax, ay, az = abs(n.x), abs(n.y), abs(n.z) for li in poly.loop_indices: co = me.vertices[me.loops[li].vertex_index].co if az >= ax and az >= ay: c = (co.x, co.y) elif ax >= ay: c = (co.y, co.z) else: c = (co.x, co.z) uv.data[li].uv = (c[0] / UV_TILE, c[1] / UV_TILE) k += 1 me.update() me.validate() if any(self.fsmooth): try: me.set_sharp_from_angle(angle=math.radians(sharp_angle)) except Exception: # noqa: BLE001 pass return me # --------------------------------------------------------------------------------------------------------------- # outlines (lists of (a, b), counter-clockwise when seen from outside) def rect(a0, a1, b0, b1): return [(a0, b0), (a1, b0), (a1, b1), (a0, b1)] def clipped(a0, a1, b0, b1, bl=0.0, br=0.0, tr=0.0, tl=0.0): """A rectangle with its corners cut off at 45 degrees: bottom left, bottom right, top right, top left.""" pts = [] for (x, y), c, (dx0, dy0), (dx1, dy1) in ( ((a0, b0), bl, (0, 1), (1, 0)), ((a1, b0), br, (-1, 0), (0, 1)), ((a1, b1), tr, (0, -1), (-1, 0)), ((a0, b1), tl, (1, 0), (0, -1))): if c > 1e-6: pts.append((x + dx0 * c, y + dy0 * c)) pts.append((x + dx1 * c, y + dy1 * c)) else: pts.append((x, y)) return pts def ngon(cx, cy, r, n, start=0.0): return [(cx + r * math.cos(start + 2.0 * math.pi * k / n), cy + r * math.sin(start + 2.0 * math.pi * k / n)) for k in range(n)] def inset(outline, d): """A convex outline moved inwards by d.""" n = len(outline) lines = [] for i in range(n): (x0, y0), (x1, y1) = outline[i], outline[(i + 1) % n] dx, dy = x1 - x0, y1 - y0 ln = math.hypot(dx, dy) or 1.0 nx, ny = -dy / ln, dx / ln # inwards, for a counter-clockwise outline lines.append((x0 + nx * d, y0 + ny * d, dx, dy)) out = [] for i in range(n): x0, y0, dx0, dy0 = lines[i - 1] x1, y1, dx1, dy1 = lines[i] den = dx0 * dy1 - dy0 * dx1 if abs(den) < 1e-9: out.append((x1, y1)) else: t = ((x1 - x0) * dy1 - (y1 - y0) * dx1) / den out.append((x0 + dx0 * t, y0 + dy0 * t)) return out def _keeps_shape(outline, d): """Does the outline, moved inwards by d, still have every edge running the way it did?""" ins = inset(outline, d) n = len(outline) for i in range(n): j = (i + 1) % n ex, ey = outline[j][0] - outline[i][0], outline[j][1] - outline[i][1] ix, iy = ins[j][0] - ins[i][0], ins[j][1] - ins[i][1] if ex * ix + ey * iy <= 1e-9: return False return True def _even(outline): """An outline with an even number of corners: a point is put on the longest edge if need be.""" if len(outline) % 2 == 0: return list(outline) n = len(outline) k = max(range(n), key=lambda i: math.hypot(outline[(i + 1) % n][0] - outline[i][0], outline[(i + 1) % n][1] - outline[i][1])) a, b = outline[k], outline[(k + 1) % n] return list(outline[:k + 1]) + [((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)] + list(outline[k + 1:]) # --------------------------------------------------------------------------------------------------------------- def prism(part, fr, outline, h0, h1, mat, cham=0.0, side=None, edge=None, back=True, front=True, panel=None, panel_mat=None): """The outline pushed out of the frame's plane from height h0 to h1. `cham`: the front edges are cut off by this much (a 45 degree chamfer). mat: front, side: the sides (default: as the front), edge: the chamfer. back=False leaves the face at h0 out (for things lying against a wall); it is then wound by construction. panel=(rim, depth): the front is a rim of that width round a field sunk in by `depth` (a pressed panel).""" side = side or mat edge = edge or mat ol = _even(outline) n = len(ol) rev = fr.flip part.begin(closed=back and front) base = [fr.p(a, b, h0) for a, b in ol] if cham > 1e-6: sh = [fr.p(a, b, h1 - cham) for a, b in ol] top = [fr.p(a, b, h1) for a, b in inset(ol, cham)] else: sh = top = [fr.p(a, b, h1) for a, b in ol] for i in range(n): j = (i + 1) % n part.face([base[i], base[j], sh[j], sh[i]], side, rev=rev) if cham > 1e-6: part.face([sh[i], sh[j], top[j], top[i]], edge, rev=rev) if front and panel: # A rim that is wide against a clipped corner would turn that corner inside out (the full check found such # faces on the roof units, seen from behind). The rim is made narrower until the field keeps its shape; if # it cannot, the front stays plain. rim, depth = panel for k in (1.0, 0.6, 0.35): if _keeps_shape(ol, cham + rim * k + depth): rim = rim * k break else: panel = None if front and panel: hi = [fr.p(a, b, h1) for a, b in inset(ol, cham + rim)] lo = [fr.p(a, b, h1 - depth) for a, b in inset(ol, cham + rim + depth)] for i in range(n): j = (i + 1) % n part.face([top[i], top[j], hi[j], hi[i]], mat, rev=rev) part.face([hi[i], hi[j], lo[j], lo[i]], edge, rev=rev) part.cap(lo, panel_mat or mat, rev=rev) elif front: part.cap(top, mat, rev=rev) if back: part.cap(base[::-1], side, rev=rev) part.end() def box(part, lo, hi, mat, cham=0.0, top=None, clip=0.0): """A block standing on the ground plane z = lo[2]: its top edges chamfered by `cham`, its vertical edges cut off by `clip`.""" fr = Frame.ground(0.0, 0.0, lo[2]) prism(part, fr, clipped(lo[0], hi[0], lo[1], hi[1], clip, clip, clip, clip), 0.0, hi[2] - lo[2], top or mat, cham=cham, side=mat) def loft(part, rings, mat, caps=(True, True), smooth=False, cap_mat=None, mats=None): """Rings of points (all the same number) joined into quads. mats: one material per span between rings.""" part.begin(closed=caps[0] and caps[1]) n = len(rings[0]) for k in range(len(rings) - 1): r0, r1 = rings[k], rings[k + 1] m = mats[k] if mats else mat for i in range(n): j = (i + 1) % n part.face([r0[i], r0[j], r1[j], r1[i]], m, smooth=smooth) if caps[0]: part.cap(list(rings[0])[::-1], cap_mat or mat) if caps[1]: part.cap(list(rings[-1]), cap_mat or mat) part.end() def ring(c, r, n, axis=(0.0, 0.0, 1.0), start=0.0, ref=None): """n points on a circle round c, in the plane across `axis`.""" ax = V(axis).normalized() ref = V(ref) if ref is not None else (Vector((1.0, 0.0, 0.0)) if abs(ax.x) < 0.9 else Vector((0.0, 1.0, 0.0))) a = (ref - ax * ref.dot(ax)).normalized() b = ax.cross(a) c = V(c) return [c + a * (r * math.cos(start + 2.0 * math.pi * k / n)) + b * (r * math.sin(start + 2.0 * math.pi * k / n)) for k in range(n)] def cylinder(part, c0, c1, r, mat, sides=12, cham=0.0, cap_mat=None, r1=None, smooth=True): """From c0 to c1; radius r (r1 at the far end, for a cone). cham: both ends are chamfered.""" c0, c1 = V(c0), V(c1) ax = (c1 - c0) ln = ax.length ax = ax / ln r1 = r if r1 is None else r1 rings = [] if cham > 1e-6: rings.append(ring(c0, r - cham, sides, ax)) rings.append(ring(c0 + ax * cham, r, sides, ax)) rings.append(ring(c1 - ax * cham, r1, sides, ax)) rings.append(ring(c1, r1 - cham, sides, ax)) else: rings.append(ring(c0, r, sides, ax)) rings.append(ring(c1, r1, sides, ax)) loft(part, rings, mat, smooth=smooth, cap_mat=cap_mat) def fillet(path, radius, steps=3): """A line with its corners rounded: each inner corner becomes an arc of `steps` pieces.""" pts = [V(p) for p in path] if radius <= 1e-6 or len(pts) < 3: return pts out = [pts[0]] for k in range(1, len(pts) - 1): p, a, c = pts[k], pts[k - 1], pts[k + 1] d0, d1 = (a - p), (c - p) l0, l1 = d0.length, d1.length d0, d1 = d0 / l0, d1 / l1 ang = d0.angle(d1) if ang > math.pi - 1e-3: out.append(p) continue t = min(radius / math.tan(ang / 2.0), l0 * 0.45, l1 * 0.45) r = t * math.tan(ang / 2.0) centre = p + (d0 + d1).normalized() * (r / math.sin(ang / 2.0)) s, e = p + d0 * t, p + d1 * t v0, v1 = s - centre, e - centre sweep_ang = v0.angle(v1) axis = v0.cross(v1).normalized() for i in range(steps + 1): out.append(centre + Matrix.Rotation(sweep_ang * i / steps, 3, axis) @ v0) out.append(pts[-1]) return out def tube(part, path, r, mat, sides=8, bend=0.0, caps=(True, True), smooth=True, cap_mat=None): """A pipe of radius r along a line. Corners are mitred; with `bend` they are rounded first, to that radius.""" pts = fillet(path, bend) if bend > 1e-6 else [V(p) for p in path] n = len(pts) dirs = [(pts[k + 1] - pts[k]).normalized() for k in range(n - 1)] # a reference direction carried along the line without twist t0 = dirs[0] ref = Vector((0.0, 0.0, 1.0)) if abs(t0.z) < 0.9 else Vector((1.0, 0.0, 0.0)) nrm = (ref - t0 * ref.dot(t0)).normalized() rings = [] for k in range(n): d_in = dirs[k - 1] if k > 0 else dirs[0] d_out = dirs[k] if k < n - 1 else dirs[-1] nrm = (nrm - d_in * nrm.dot(d_in)).normalized() bi = d_in.cross(nrm) m = (d_in + d_out) if m.length < 1e-6: m = d_in.copy() m.normalize() den = d_in.dot(m) loop = [] for i in range(sides): a = 2.0 * math.pi * i / sides q = pts[k] + nrm * (r * math.cos(a)) + bi * (r * math.sin(a)) loop.append(q - d_in * ((q - pts[k]).dot(m) / den)) # along the pipe onto the plane of the mitre rings.append(loop) if k < n - 1: # carry the reference round the corner nrm = (nrm - d_out * nrm.dot(d_out)) if nrm.length < 1e-6: nrm = d_out.cross(bi) nrm.normalize() loft(part, rings, mat, caps=caps, smooth=smooth, cap_mat=cap_mat) def sweep(part, profile, path, mats, closed=False, cap_quads=None, ends=(True, True)): """A profile drawn along a path on the ground plan. profile [(n, z)]: n = distance to the right of the path's direction, z = height. Any simple polygon. path [(x, y)] or [(x, y, z0)]: the line; z0 lifts the profile. mats one material per edge of the profile (edge i runs from point i to point i + 1) cap_quads how the profile's face is cut into convex pieces at an open path's ends: lists of point numbers. Default: the profile is convex and is cut into strips.""" pts = [(p[0], p[1], p[2] if len(p) > 2 else 0.0) for p in path] n = len(pts) m = len(profile) rings = [] for k in range(n): if closed: a, c = pts[k - 1], pts[(k + 1) % n] else: a, c = pts[max(k - 1, 0)], pts[min(k + 1, n - 1)] p = pts[k] d0 = (p[0] - a[0], p[1] - a[1]) if (closed or k > 0) else (c[0] - p[0], c[1] - p[1]) d1 = (c[0] - p[0], c[1] - p[1]) if (closed or k < n - 1) else d0 l0, l1 = math.hypot(*d0) or 1.0, math.hypot(*d1) or 1.0 n0 = (d0[1] / l0, -d0[0] / l0) # to the right of the direction n1 = (d1[1] / l1, -d1[0] / l1) bx, by = n0[0] + n1[0], n0[1] + n1[1] bl = math.hypot(bx, by) or 1.0 bx, by = bx / bl, by / bl k_m = 1.0 / max(0.2, bx * n0[0] + by * n0[1]) # the mitre is longer than the profile is wide rings.append([Vector((p[0] + bx * k_m * pn, p[1] + by * k_m * pn, p[2] + pz)) for pn, pz in profile]) part.begin(closed=closed or (ends[0] and ends[1])) last = n if closed else n - 1 for k in range(last): r0, r1 = rings[k], rings[(k + 1) % n] for i in range(m): j = (i + 1) % m part.face([r0[i], r1[i], r1[j], r0[j]], mats[i]) if not closed: for which, rg in ((0, rings[0]), (1, rings[-1])): if not ends[which]: continue pieces = cap_quads or None if pieces: for q in pieces: loop = [rg[i] for i in q] part.face(loop if which == 0 else loop[::-1], mats[q[0]]) else: part.cap(list(rg) if which == 0 else list(rg)[::-1], mats[0]) part.end()