"""Geometry helpers for the robot cell (exec'd by rc_build.py and the checks). The user's picture (reference/ref_user.jpg, 1200 x 960) is one product shot of the cell from the front-right, at about eye height. Its camera was fitted to the cell's outer box (rc_fit.py, six corners, 3 px rms) and is modelled here (CAM): parts are traced in the picture's pixels (u right, v down) and put onto the plane of their face by casting the pixel's ray onto that plane. The same camera renders the side-by-side check (RC_PictureCamera in the file). World: x right along the front, y back (the front faces -y), z up, metres; the ground (the feet's soles) is z 0. Surfaces are quads made from loops of equal vertex count; flat caps are filled with quad ladders or, for concave outlines, ear-clipped triangles joined back into quads. Nothing is a boolean. (MB, prism, lathe, tube and the 2D tools are the canister station's, models/canister-station/build/cs_lib.py.) """ import bpy, bmesh, math from mathutils import Vector, Matrix # ------------------------------------------------------------------ the picture's camera (fitted, build/cam_fit.json) RES = (1200, 960) HB = 2.40 # the box: base rail's underside to the roof's top (sets the scale) FOOT = 0.09 # the levelling feet under it _FIT = dict(cam=(1.0347, -2.9256, 0.5588), yaw=-21.204, pitch=-1.436, roll=0.416, f=2028.66, W=1.2716, D=1.1982) W_BOX, D_BOX = _FIT["W"] * HB, _FIT["D"] * HB # 3.05 x 2.88 m FPX = _FIT["f"] CX, CY = RES[0] / 2.0, RES[1] / 2.0 LENS, SENSOR = FPX / RES[0] * 36.0, 36.0 class Camera: def __init__(s): c = _FIT["cam"] s.C = Vector((c[0] * HB, c[1] * HB, c[2] * HB + FOOT)) yaw, pitch, roll = (math.radians(_FIT[k]) for k in ("yaw", "pitch", "roll")) f = Vector((math.sin(yaw) * math.cos(pitch), math.cos(yaw) * math.cos(pitch), math.sin(pitch))) r = f.cross(Vector((0, 0, 1))).normalized() u = r.cross(f) cr, sr = math.cos(roll), math.sin(roll) s.f, s.r, s.u = f, cr * r + sr * u, -sr * r + cr * u def project(s, P): d = Vector(P) - s.C zc = d.dot(s.f) return (CX + FPX * d.dot(s.r) / zc, CY - FPX * d.dot(s.u) / zc) def ray(s, u, v): return (s.f + ((u - CX) / FPX) * s.r - ((v - CY) / FPX) * s.u).normalized() def on_plane(s, u, v, p0, n): d = s.ray(u, v) n = Vector(n) t = (Vector(p0) - s.C).dot(n) / d.dot(n) return s.C + t * d def on_y(s, u, v, y0): return s.on_plane(u, v, (0, y0, 0), (0, 1, 0)) def on_x(s, u, v, x0): return s.on_plane(u, v, (x0, 0, 0), (1, 0, 0)) def on_z(s, u, v, z0): return s.on_plane(u, v, (0, 0, z0), (0, 0, 1)) def matrix_world(s): """the Blender camera's matrix (looks down its -Z, Y up)""" z = -s.f return Matrix(((s.r.x, s.u.x, z.x, s.C.x), (s.r.y, s.u.y, z.y, s.C.y), (s.r.z, s.u.z, z.z, s.C.z), (0, 0, 0, 1))) CAM = Camera() def XZ(u, v, y0): """picture pixel -> (x, z) on the plane y = y0""" p = CAM.on_y(u, v, y0) return (p.x, p.z) def YZ(u, v, x0): """picture pixel -> (y, z) on the plane x = x0""" p = CAM.on_x(u, v, x0) return (p.y, p.z) # ------------------------------------------------------------------ 2D polygon tools (x, z) def area2(P): return sum(P[i][0] * P[(i + 1) % len(P)][1] - P[(i + 1) % len(P)][0] * P[i][1] for i in range(len(P))) / 2.0 def ccw(P): P = [tuple(p) for p in P] return P if area2(P) > 0 else P[::-1] def even(P): """an even vertex count (a ladder of quads needs it): split the longest edge""" if len(P) % 2 == 0: return list(P) k = max(range(len(P)), key=lambda i: math.dist(P[i], P[(i + 1) % len(P)])) a, b = P[k], P[(k + 1) % len(P)] return list(P[:k + 1]) + [((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)] + list(P[k + 1:]) def inset(P, d, cap=3.0): """offset a CCW polygon inward by d (mitred, the mitre clamped to cap * d)""" n = len(P) out = [] for i in range(n): a, b, c = P[i - 1], P[i], P[(i + 1) % n] e1 = (b[0] - a[0], b[1] - a[1]); l1 = math.hypot(*e1) or 1e-12 e2 = (c[0] - b[0], c[1] - b[1]); l2 = math.hypot(*e2) or 1e-12 n1 = (-e1[1] / l1, e1[0] / l1) n2 = (-e2[1] / l2, e2[0] / l2) den = 1.0 + n1[0] * n2[0] + n1[1] * n2[1] if den < 1e-6: m = n1 k = d else: m = ((n1[0] + n2[0]) / den, (n1[1] + n2[1]) / den) k = d ml = math.hypot(*m) * d if ml > cap * d: k = d * cap * d / ml out.append((b[0] + m[0] * k, b[1] + m[1] * k)) return out def safe_inset(P, d): """inset by d, or by less where a short side would fold over (every inset edge keeps its direction)""" while d > 1e-5: I = inset(P, d) n = len(P) ok = area2(I) > 0 and all( (I[(i + 1) % n][0] - I[i][0]) * (P[(i + 1) % n][0] - P[i][0]) + (I[(i + 1) % n][1] - I[i][1]) * (P[(i + 1) % n][1] - P[i][1]) > 0 for i in range(n)) if ok: return I, d d *= 0.5 return list(P), 0.0 def _cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) def _seg_x(p1, p2, q1, q2): d1, d2 = _cross(q1, q2, p1), _cross(q1, q2, p2) d3, d4 = _cross(p1, p2, q1), _cross(p1, p2, q2) return (d1 * d2 < -1e-18) and (d3 * d4 < -1e-18) def _inside(P, q): c = False n = len(P) for i in range(n): a, b = P[i], P[(i + 1) % n] if (a[1] > q[1]) != (b[1] > q[1]): x = a[0] + (q[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) if x > q[0]: c = not c return c def _convex_ccw(q): return all(_cross(q[i - 1], q[i], q[(i + 1) % len(q)]) > 1e-14 for i in range(len(q))) def ladder(P): """quads (index lists) between the two halves of an even CCW polygon, or None: rungs i <-> n-i from the best start""" n = len(P) best = None for s in range(n // 2): idx = [(s + k) % n for k in range(n)] A = [idx[k] for k in range(n // 2 + 1)] B = [idx[(n - k) % n] for k in range(n // 2 + 1)] quads, ok, worst = [], True, 1e9 for k in range(n // 2): q = [A[k], A[k + 1], B[k + 1], B[k]] q = list(dict.fromkeys(q)) pts = [P[i] for i in q] if len(q) == 3: if _cross(*pts) <= 1e-14: ok = False break elif not _convex_ccw(pts): ok = False break quads.append(q) # quality: the smallest corner sine for i in range(len(pts)): a, b, c = pts[i - 1], pts[i], pts[(i + 1) % len(pts)] u = (a[0] - b[0], a[1] - b[1]); w = (c[0] - b[0], c[1] - b[1]) lu, lw = math.hypot(*u), math.hypot(*w) if lu * lw > 0: worst = min(worst, abs(u[0] * w[1] - u[1] * w[0]) / (lu * lw)) if not ok: continue for k in range(1, n // 2): # rungs inside and not crossing the outline a, b = P[A[k]], P[B[k]] mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2) if not _inside(P, mid) or any(_seg_x(a, b, P[i], P[(i + 1) % n]) for i in range(n)): ok = False break if ok and (best is None or worst > best[0]): best = (worst, quads) return best[1] if best else None def earclip_quads(P): """ear-clipped triangles of a CCW polygon, greedily joined into convex quads""" n = len(P) V = list(range(n)) tris = [] guard = 0 while len(V) > 3 and guard < 10000: guard += 1 m = len(V) for k in range(m): i0, i1, i2 = V[k - 1], V[k], V[(k + 1) % m] if _cross(P[i0], P[i1], P[i2]) <= 1e-14: continue if any(_cross(P[i0], P[i1], P[j]) >= 0 and _cross(P[i1], P[i2], P[j]) >= 0 and _cross(P[i2], P[i0], P[j]) >= 0 for j in V if j not in (i0, i1, i2)): continue tris.append((i0, i1, i2)) V.pop(k) break else: break if len(V) == 3: tris.append(tuple(V)) used = [False] * len(tris) faces = [] for a in range(len(tris)): if used[a]: continue best = None for b in range(a + 1, len(tris)): if used[b]: continue sh = set(tris[a]) & set(tris[b]) if len(sh) != 2: continue oa = [x for x in tris[a] if x not in sh][0] ob = [x for x in tris[b] if x not in sh][0] ta = list(tris[a]) k = ta.index(oa) q = [ta[k], ta[(k + 1) % 3], ob, ta[(k + 2) % 3]] if _convex_ccw([P[i] for i in q]): best = (b, q) break if best: used[a] = used[best[0]] = True faces.append(best[1]) else: used[a] = True faces.append(list(tris[a])) return faces def cap_faces(P): q = ladder(P) if len(P) % 2 == 0 else None return q if q is not None else earclip_quads(P) # ------------------------------------------------------------------ mesh builder class MB: def __init__(s): s.co, s.faces, s.mats, s.uvs = [], [], [], [] # uvs: per face, a list of special UVs or None def v(s, co): s.co.append(Vector(co)) return len(s.co) - 1 def f(s, idx, mat, uv=None): uq = [] for i in idx: if i not in uq: uq.append(i) if len(uq) >= 3: s.faces.append(uq) s.mats.append(mat) s.uvs.append(uv if uv is None or len(uv) == len(uq) else None) def mark(s): return (len(s.co), len(s.faces)) def mirror_from(s, mk): """copy everything made since the mark onto the machine's far side (y -> -y), faces wound back""" v0, f0 = mk nv = len(s.co) off = nv - v0 for i in range(v0, nv): c = s.co[i] s.co.append(Vector((c.x, -c.y, c.z))) for k in range(f0, len(s.faces)): s.faces.append([i + off for i in s.faces[k][::-1]]) s.mats.append(s.mats[k]) s.uvs.append(None) def bridge(s, la, lb, mat, closed=True, uvf=None): n = len(la) assert n == len(lb), (n, len(lb)) for i in range(n if closed else n - 1): j = (i + 1) % n s.f([la[i], la[j], lb[j], lb[i]], mat(i) if callable(mat) else mat, uvf(i) if uvf else None) def cap(s, loop, P2, mat, flip=False): """fill a planar loop whose 2D outline is P2 (same order); faces wound like P2 (CCW) or flipped""" for q in cap_faces(P2): ids = [loop[i] for i in q] s.f(ids[::-1] if flip else ids, mat) def coons(s, loop, corners, mat): n = len(loop) c = list(corners) side = lambda a, b: [loop[(a + k) % n] for k in range((b - a) % n + 1)] top, right = side(c[0], c[1]), side(c[1], c[2]) bot, left = side(c[2], c[3])[::-1], side(c[3], c[0])[::-1] nu, nv = len(top) - 1, len(right) - 1 assert len(bot) - 1 == nu and len(left) - 1 == nv P = lambda i: s.co[i] def chord(pts): d = [0.0] for a, b in zip(pts, pts[1:]): d.append(d[-1] + (P(b) - P(a)).length) return [x / (d[-1] or 1) for x in d] su = [(a + b) / 2 for a, b in zip(chord(top), chord(bot))] tv = [(a + b) / 2 for a, b in zip(chord(left), chord(right))] grid = [[None] * (nv + 1) for _ in range(nu + 1)] for i in range(nu + 1): grid[i][0], grid[i][nv] = top[i], bot[i] for j in range(nv + 1): grid[0][j], grid[nu][j] = left[j], right[j] T00, T10, T01, T11 = P(top[0]), P(top[nu]), P(bot[0]), P(bot[nu]) for i in range(1, nu): for j in range(1, nv): a, b = su[i], tv[j] p = ((1 - b) * P(top[i]) + b * P(bot[i]) + (1 - a) * P(left[j]) + a * P(right[j]) - ((1 - a) * (1 - b) * T00 + a * (1 - b) * T10 + (1 - a) * b * T01 + a * b * T11)) grid[i][j] = s.v(p) for i in range(nu): for j in range(nv): s.f([grid[i][j], grid[i + 1][j], grid[i + 1][j + 1], grid[i][j + 1]], mat) def to_mesh(s, name, mat_names, uv_special=True): """the mesh; winding made consistent per connected part and turned outward (by the part's volume sign)""" me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name) bm = bmesh.new() bv = [bm.verts.new(c) for c in s.co] bm.verts.ensure_lookup_table() idx = {m: i for i, m in enumerate(mat_names)} uvl = bm.loops.layers.uv.new("UV_Special") if uv_special else None bad = 0 for f, m, uv in zip(s.faces, s.mats, s.uvs): try: face = bm.faces.new([bv[i] for i in f]) face.material_index = idx[m] if uv is not None and uvl is not None: for lp, (a, b) in zip(face.loops, uv): lp[uvl].uv = (a, b) except ValueError: bad += 1 bmesh.ops.recalc_face_normals(bm, faces=bm.faces) # every part is a closed shell: outward is defined bm.normal_update() me.clear_geometry() if len(me.vertices) else None me.materials.clear() # before the faces: clearing slots also zeroes the faces' indices for m in mat_names: me.materials.append(bpy.data.materials.get(m) or bpy.data.materials.new(m)) bm.to_mesh(me) bm.free() return me, bad # ------------------------------------------------------------------ solids def prism(mb, P, y0, y1, mat, ch=0.006, back=True, front_mat=None, side_mat=None, back_ch=None): """a chamfered prism: outline P [(x, z)] in metres, front face at y0 (toward the viewer), back at y1 > y0""" P = even(ccw(P)) bc = ch if back_ch is None else back_ch I, ch = safe_inset(P, ch) if ch > 0 else (P, 0.0) Ib, bc = safe_inset(P, bc) if bc > 0 else (P, 0.0) V = lambda p, y: mb.v((p[0], y, p[1])) r0 = [V(p, y0) for p in I] r1 = [V(p, y0 + ch) for p in P] if ch > 0 else r0 r2 = [V(p, y1 - (bc if back else 0.0)) for p in P] if ch > 0: mb.bridge(r1, r0, side_mat or mat) # wound outward (back to front) mb.bridge(r2, r1, side_mat or mat) mb.cap(r0, I, front_mat or mat) if back: r3 = [V(p, y1) for p in Ib] if bc > 0 else r2 if bc > 0: mb.bridge(r3, r2, side_mat or mat) mb.cap(r3, Ib, side_mat or mat, flip=True) return r0 def prism_px(mb, pts_px, y0, y1, mat, **kw): """a prism traced in picture pixels on its front plane y0""" return prism(mb, [XZ(u, v, y0) for u, v in pts_px], y0, y1, mat, **kw) def frame_prism(mb, outer, inner, y0, y1, mat, ch=0.004, hole_mat=None, depth_hole=None): """a ring-shaped prism (a handle's frame): outer and inner outlines (x, z), the same vertex count, both CCW. The hole is closed at depth_hole (a dark floor) or open through to the back.""" O, I = ccw(outer), ccw(inner) assert len(O) == len(I) Oi, Io = inset(O, ch), inset(I, -ch) V = lambda p, y: mb.v((p[0], y, p[1])) o0, i0 = [V(p, y0) for p in Oi], [V(p, y0) for p in Io] o1, i1 = [V(p, y0 + ch) for p in O], [V(p, y0 + ch) for p in I] o2 = [V(p, y1) for p in O] mb.bridge(o0, i0, mat) mb.bridge(o1, o0, mat); mb.bridge(o2, o1, mat) mb.bridge(i0, i1, mat) yb = depth_hole if depth_hole is not None else y1 i2 = [V(p, yb) for p in I] mb.bridge(i1, i2, hole_mat or mat) if depth_hole is not None: mb.cap(i2, I, hole_mat or mat) mb.cap(o2, O, mat, flip=True) else: mb.bridge(i2, o2, mat) return o0 def frame_axes(A): A = Vector(A).normalized() ref = Vector((0, 1, 0)) if abs(A.y) < 0.9 else Vector((1, 0, 0)) e1 = (ref - A * ref.dot(A)).normalized() e2 = A.cross(e1).normalized() return A, e1, e2 def lathe(mb, prof, O, A, nseg, mats, cap0=False, cap1=False, e1=None, phase=0.0, uvf=None): """prof [(r, t)] along axis A from O; mats one per segment or a name. r 0 gives a single tip vertex. e1 sets angle 0 (default: toward +y projected). Caps close an end ring with a Coons patch.""" A, d1, d2 = frame_axes(A) if e1 is not None: d1 = (Vector(e1) - A * Vector(e1).dot(A)).normalized() d2 = A.cross(d1).normalized() O = Vector(O) rings = [] for (r, t) in prof: if r <= 1e-9: rings.append([mb.v(O + A * t)]) else: rings.append([mb.v(O + A * t + r * (math.cos(phase + 2 * math.pi * k / nseg) * d1 + math.sin(phase + 2 * math.pi * k / nseg) * d2)) for k in range(nseg)]) for k in range(len(rings) - 1): m = mats if isinstance(mats, str) else mats[k] a, b = rings[k], rings[k + 1] if len(a) == 1 and len(b) == 1: continue if len(a) == 1 or len(b) == 1: ring, tip = (b, a[0]) if len(a) == 1 else (a, b[0]) for i in range(nseg): mb.f([ring[i], ring[(i + 1) % nseg], tip], m) else: mb.bridge(a, b, m, uvf=(lambda i, k=k: uvf(k, i)) if uvf else None) q = nseg // 4 if cap0 and len(rings[0]) > 1: mb.coons(rings[0], [0, q, 2 * q, 3 * q], mats if isinstance(mats, str) else mats[0]) if cap1 and len(rings[-1]) > 1: mb.coons(rings[-1], [0, q, 2 * q, 3 * q], mats if isinstance(mats, str) else mats[-1]) return rings, (A, d1, d2) def curved_box(mb, O, A, d1, d2, t0, t1, th0, th1, r0, r1, nseg, mat, end_mat=None, cap0=True, cap1=True): """an annular sector (angles th0..th1 radians from d1 toward d2, radii r0..r1) swept along A from t0 to t1""" O = Vector(O) ths = [th0 + (th1 - th0) * k / nseg for k in range(nseg + 1)] pt = lambda r, th, t: O + A * t + r * (math.cos(th) * d1 + math.sin(th) * d2) loops = [] for t in (t0, t1): outer = [mb.v(pt(r1, th, t)) for th in ths] inner = [mb.v(pt(r0, th, t)) for th in ths[::-1]] loops.append(outer + inner) mb.bridge(loops[0], loops[1], mat) n = nseg + 1 for k, (lp, on) in enumerate(((loops[0], cap0), (loops[1], cap1))): if not on: continue for i in range(nseg): q = [lp[i], lp[i + 1], lp[2 * n - 2 - i], lp[2 * n - 1 - i]] mb.f(q if k else q[::-1], end_mat or mat) def box_frame(mb, O, ex, ey, ez, P2, z0, z1, mat, ch=0.0, back=True, front_mat=None): """a chamfered prism in a local frame: outline P2 [(a, b)] in (ex, ey), extruded along ez from z0 to z1""" P = even(ccw(P2)) I, ch = safe_inset(P, ch) if ch > 0 else (P, 0.0) O = Vector(O) V = lambda p, z: mb.v(O + ex * p[0] + ey * p[1] + ez * z) r0 = [V(p, z0) for p in I] r1 = [V(p, z0 + ch) for p in P] if ch > 0 else r0 r2 = [V(p, z1 - (ch if back else 0)) for p in P] if ch > 0: mb.bridge(r1, r0, mat) mb.bridge(r2, r1, mat) mb.cap(r0, I, front_mat or mat) if back: r3 = [V(p, z1) for p in I] if ch > 0 else r2 if ch > 0: mb.bridge(r3, r2, mat) mb.cap(r3, I, mat, flip=True) def catmull(pts, n_per=8): P = [Vector(p) for p in pts] P = [P[0] + (P[0] - P[1])] + P + [P[-1] + (P[-1] - 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(n_per): t = k / n_per out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t)) out.append(P[-2]) return out def tube(mb, pts, radius, nseg, mat, n_per=8, caps=True): """a cable through 3D points (Catmull-Rom), parallel-transported rings""" C = catmull(pts, n_per) T = [(C[min(i + 1, len(C) - 1)] - C[max(i - 1, 0)]).normalized() for i in range(len(C))] ref = Vector((0, 1, 0)) if abs(T[0].y) < 0.9 else Vector((1, 0, 0)) n1 = (ref - T[0] * ref.dot(T[0])).normalized() rings = [] for i, c in enumerate(C): if i: n1 = (n1 - T[i] * n1.dot(T[i])).normalized() n2 = T[i].cross(n1) rings.append([mb.v(c + radius * (math.cos(2 * math.pi * k / nseg) * n1 + math.sin(2 * math.pi * k / nseg) * n2)) for k in range(nseg)]) for a, b in zip(rings, rings[1:]): mb.bridge(a, b, mat) if caps: q = nseg // 4 mb.coons(rings[0], [0, q, 2 * q, 3 * q], mat) mb.coons(rings[-1], [0, q, 2 * q, 3 * q], mat) return C def bolt(mb, centre, normal, r, h, mat, nseg=8): """a domed bolt head on a face: centre on the face, normal out of it""" prof = [(r, -0.002), (r, h * 0.45), (r * 0.78, h * 0.85), (r * 0.42, h)] lathe(mb, prof, centre, normal, nseg, mat, cap1=True) # ------------------------------------------------------------------ audit def audit(ob): me = ob.data bm = bmesh.new() bm.from_mesh(me) q = sum(1 for f in bm.faces if len(f.verts) == 4) t = sum(1 for f in bm.faces if len(f.verts) == 3) ng = sum(1 for f in bm.faces if len(f.verts) > 4) open_e = sum(1 for e in bm.edges if len(e.link_faces) == 1) nonman = sum(1 for e in bm.edges if len(e.link_faces) > 2) tiny = sum(1 for f in bm.faces if f.calc_area() < 1e-9) tris = sum(len(f.verts) - 2 for f in bm.faces) bm.free() return dict(quads=q, tris=t, ngons=ng, open_edges=open_e, nonmanifold_edges=nonman, zero_area=tiny, triangles=tris, verts=len(me.vertices))