"""Build the nutrition vat after the user's picture (reference/ref_user.jpg, one pod from three sides), headless: blender.exe -b --factory-startup --python nv_build.py -> ../NutritionVat.blend (rebuilt from scratch) Numbers are pixels of the picture's middle view (the pod's right side, square on): u to the rear, v down, x across the pod (+x is the side the picture shows); 1 px = 4.62 mm (see nv_lib.py). Measured from the ruler crops reference/z_*.png: head u 408..563, v 70..111; a groove at v 111..112.5 over the lip lip the top bezel over the tank, u 408..493, v 112.5..148, its rear corner curving down (r 22) into the column column the C-frame beside the tank: u 470..493 down to v 274, then forward to u 448 and round (r 45) at the bottom; its inner side wraps the glass. The C-groove behind it (u 493..498) holds a vertical rod with pins at v 164 and 336 middle a raised panel u 498..535, its top along the diagonal (490,111)-(547,150), down to v 355 rear a plate u 539..563 with two pills (542..560 x 154..201, 207..300) tank axis u 471, two glass chambers r 44 (front at u 427) with rounded ends, v 148.6..220.4 and 232.4..379.4; mid band r 52 at v 219..233; bottom bezel r 52 at v 372..393; a glowing disc r 26 under the top lower the lower housing, front u 410, top v 393, its rear along the arm's inner edge down to the plinth; hex knobs (539,372) and (522,392) in round bezels; a slot (483..508, 408..418); a keypad on the front plinth u 400..637, v 470..503 (a dark toe under it); a rear block under the arm arm a C-shaped arm behind the pod: its centreline an arc about (480,330), r 122, from -62 to 59 degrees, then straight down and forward into the plinth; band 54 px, 60 px thick across; five windows canisters one at each rear corner, axis (x +-47, u 580), r 15, v 188..299, a valve under each, clamp rings ports six ports on the head's top round (x 0, u 495), r 45, tilted out 22 degrees handles a U handle out of the head's front left corner, a rail round the lower housing's front left corner pipes (2026-10-22, the user's change) three of the ports carry the docking arm's clear ore duct, as wide as the port, bent to vertical and run up to a ceiling coupling at 3.46 m; the other three ports stay open The pod's width (110 px) is from the left view's head (93 px front face beside 83 px side face, about 32 degrees). The left view shows no arm, hoses or second canister and a shorter base; the model follows the middle and right views there (where the views disagree the middle, side view rules). The middle view's hoses were replaced by the pipes. """ import bpy, bmesh, math, os, sys, json from mathutils import Vector, Matrix HERE = os.path.dirname(os.path.abspath(__file__)) FOLDER = os.path.dirname(HERE) exec(open(os.path.join(HERE, "nv_lib.py")).read()) HW = 55.0 # half width of the shell CW = 48.0 # half width of the core TANK_U, TANK_R = 471.0, 44.0 ARM_C, ARM_RC, ARM_HW = (480.0, 330.0), 122.0, 27.0 # the arm: centre and radius of its centreline, half its band ARM_T = 30.0 # half the arm's thickness across the pod ARM_WINS, ARM_WH = (-47.0, -22.0, 3.0, 28.0, 53.0), 6.5 # windows: middles and half lengths (degrees) CAN_X, CAN_U = 47.0, 580.0 # the canister's axis MATS = ["M_NV_Shell", "M_NV_ShellDark", "M_NV_Gap", "M_NV_Glass", "M_NV_Glow", "M_NV_Rubber", "M_NV_Chrome", "M_NV_Brass", "M_NV_Keypad", "M_NV_Grille", "M_NV_Decal", "M_NV_CanGlass"] ALL_MATS = MATS + ["M_NV_Fluid", "M_NV_PipeFilm", "M_NV_PipeBand"] def prof_box(x_out, x_in, b, nb=3): """a closed slab: a cap at x_out with a bevel b, straight back to a sharp cap at x_in""" pr = prof_plate(x_out, x_in, b, nb) return pr def arm_inner(th): """a point of the arm's inner edge at th degrees (side px)""" t = math.radians(th) r = ARM_RC - ARM_HW return (ARM_C[0] + r * math.cos(t), ARM_C[1] + r * math.sin(t)) def arm_inner_v(v): """the arm's inner edge where it crosses height v (its rear half)""" r = ARM_RC - ARM_HW return (ARM_C[0] + math.sqrt(r * r - (v - ARM_C[1]) ** 2), v) def arm_inner_u(u): """height of the arm's inner edge at u (its lower half)""" r = ARM_RC - ARM_HW return ARM_C[1] + math.sqrt(r * r - (u - ARM_C[0]) ** 2) def arm_outer(th): t = math.radians(th) r = ARM_RC + ARM_HW return (ARM_C[0] + r * math.cos(t), ARM_C[1] + r * math.sin(t)) def capsule(v0, v1, r, r_end, wall): """a glass chamber's wall as a closed lathe profile: the outer surface from v0 down to v1 with rounded ends, then the inner surface back up (wall thick)""" def side(rr, a, b, rc): pts = [] for k in range(5): # top fillet t = math.radians(90 * k / 4) pts.append((r_end + (rr - r_end) * math.sin(t), a + rc * (1 - math.cos(t)))) for k in range(5): # bottom fillet t = math.radians(90 * (1 - k / 4)) pts.append((r_end + (rr - r_end) * math.sin(t), b - rc * (1 - math.cos(t)))) return pts rc = 8.0 outer = side(r, v0, v1, rc) inner = side(r - wall, v0 + wall, v1 - wall, rc - wall) return outer + inner[::-1] def hexnut(mb, o, R, h): """a hexagonal nut on the +x side at o: 12 points round (corners and edge middles), a chamfered top""" mb.begin(hint=(1, 0, 0)) def ring(x, rs): pts = [] for k in range(12): a = math.radians(30 * k) r = R * rs * (1.0 if k % 2 == 0 else math.cos(math.radians(30))) pts.append(mb.v(o + Vector((x * S, r * S * math.cos(a), r * S * math.sin(a))))) return pts r0, r1, r2 = ring(-0.5, 1.0), ring(h - 0.5, 1.0), ring(h, 0.88) mb.bridge(r0, r1, "M_NV_Shell") mb.bridge(r1, r2, "M_NV_Shell") mb.coons(r2, [0, 3, 6, 9], "M_NV_Shell") def sides(fn): """build a side part on +x and on -x""" fn(Frame(n=(1, 0, 0)), 1.0) fn(Frame(n=(-1, 0, 0)), -1.0) # ------------------------------------------------------------------ the shell def build_shell(mb): sh = "M_NV_Shell" # head: full width, the bottom left sharp (the lip groove under it, the wedge plates flush at the rear) head = rpoly_px([(408, 70), (563, 70), (563, 111), (408, 111)], [14, 12, 0, 0], [4, 4, 0, 0], [16, 4, 20, 4], [1, 1, 0, 1]) slab(mb, head, SIDE, prof_full(HW, 6, 3), sh) # lip: the top bezel over the tank, full width; its bottom's rear part meets the columns (jog at u 470) lip = rpoly_px([(408, 112.5), (493, 112.5), (493, 148), (470, 148), (408, 148)], [1.5, 22, 0, 0, 3], [2, 6, 0, 1, 2], [10, 2, 3, 9, 3], [1, 1, 0, 1, 1]) slab(mb, lip, SIDE, prof_full(HW + 0.5, 6, 3), sh, corners=[lip.mid(0), lip.mid(1), lip.mid(2), lip.mid(4)]) def column(fr, sg): up = rpoly_px([(470, 148), (493, 148), (493, 274), (470, 274)], [0, 0, 0, 0], [0, 0, 0, 0], [3, 14, 3, 14], [0, 1, 0, 1]) slab(mb, up, fr, prof_box(HW + 0.5, 46, 6), sh, hint=fr.n, skip=(0, 2)) lo = rpoly_px([(470, 274), (493, 274), (493, 386), (445, 386), (448, 296)], [0, 0, 45, 2, 4], [0, 0, 8, 2, 2], [5, 7, 1, 4, 3], [0, 1, 1, 1, 1]) n0 = len(mb.co) slab(mb, lo, fr, prof_box(HW + 0.5, 34, 6), sh, corners=[lo.start[0], lo.start[1], lo.mid(2), lo.start[3]], hint=fr.n) rc = TANK_R + 0.6 # its inner side wraps the glass, 0.6 px (2.8 mm) off it for i in range(n0, len(mb.co)): c = mb.co[i] if abs(abs(c.x) - 34 * S) < 1e-7: du = c.y / S + U0 - TANK_U if abs(du) < rc: c.x = math.copysign(max(34.0, math.sqrt(rc * rc - du * du)) * S, c.x) # side plates on the core wedge = rpoly_px([(490, 111), (563, 111), (563, 150), (547, 150)], [0, 0, 2, 0], [0, 0, 2, 0], [5, 8, 4, 9], [0, 1, 1, 0.35]) # a small bevel on the diagonal: the label sits on the flat slab(mb, wedge, fr, prof_plate(HW, CW, 6), sh, caps=(True, False), corners=[wedge.start[0], wedge.start[1], wedge.mid(2), wedge.start[3]], hint=fr.n, skip=(0,)) mid = rpoly_px([(498, 120), (535, 145), (535, 355), (498, 355)], [3, 3, 6, 6], [2, 2, 2, 2], [4, 24, 4, 24]) slab(mb, mid, fr, prof_plate(HW + 1, CW, 3), sh, caps=(True, False), hint=fr.n) rear = rpoly_px([(539, 152), (563, 152), (563, 357), (539, 357)], [3, 0, 0, 3], [2, 0, 0, 2], [3, 32, 3, 30], [0.5, 1, 0.5, 0.5]) slab(mb, rear, fr, prof_plate(HW, CW, 6), sh, caps=(True, False), corners=[rear.mid(0), rear.start[1], rear.start[2], rear.mid(3)], hint=fr.n) for (v0, v1, nv) in ((154, 201, 6), (207, 300, 12)): pill = rpoly_px([(542, v0), (560, v0), (560, v1), (542, v1)], [8.5] * 4, [4] * 4, [1, nv, 1, nv]) slab(mb, pill, fr, prof_plate(HW + 1.5, HW - 0.5, 1.2), sh, caps=(True, False), hint=fr.n) a0, a1 = (563.0, arm_inner_u(563.0)), arm_inner_v(393.0) low = rpoly_px([(480, 360), (563, 360), a0, arm_inner(35.3), a1, (446, 393)], [0, 3, 0, 0, 0, 0], [0, 2, 0, 0, 0, 0], [11, 2, 1, 1, 12, 5], [1, 1, 1, 1, 0, 0]) slab(mb, low, fr, prof_plate(HW, CW, 6), sh, caps=(True, False), corners=[low.start[0], low.mid(1), low.start[4], low.start[5]], hint=fr.n, skip=(4,)) sides(column) # core: the spine behind and round the tank, a U in plan extruded from v 111 to 393 (no caps: closed by the head, # the lower housing, the plates); its rear face 2 px inside the plates' yr, yf = Y(561), Y(TANK_U) cr = (TANK_R + 2) * S pts = [Vector((CW * S, yf)), Vector((CW * S, yr)), Vector((-CW * S, yr)), Vector((-CW * S, yf))] narc = 24 for j in range(narc + 1): # the cradle, from x -46 round the back to x +46 a = math.pi + math.pi * j / narc pts.append(Vector((math.cos(a) * cr, yf - math.sin(a) * cr))) mb.begin() rb = [mb.v((p.x, p.y, Z(376))) for p in pts] rt = [mb.v((p.x, p.y, Z(111))) for p in pts] mb.bridge(rb, rt, "M_NV_Shell") # below v 376 the arm passes behind the lower plates: the core's last part stops at u 549, a lid closes the step pts2 = [Vector((p.x, Y(549))) if abs(p.y - yr) < 1e-9 else p for p in pts] mb.begin() rb = [mb.v((p.x, p.y, Z(393))) for p in pts2] rt = [mb.v((p.x, p.y, Z(376))) for p in pts2] mb.bridge(rb, rt, "M_NV_Shell") mb.begin(hint=(0, 0, -1)) lid = [mb.v((x * S, Y(u), Z(376))) for (x, u) in ((CW, 549), (CW, 561), (-CW, 561), (-CW, 549))] mb.f(lid, "M_NV_Shell") # lower housing: full width, its top's rear part under the core and the lower plates (jog at u 446) e59 = arm_inner(59.0) foot = (e59[0] - (470 - e59[1]) / math.tan(math.radians(31.0)), 470) # the straight inner edge meets the plinth lh = rpoly_px([(410, 393), (446, 393), arm_inner_v(393.0), arm_inner(47.3), arm_inner(53.1), e59, foot, (410, 470)], [4, 0, 0, 0, 0, 0, 2, 5], [2, 1, 0, 0, 0, 0, 2, 2], [4, 13, 3, 3, 3, 13, 3, 7], [1, 0, 1, 1, 1, 1, 1, 1]) slab(mb, lh, SIDE, prof_full(HW, 6, 3), sh, corners=[lh.mid(0), lh.start[2], lh.start[5], lh.mid(7)], skip=(6,)) # plinth and its toe pl = rpoly_px([(400, 470), (637, 470), (637, 496), (400, 496)], [6, 8, 0, 0], [3, 3, 0, 0], [30, 2, 33, 3], [1, 1, 0, 1]) slab(mb, pl, SIDE, prof_full(62, 5, 3), sh, corners=[pl.mid(0), pl.mid(1), pl.start[2], pl.start[3]]) toe = rpoly_px([(404, 496), (633, 496), (633, 503), (404, 503)], [0, 0, 0, 0], [0, 0, 0, 0], [20, 1, 20, 1], [0, 1, 0, 1]) slab(mb, toe, SIDE, prof_full(58, 0.01, 1), "M_NV_ShellDark", skip=(0,)) # rear block under the ring o59 = arm_outer(59.0) foot_o = (o59[0] - (470 - o59[1]) / math.tan(math.radians(31.0)), 470) rbk = rpoly_px([foot_o, o59, arm_outer(50.0), arm_outer(41.0), arm_outer(33.0), (630, 424), (634, 470)], [0, 0, 0, 0, 0, 6, 0], [0, 0, 0, 0, 0, 3, 0], [3, 3, 3, 3, 4, 4, 11], [1, 1, 1, 1, 1, 1, 0]) slab(mb, rbk, SIDE, prof_full(ARM_T + 2, 3, 3), sh, corners=[rbk.start[0], rbk.start[2], rbk.mid(5), rbk.start[6]], skip=(6,)) # ------------------------------------------------------------------ the tank (static parts) def build_tank(mb): o = P(0, TANK_U, 0) ax = (0, 0, -1) # glass: a thin wall, closed for (v0, v1) in ((148.6, 220.4), (232.4, 379.4)): lathe(mb, capsule(v0, v1, 44.0, 34.0, 1.5), o, ax, 64, "M_NV_Glass", closed=True) # top collar with the glowing disc under it lathe(mb, [(47.5, 144), (47.5, 147.5), (46.5, 148.5), (36.5, 148.5), (27, 148.6), (26, 149.2)], o, ax, 64, ["M_NV_Shell"] * 3 + ["M_NV_Gap"] * 2, cap_end=True, cap_mat="M_NV_Glow") # mid band and a fine light under it lathe(mb, [(43.8, 219), (49, 219), (51, 220.4), (52, 223), (52, 229), (51, 231.6), (49, 233), (43.8, 233)], o, ax, 64, "M_NV_Shell", closed=True) lathe(mb, [(44.1, 233), (47.5, 233), (47.5, 234.6), (44.1, 234.6)], o, ax, 64, "M_NV_Glow", closed=True) # bottom bezel and its light at the glass lathe(mb, [(43.8, 393), (43.8, 376), (45, 373.6), (48, 372.4), (50.5, 373.4), (52, 376), (52, 393)], o, ax, 64, "M_NV_Shell") lathe(mb, [(44.1, 373.2), (46.2, 372.6), (46.2, 375.2), (44.1, 375.8)], o, ax, 64, "M_NV_Glow", closed=True) def build_fluid(mb): o = P(0, TANK_U, 0) lo, up = capsule(232.4, 379.4, 44.0, 34.0, 1.9), capsule(148.6, 220.4, 44.0, 34.0, 1.9) n = len(lo) // 2 # the inner halves of the chambers, bottom to top prof = lo[n:] + [(33.4, 233.0), (33.4, 220.0)] + up[n:] prof = [(r - 0.4, v) for (r, v) in prof] lathe(mb, prof, o, (0, 0, -1), 64, "M_NV_Fluid", cap_start=True, cap_end=True) # ------------------------------------------------------------------ the arm behind the pod def arm_samples(): """centreline samples (u, v), outward normal, angle: an arc about ARM_C from -62 to 59 degrees, then straight down and forward into the plinth""" angs = set() a = -62.0 while a < 59.0: angs.add(round(a, 3)) a += 3.0 angs.add(59.0) for w in ARM_WINS: for d in (-ARM_WH - 1.6, -ARM_WH, ARM_WH, ARM_WH + 1.6): angs.add(w + d) angs = sorted(angs) clean = [angs[0]] for x in angs[1:]: if x - clean[-1] > 0.5: clean.append(x) cu, cv = ARM_C out = [] for th in clean: t = math.radians(th) out.append(((cu + ARM_RC * math.cos(t), cv + ARM_RC * math.sin(t)), (math.cos(t), math.sin(t)), th)) (u, v), nrm, _ = out[-1] d = (-nrm[1], nrm[0]) for L in (14, 28, 42, 56, 70, 84): out.append(((u + d[0] * L, v + d[1] * L), nrm, None)) return out def build_arm(mb): sm = arm_samples() ts = [-ARM_HW, -ARM_HW + 2, -11.0, -9.0, 16.0, 18.0, ARM_HW - 2, ARM_HW] win = set() for i in range(len(sm) - 1): a, b = sm[i][2], sm[i + 1][2] if a is None or b is None: continue m = 0.5 * (a + b) if any(abs(m - w) < ARM_WH for w in ARM_WINS): win.add((i, 3)) cells = {(i, j): "M_NV_Shell" for i in range(len(sm) - 1) for j in range(len(ts) - 1) if (i, j) not in win} edge = set() for (i, j) in win: edge |= {(i, j), (i + 1, j), (i, j + 1), (i + 1, j + 1)} ch = 2.5 def F(i, j, sd): (u, v), (nu, nv), _ = sm[i] x = ARM_T if sd else -ARM_T if j in (0, len(ts) - 1) or (i, j) in edge: x = math.copysign(ARM_T - ch, x) return P(x, u + ts[j] * nu, v + ts[j] * nv) cell_solid(mb, len(sm) - 1, len(ts) - 1, cells, F) # ------------------------------------------------------------------ details def build_details(mb): # ports: six on the head's top round (x 0, u 495), r 45, tilted out 22 degrees for base, axis in port_frames(): prof = [(22.5, -6), (22.5, 0.6), (21.4, 2.2), (20.2, 3.4), (20.2, 6), (21.2, 6.6), (21.2, 8.6), (20.2, 9.2), (20.2, 11), (21.2, 11.6), (21.2, 13.6), (20.2, 14.2), (20.2, 18), (21.4, 19), (21.4, 23.2), (20, 24.6), (13, 24.6), (11.2, 23), (11.2, 9)] lathe(mb, prof, base, axis, 32, ["M_NV_Rubber"] * (len(prof) - 2) + ["M_NV_Gap"], cap_end=True) # knobs on the +x side for (u, v) in ((539, 372), (522, 392)): lathe(mb, [(12.5, -1), (12.5, 0.8), (11.8, 1.8), (10.6, 2.2), (9.4, 1.8), (8.8, 0.9), (8.6, 0.3)], P(HW, u, v), (1, 0, 0), 32, "M_NV_Shell") hexnut(mb, P(HW, u, v), 7.0, 2.4) # a rounded slot on the lower housing's +x side sl = rpoly_px([(483, 408), (508, 408), (508, 418), (483, 418)], [2.5] * 4, [2] * 4, [4, 1, 4, 1]) slab(mb, sl, SIDE, prof_plate(HW + 0.8, HW - 0.5, 0.6, 2), "M_NV_Shell", caps=(True, False), hint=(1, 0, 0)) # grille on the +x side, near the top gr = rpoly_px([(482, 80), (508, 80), (508, 87.5), (482, 87.5)], [3.5] * 4, [4] * 4, [4, 1, 4, 1]) slab(mb, gr, SIDE, prof_plate(HW + 0.5, HW - 0.5, 0.4, 2), "M_NV_Grille", caps=(True, False), hint=(1, 0, 0)) # the rod in each C-groove, with pins across the groove for sg in (1, -1): rod = [P(sg * 52.6, 495.5, 161), P(sg * 52.6, 495.5, 339)] tube(mb, rod, 2.2 * S, 16, "M_NV_Chrome", cap_start=True, cap_end=True) for v in (164, 336): tube(mb, [P(sg * 50.5, 492.0, v), P(sg * 50.5, 499.5, v)], 1.5 * S, 12, "M_NV_Chrome", cap_start=True, cap_end=True) # the canister's clamp rings, touching the rear face; 0.2 px (1 mm) round the canister's glass for v in (211.0, 253.0): lathe(mb, [(14.8, v), (17.6, v), (17.6, v + 6), (14.8, v + 6)], P(sg * CAN_X, CAN_U, 0), (0, 0, -1), 32, "M_NV_Chrome", closed=True) # keypad on the lower housing's front (x -38..-12, v 420..447) kp = rpoly_px([(-38, 503 - 447), (-12, 503 - 447), (-12, 503 - 420), (-38, 503 - 420)], [2] * 4, [2] * 4, [4, 4, 4, 4], frame='front') fr = Frame(o=(0, Y(410), 0), a=(1, 0, 0), b=(0, 0, 1), n=(0, -1, 0)) slab(mb, kp, fr, prof_plate(1.0, -0.5, 0.5, 2), "M_NV_Keypad", caps=(True, False), hint=(0, -1, 0)) for i in range(3): for j in range(3): x0, z0 = -34 + i * 7.0, 503 - 443 + j * 7.0 bt = rpoly_px([(x0, z0), (x0 + 5, z0), (x0 + 5, z0 + 5), (x0, z0 + 5)], [1] * 4, [2] * 4, [1, 1, 1, 1], frame='front') slab(mb, bt, fr, prof_plate(1.9, 0.6, 0.4, 2), "M_NV_Shell", caps=(True, False), hint=(0, -1, 0)) # a light strip along the lower housing's front, low (the picture's cyan line under the rail) ls = rpoly_px([(-48, 503 - 468.6), (48, 503 - 468.6), (48, 503 - 465.4), (-48, 503 - 465.4)], [1.4] * 4, [2] * 4, [16, 1, 16, 1], frame='front') slab(mb, ls, Frame(o=(0, Y(410), 0), a=(1, 0, 0), b=(0, 0, 1), n=(0, -1, 0)), prof_plate(0.6, -1.0, 0.3, 2), "M_NV_Glow", caps=(True, False), hint=(0, -1, 0)) # handles: a U out of the head's front left corner, a rail round the lower housing's front left corner d = Vector((-0.9, -1, 1.1)).normalized() A, B = P(-30, 408.5, 79), P(-55.5, 432, 79) L = 44 * S top = fillet_path([A - d * 0.01, A + d * L, B + d * L, B - d * 0.01], 9 * S, 6) tube(mb, top, 4 * S, 16, "M_NV_Chrome", cap_start=True, cap_end=True) rail = [P(34, 409.5, 452), P(34, 384, 462), P(-74, 384, 468), P(-74, 446, 462), P(-55.5, 446, 452)] tube(mb, fillet_path(rail, 12 * S, 6), 4 * S, 16, "M_NV_Chrome", cap_start=True, cap_end=True) def port_frames(): """the six ports on the head's top: (base point, axis), round (x 0, u 495), r 45, tilted out 22 degrees""" out = [] for k in range(6): ph = math.radians(30 + 60 * k) cx, cu = 45 * math.cos(ph), 495 + 45 * math.sin(ph) o = Vector((math.cos(ph), math.sin(ph), 0)) axis = (Vector((0, 0, 1)) * math.cos(math.radians(22)) + o * math.sin(math.radians(22))).normalized() out.append((P(cx * 0.92, cu, 70), axis)) return out # ------------------------------------------------------------------ pipes to the ceiling (the user, 2026-10-22) # Three of the six ports carry the docking arm's clear ore duct (spiral rib, steel clamp bands), as wide as the port (the user's # red drawing), bent to vertical and run straight up to a coupling in the ceiling: the vat's connection to the ship's main line # that brings biomass from the Tenders' cargo, as ore goes. The other three ports stay open. PIPE_PORTS = (0, 2, 4) # port k sits at 30 + 60k degrees: right rear, left rear, front (120 degrees apart) PIPE_NAMES = ("A", "B", "C") PIPE_R = 22.4 # px: the duct slides over the port's rim (r 21.4), so it is the port's width PIPE_SIDES = 16 BAND_DR, BAND_W = 3.0, 7.8 # px: a clamp band stands 1.4 cm proud and is 3.6 cm wide (the docking arm's bands) DUCT_REPEAT = 0.69 # m along the duct per texture repeat (the docking arm's ore hose: one rib pitch 4.9 cm) CEIL_Z = 3.46 # m: the ship's corridor ceiling (vessel interior), the default the pipes run up to FIT_Z = 72.0 # px over the head's top: where the fitting ends and the straight run begins TILT = math.radians(22.0) # the ports lean out this much BEND_R = 60.0 # px: the bend from the port's axis to vertical (2.8 x the duct's radius) SPIGOT = (21.4, 16.0, 27.0) # px: the ceiling coupling's spigot (radius, top and bottom under the ceiling) RUN_TOP = 18.0 # px under the ceiling: the run's film ends over the spigot def fitting_path(): """the duct's centre line in the fitting's frame (px; x out from the vat's middle, z up from the head's top): along the port's axis, a bend of 22 degrees to vertical, then up to FIT_Z""" ax = Vector((math.sin(TILT), 0, math.cos(TILT))) pts = [ax * h for h in (15.0, 21.0, 27.0, 30.0)] n = Vector((-math.cos(TILT), 0, math.sin(TILT))) # toward the bend's centre C = ax * 30.0 + n * BEND_R for k in range(1, 9): ang = TILT * (1 - k / 8) # the duct's lean from vertical, 22 down to 0 degrees pts.append(C - Vector((-math.cos(ang), 0, math.sin(ang))) * BEND_R) top = pts[-1] z = top.z while z + 9.0 < FIT_Z: z += 8.0 pts.append(Vector((top.x, 0, z))) pts.append(Vector((top.x, 0, FIT_Z))) return pts def pipe_axis_x(): """how far out the vertical part stands from the port's base (px, in the fitting's frame)""" return fitting_path()[-1].x def duct_mesh(name, path_px, bands, film=True): """a clear duct along path_px (px) with clamp bands; bands: [(index into path_px, offset px along it)]. UVMap: the film U round 0..1 and V in texture repeats along it (DUCT_REPEAT m each); the bands in metres. Materials: film, band.""" P_ = [Vector(p) * S for p in path_px] n = len(P_) tang = [(P_[min(i + 1, n - 1)] - P_[max(i - 1, 0)]).normalized() for i in range(n)] ref = Vector((1, 0, 0)) if abs(tang[0].x) < 0.9 else Vector((0, 1, 0)) nrm = (ref - tang[0] * ref.dot(tang[0])).normalized() frames = [] for i in range(n): t = tang[i] if i: nrm = tang[i - 1].rotation_difference(t) @ nrm nrm = (nrm - t * nrm.dot(t)).normalized() frames.append((t, nrm.copy(), t.cross(nrm))) s_acc = [0.0] for i in range(1, n): s_acc.append(s_acc[-1] + (P_[i] - P_[i - 1]).length) me = bpy.data.meshes.new(name) bm = bmesh.new() uvl = bm.loops.layers.uv.new("UVMap") R = PIPE_R * S if film: rings = [] for i in range(n): t, a, b = frames[i] rings.append([bm.verts.new(P_[i] + R * (math.cos(2 * math.pi * k / PIPE_SIDES) * a + math.sin(2 * math.pi * k / PIPE_SIDES) * b)) for k in range(PIPE_SIDES)]) for i in range(n - 1): for k in range(PIPE_SIDES): k2 = (k + 1) % PIPE_SIDES f = bm.faces.new((rings[i][k], rings[i + 1][k], rings[i + 1][k2], rings[i][k2])) f.material_index = 0 v0, v1 = s_acc[i] / DUCT_REPEAT, s_acc[i + 1] / DUCT_REPEAT for l, uv in zip(f.loops, ((k / PIPE_SIDES, v0), (k / PIPE_SIDES, v1), ((k + 1) / PIPE_SIDES, v1), ((k + 1) / PIPE_SIDES, v0))): l[uvl].uv = uv r_in, r_out, w = (PIPE_R - 0.4) * S, (PIPE_R + BAND_DR) * S, BAND_W * S for (i, off) in bands: t, a, b = frames[i] c = P_[i] + t * (off * S) prof = [(-w / 2, r_in), (-w / 2, r_out), (w / 2, r_out), (w / 2, r_in)] cols = [[bm.verts.new(c + t * ax_ + r * (math.cos(2 * math.pi * k / 24) * a + math.sin(2 * math.pi * k / 24) * b)) for k in range(24)] for (ax_, r) in prof] hh = r_out - r_in pv = [0.0, hh, hh + w, 2 * hh + w, 2 * hh + 2 * w] ua = lambda k_: 2 * math.pi * r_out * k_ / 24 for j in range(4): A_, B_ = cols[j], cols[(j + 1) % 4] for k in range(24): k2 = (k + 1) % 24 f = bm.faces.new((A_[k], B_[k], B_[k2], A_[k2])) f.material_index = 1 uvs = {A_[k]: (ua(k), pv[j]), B_[k]: (ua(k), pv[j + 1]), B_[k2]: (ua(k + 1), pv[j + 1]), A_[k2]: (ua(k + 1), pv[j])} for l in f.loops: l[uvl].uv = uvs[l.vert] bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=[f for f in bm.faces if f.material_index == 1]) film_faces = [f for f in bm.faces if f.material_index == 0] for f in film_faces: # the film's normals out, away from the duct's axis c = f.calc_center_median() best = min(range(n), key=lambda i: (P_[i] - c).length) if f.normal.dot(c - P_[best]) < 0: f.normal_flip() bm.to_mesh(me) bm.free() for m in ("M_NV_PipeFilm", "M_NV_PipeBand"): me.materials.append(bpy.data.materials.get(m) or bpy.data.materials.new(m)) for pg in me.polygons: pg.use_smooth = pg.material_index == 0 return me def hexprism(mb, o, axis, R, h, mat): """a hexagonal bolt head on a face at o, standing out along axis (12 points round, a chamfered top)""" mb.begin(hint=axis) n = Vector(axis).normalized() a = Vector((1, 0, 0)) if abs(n.x) < 0.9 else Vector((0, 1, 0)) a = (a - n * a.dot(n)).normalized() b = n.cross(a) def ring(d, rs): out = [] for k in range(12): t = math.radians(30 * k) r = R * rs * (1.0 if k % 2 == 0 else math.cos(math.radians(30))) out.append(mb.v(o + n * (d * S) + (a * math.cos(t) + b * math.sin(t)) * (r * S))) return out r0, r1, r2 = ring(-0.3, 1.0), ring(h - 0.4, 1.0), ring(h, 0.86) mb.bridge(r0, r1, mat) mb.bridge(r1, r2, mat) mb.coons(r2, [0, 3, 6, 9], mat) def build_pipe_ceiling(mb): """the ceiling coupling, in its own frame (px; the ceiling's plane at z 0, the duct's axis at x = y = 0): a flange with six bolts, a collar, the spigot the duct slides over and the clamp band that holds it there""" rs, z0, z1 = SPIGOT lathe(mb, [(16.0, 0.0), (40.0, 0.0), (40.0, -2.4), (39.0, -3.2), (27.0, -3.2), (27.0, -12.5), (24.5, -14.5), (rs, -z0), (rs, -z1), (19.4, -z1 - 1.0), (16.0, -z1 - 1.0)], Vector((0, 0, 0)), (0, 0, 1), 48, ["M_NV_PipeBand"] * 10 + ["M_NV_Gap"], closed=True) for k in range(6): t = math.radians(30 + 60 * k) hexprism(mb, Vector((33.5 * S * math.cos(t), 33.5 * S * math.sin(t), -3.2 * S)), (0, 0, -1), 2.6, 1.8, "M_NV_PipeBand") zc = -(RUN_TOP + z1) / 2 # the clamp band on the duct's end over the spigot (as at the port) lathe(mb, [(PIPE_R - 0.4, zc + BAND_W / 2), (PIPE_R + BAND_DR, zc + BAND_W / 2), (PIPE_R + BAND_DR, zc - BAND_W / 2), (PIPE_R - 0.4, zc - BAND_W / 2)], Vector((0, 0, 0)), (0, 0, 1), 24, "M_NV_PipeBand", closed=True) def build_canister(mb): o = P(CAN_X, CAN_U, 0) ax = (0, 0, -1) lathe(mb, [(10, 188), (13.6, 189.4), (15, 192), (15, 204.5), (16, 205.5), (16, 208), (14.6, 208.6)], o, ax, 32, "M_NV_Rubber", cap_start=True) lathe(mb, [(14.6, 208.6), (14.6, 262.4)], o, ax, 32, "M_NV_CanGlass") lathe(mb, [(14.6, 262.4), (16, 263), (16, 265.5), (15, 266.5), (15, 285), (13, 289), (6, 291.5), (5, 291.5), (5, 299)], o, ax, 32, "M_NV_Rubber", cap_end=True) # valve under it: a block on the axis with a lever pointing out vb = rpoly_px([(CAN_U - 6, 297), (CAN_U + 6, 297), (CAN_U + 6, 309), (CAN_U - 6, 309)], [2] * 4, [2] * 4, [2, 2, 2, 2]) slab(mb, vb, Frame(o=(0, 0, 0)), prof_full(CAN_X + 6, 1.5, 2, h_back=-(CAN_X - 6)), "M_NV_Rubber") lever = [P(CAN_X + 6.5, CAN_U, 303), P(CAN_X + 13, CAN_U, 303), P(CAN_X + 16, CAN_U + 13, 309)] tube(mb, fillet_path(lever, 3 * S, 4), 1.6 * S, 12, "M_NV_Brass", cap_start=True, cap_end=True) def add_decals(mb): """decal grids (nv_lib.DECALS), laid onto the surface under them 0.25 mm proud; their UVs come in nv_surfaces.py""" from mathutils.bvhtree import BVHTree bvh = BVHTree.FromPolygons([tuple(c) for c in mb.co], [list(f) for f in mb.faces]) for d in DECALS: o, a, b, n = decal_frame(d) nu, nv = d["grid"] mb.begin(hint=n) g = [] for j in range(nv + 1): row = [] for i in range(nu + 1): p = decal_point(d, i / nu, j / nv) if d["snap"]: hit = bvh.ray_cast(p + n * (3 * S), -n, 8 * S) if hit[0] is not None: p = hit[0] row.append(mb.v(p + n * 0.00025)) g.append(row) for j in range(nv): for i in range(nu): mb.f([g[j][i], g[j][i + 1], g[j + 1][i + 1], g[j + 1][i]], "M_NV_Decal") def ensure_mats(names): for n in names: if n not in bpy.data.materials: bpy.data.materials.new(n) def coll(name, parent=None): c = bpy.data.collections.get(name) or bpy.data.collections.new(name) p = parent or bpy.context.scene.collection if c.name not in [x.name for x in p.children]: p.children.link(c) return c def obj(name, me, col, origin=None): ob = bpy.data.objects.get(name) if ob is None: ob = bpy.data.objects.new(name, me) col.objects.link(ob) ob.data = me if origin is not None: me.transform(Matrix.Translation(-origin)) ob.location = origin me.shade_smooth() try: me.set_sharp_from_angle(angle=math.radians(38)) except Exception: pass return ob def main(): for o in list(bpy.data.objects): bpy.data.objects.remove(o) for m in list(bpy.data.meshes): bpy.data.meshes.remove(m) for c in list(bpy.data.collections): bpy.data.collections.remove(c) ensure_mats(ALL_MATS) sc = bpy.context.scene sc.unit_settings.system = 'METRIC' top = coll("NutritionVat") c_static, c_mov = coll("NV | static", top), coll("NV | movable", top) coll("NV | collision", top) mb = MB() build_shell(mb) build_tank(mb) build_arm(mb) build_details(mb) add_decals(mb) me, bad = mb.to_mesh("SM_NutritionVat_Body", MATS) body = obj("SM_NutritionVat_Body", me, c_static) fm = MB() build_fluid(fm) me_f, bad_f = fm.to_mesh("SM_NutritionVat_Fluid", ["M_NV_Fluid"]) fluid = obj("SM_NutritionVat_Fluid", me_f, c_mov, origin=P(0, TANK_U, 380)) cm = MB() build_canister(cm) me_c, bad_c = cm.to_mesh("SM_NutritionVat_Canister", MATS) can = obj("SM_NutritionVat_Canister", me_c, c_mov, origin=P(CAN_X, CAN_U, 299)) can.name = "NutritionVat_Canister_R" can_l = bpy.data.objects.new("NutritionVat_Canister_L", me_c) # the same cartridge, turned round, left side c_mov.objects.link(can_l) can_l.location = P(-CAN_X, CAN_U, 299) can_l.rotation_euler = (0, 0, math.pi) # pipes: one fitting, one straight run (a 1 m piece stretched to length) and one ceiling coupling per pipe; the three # pipes share these three meshes c_pipe = coll("NV | pipes", top) path = fitting_path() me_fit = duct_mesh("SM_NutritionVat_PipeFitting", path, [(1, 0.5), (len(path) - 1, 0.0)]) me_run = duct_mesh("SM_NutritionVat_PipeRun", [(0, 0, 0), (0, 0, 1.0 / S)], []) pm = MB() build_pipe_ceiling(pm) me_ceil, bad_p = pm.to_mesh("SM_NutritionVat_PipeCeiling", ["M_NV_PipeBand", "M_NV_Gap"]) pipes = [] ports = port_frames() run_len = CEIL_Z - RUN_TOP * S - (Z(70) + FIT_Z * S) for nm, k in zip(PIPE_NAMES, PIPE_PORTS): base, axis = ports[k] ph = math.radians(30 + 60 * k) out = Vector((math.cos(ph), math.sin(ph), 0)) fit = obj("NutritionVat_PipeFitting_" + nm, me_fit, c_pipe) fit.location, fit.rotation_euler = base, (0, 0, ph) run = obj("NutritionVat_PipeRun_" + nm, me_run, c_pipe) run.location = base + out * (pipe_axis_x() * S) + Vector((0, 0, FIT_Z * S)) run.rotation_euler, run.scale = (0, 0, ph), (1, 1, run_len) cei = obj("NutritionVat_PipeCeiling_" + nm, me_ceil, c_pipe) cei.location = Vector((run.location.x, run.location.y, CEIL_Z)) cei.rotation_euler = (0, 0, ph) pipes += [fit, run, cei] root = bpy.data.objects.new("NutritionVat_Root", None) root.empty_display_type = 'PLAIN_AXES' root.empty_display_size = 0.2 top.objects.link(root) for o in [body, fluid, can, can_l] + pipes: o.parent = root rep = dict(faces_not_made=dict(body=bad, fluid=bad_f, canister=bad_c, pipe_ceiling=bad_p), audit={o.name: audit(o) for o in (body, fluid, can, pipes[0], pipes[1], pipes[2])}, pipes=dict(ports=list(PIPE_PORTS), ceiling_m=CEIL_Z, run_m=round(run_len, 4), axis_out_px=round(pipe_axis_x(), 2))) path = os.path.join(FOLDER, "NutritionVat.blend") bpy.ops.wm.save_as_mainfile(filepath=path) with open(os.path.join(HERE, "build_report.json"), "w") as fh: json.dump(rep, fh, indent=1) print("BUILD OK", json.dumps(rep)) if __name__ == "__main__": main()