"""Build the nutrition feeder after the user's picture (reference/ref_user.png), headless: blender.exe -b --factory-startup --python nf_build.py -> ../NutritionFeeder.blend (rebuilt from scratch) Numbers are the picture's pixels (u right, v down; 1 px = 0.5 mm, from the hand in the picture) and depths f in mm off the wall (see nf_lib.py). Measured from the ruler crops in reference/z_*.png: panel u 46..980, v 100..1345, corner r 98; a rolled rim, a fine groove at u 57..60, a light face screen recess u 112..915, v 150..642 (r 55); dark bezel to u 138; glass u 140..890, v 175..618 cavity frame u 410..935, v 662..1268 (r 70), raised; its inner edge u 447..900, v 697..1228 (r 55) tunnel a squircle (n 2.5, fitted to the mouth's edges) about (674, 972): a rounded lip bright at the bottom and dim at the top, a dark groove round it, two blue bars on it at v 905..1030 cradle a band outside the lip's lower part from 26 to 154 degrees, widest at the bottom injector mount u 563..789, v 709..805 (shoulders light, bridge and cheeks dark, blue lights under the shoulders); chrome cylinder dia 87 px (v 709..807, a seam at 751), glow ring at 808, collar dia 76 (810..838); nozzle: neck dia 18, glow flange at 858, sleeve dia 33 (862..902), tip dia 22 (902..928), needle to 997 Depths are not in the picture; they are chosen: panel face at 55 mm, frame top 71 mm, back plate at the wall (f 0), tunnel 123 mm into the wall, injector axis 44 mm out. """ 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, "nf_lib.py")).read()) F0 = 55.0 # the panel face, mm off the wall FACE = RR(74, 128, 952, 1317, 70) # the flat face's edge (28 px inside the silhouette) SCREEN = RR(112, 150, 915, 642, 55) # the screen's recess CAVITY = RR(410, 662, 935, 1268, 70) # the cavity frame's foot GRID_STEP = 24.0 # px between the face's grid lines BODY_MATS = ["M_NF_Shell", "M_NF_Trim", "M_NF_Gap", "M_NF_Bezel", "M_NF_Screen", "M_NF_Frame", "M_NF_Cavity", "M_NF_Tunnel", "M_NF_Rim", "M_NF_Steel", "M_NF_SteelDark", "M_NF_Glow"] # profiles: (d px from the base rectangle, outward +; f mm) or (d, r, f) with an explicit corner radius PANEL_PROFILE = [(3.5, F0 - 0.19), (7, F0 - 0.75), (10.5, F0 - 1.69), (14, F0 - 3.0), # the face rolls off (14.5, F0 - 5.5), (16.5, F0 - 5.5), (17, F0 - 3.0), # groove (19, F0 - 2.8), (21.5, F0 - 2.8), # rim front (24.75, F0 - 3.24), (27.13, F0 - 4.42), (28, F0 - 6.05), # rim roll (r 3.25 mm) (28, 25.0), (28, 0.0)] # side to the wall PANEL_MATS = ["M_NF_Shell"] * 4 + ["M_NF_Gap"] * 3 + ["M_NF_Trim"] * 7 SCREEN_PROFILE = [(-1.2, F0 - 0.35), (-2.6, F0 - 1.2), (-3.6, F0 - 2.4), (-6.5, F0 - 8.0), (-7.6, F0 - 10.0), # into the recess (-8.0, F0 - 11.0), (-9.2, F0 - 11.0), # the gap's floor (-9.6, F0 - 7.5), (-10.2, F0 - 5.5), (-11.2, F0 - 4.6), (-12.6, F0 - 4.3), # bezel's outer edge (-20.0, F0 - 4.4), (-25.0, F0 - 4.8), (-26.6, F0 - 5.6), (-27.6, F0 - 7.2), # bezel (-28.2, F0 - 10.5), (-28.5, F0 - 11.0)] # black lip to the glass SCREEN_MATS = ["M_NF_Shell"] * 5 + ["M_NF_Gap"] * 2 + ["M_NF_Bezel"] * 7 + ["M_NF_Gap"] * 3 CAV_PROFILE = [(-0.4, 69.6, F0 + 0.6), (-0.8, 69.2, F0 + 2.0), # foot (-1.4, 68.6, F0 + 8.0), (-2.0, 68.0, F0 + 12.0), # frame's outer side (-3.2, 66.8, F0 + 14.2), (-5.0, 65.0, F0 + 15.4), (-8.0, 62.0, F0 + 16.0), # roll onto the top (-14.0, 59.0, F0 + 16.3), (-21.0, 58.0, F0 + 16.3), (-27.0, 57.0, F0 + 16.0), # top (-30.5, 56.0, F0 + 15.2), (-33.0, 55.5, F0 + 13.6), (-35.0, 55.0, F0 + 11.2), # inner roll (the bright line) (-36.4, 55.0, F0 + 8.5), (-37.0, 55.0, F0 + 4.0), (-37.4, 55.0, F0 - 10.0), (-37.8, 54.5, 30.0), (-38.4, 54.0, 3.0), # recess wall (-39.6, 53.0, 0.8), (-41.5, 51.5, 0.0)] # fillet onto the back plate CAV_MATS = ["M_NF_Frame"] * 10 + ["M_NF_Trim"] * 4 + ["M_NF_Cavity"] * 6 RIM_FOOT = 216.5 # the back plate's edge round the mouth (squircle half-size, px) RIM_PROFILE = [(215.6, -0.6), (214.9, -1.8), # plate edge rolls down (214.4, -3.2), (213.6, -3.6), (212.9, -3.0), # groove (212.5, -1.2), (211.4, 0.4), (209.4, 1.0), (206.8, 0.6), # the lip's crown (204.0, -0.8), (201.4, -3.0), (199.4, -5.8), (198.0, -9.0), (197.3, -12.6), # rolling into the tunnel (197.0, -16.0), (193.0, -40.0), (188.0, -70.0), (183.0, -100.0), (181.0, -112.0), # tunnel, narrowing (179.4, -118.0), (176.0, -121.5), (171.0, -123.0)] # into its back RIM_GLOW = (0, 8) # the side light bars: from the plate's edge over the groove onto the lip's crown RIM_MATS = ["M_NF_Cavity"] * 2 + ["M_NF_Gap"] * 3 + ["M_NF_Rim"] * 9 + ["M_NF_Tunnel"] * 8 GLOW_LEFT = (math.radians(161.5), math.radians(200.5)) # picture v 1030 .. 905 on the left side, and a row GLOW_RIGHT = (math.radians(-21.0), math.radians(19.0)) # injector: axis and its parts INJ_U, INJ_F = 673.0, 40.0 BODY_LATHE = [(43.5, 709.0), (43.5, 749.5), (42.3, 750.3), (42.3, 751.7), (43.5, 752.5), (43.5, 803.5), # chrome, a seam (42.6, 806.0), (41.0, 807.4), (38.6, 808.4), # chamfer, glow ring (38.0, 810.0), (38.0, 833.5), (37.0, 836.8), (34.8, 838.6), (20.0, 838.6), # collar (19.0, 837.6), (19.0, 730.0), (18.0, 728.0)] # bore for the nozzle BODY_LATHE_MATS = (["M_NF_Steel", "M_NF_Steel", "M_NF_Gap", "M_NF_Steel", "M_NF_Steel", "M_NF_Steel", "M_NF_Steel", "M_NF_Glow"] + ["M_NF_SteelDark"] * 5 + ["M_NF_Gap"] * 3) NOZZLE_LATHE = [(9.0, 830.0), (9.0, 854.5), (12.0, 855.2), (16.0, 856.3), (17.5, 857.6), (17.5, 861.6), (17.0, 862.6), (16.5, 863.5), (16.5, 899.5), (15.5, 901.6), (12.0, 902.4), (11.0, 903.2), (11.0, 922.8), (10.0, 925.6), (7.5, 927.6), (4.2, 928.6), (2.6, 928.6), (2.6, 844.0)] NOZZLE_MATS = ["M_NF_Steel"] * 4 + ["M_NF_Glow"] + ["M_NF_Steel"] * 10 + ["M_NF_Gap"] * 2 NEEDLE_LATHE = [(2.0, 919.0), (2.0, 935.0), (1.6, 950.0), (1.15, 965.0), (0.75, 980.0), (0.4, 991.0), (0.0, 997.0)] NEEDLE_MATS = ["M_NF_Steel"] * 5 + ["M_NF_Glow"] NOZZLE_TRAVEL = 94.0 * S # m: tip from v 928 (out, as in the picture) up to v 834 (inside the collar) NEEDLE_TRAVEL = 72.0 * S # m: tip from v 997 up to v 925 (inside the nozzle's tip) # cradle: a band on the back plate round the mouth's lower part (the picture's mid-grey band between the bright lip # and a dark line), high at its inner edge and sloping down outward; narrow at its ends (the notches), wide at the bottom CRADLE_A = (math.radians(26.1), math.radians(153.7)) CRADLE_IN = 218.5 # px, 2 px outside the plate's edge round the mouth CRADLE_W = (11.0, 23.0) # px wide at the ends .. at the bottom CRADLE_H = (4.0, 8.5) # mm high at the ends .. at the bottom CRADLE_TOP = [(0.0, 0.55), (0.0, 0.80), (0.04, 0.95), (0.12, 1.0), (0.3, 0.94), (0.5, 0.80), (0.68, 0.62), (0.82, 0.44), (0.93, 0.26), (1.0, 0.10)] # (across 0 inner .. 1 outer, height as a share) def lines(req, step): req = sorted(req) out = [] for a, b in zip(req, req[1:]): n = max(1, int(round((b - a) / step))) out += [a + (b - a) * k / n for k in range(n)] return out + [req[-1]] def face_grid(mb): cols = lines([74, 112, 410, 915, 935, 952], GRID_STEP) rows = lines([128, 150, 642, 662, 1268, 1317], GRID_STEP) ci = lambda u: min(range(len(cols)), key=lambda i: abs(cols[i] - u)) rj = lambda v: min(range(len(rows)), key=lambda j: abs(rows[j] - v)) holes = [(SCREEN, ci(112), ci(915), rj(150), rj(642)), (CAVITY, ci(410), ci(935), rj(662), rj(1268))] NI, NJ = len(cols) - 1, len(rows) - 1 keep = {(i, j) for i in range(NI) for j in range(NJ) if not any(a <= i < b and c <= j < d for (_, a, b, c, d) in holes)} exists = {(i + di, j + dj) for (i, j) in keep for di in (0, 1) for dj in (0, 1)} pos, owner = {}, {} for (i, j) in exists: u, v = cols[i], rows[j] pos[(i, j)] = [u, v] if i in (0, NI) or j in (0, NJ): owner[(i, j)] = FACE for (rr, a, b, c, d) in holes: if a <= i <= b and c <= j <= d and (i in (a, b) or j in (c, d)): owner[(i, j)] = rr prm = {} for k, rr in owner.items(): prm[k] = rr.param_of(cols[k[0]], rows[k[1]]) pos[k] = list(rr.at(prm[k])) # relax the inner vertices near the rounded corners (Laplace, boundary fixed) centres = [(c, rr.r) for rr in (FACE, SCREEN, CAVITY) for c in rr.arc_centres()] free = [k for k in exists if k not in owner and any(math.hypot(cols[k[0]] - c[0], rows[k[1]] - c[1]) < 2.4 * r for c, r in centres)] for _ in range(150): new = {} for (i, j) in free: nb = [pos[n] for n in ((i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1))] new[(i, j)] = [sum(p[0] for p in nb) / 4, sum(p[1] for p in nb) / 4] pos.update(new) vid = {k: mb.v(W(p[0], p[1], F0)) for k, p in pos.items()} for (i, j) in keep: mb.f([vid[(i, j)], vid[(i + 1, j)], vid[(i + 1, j + 1)], vid[(i, j + 1)]], "M_NF_Shell") # check the quads in the picture plane: all turn the same way, none too thin worst = 1e9 for (i, j) in keep: q = [pos[(i, j)], pos[(i + 1, j)], pos[(i + 1, j + 1)], pos[(i, j + 1)]] for k in range(4): a, b, c = q[k - 1], q[k], q[(k + 1) % 4] cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) worst = min(worst, cr) def walk(a, b, c, d): """the rectangle a..b x c..d's boundary, TL corner first, clockwise in the picture""" p = [(i, c) for i in range(a, b)] + [(b, j) for j in range(c, d)] + \ [(i, d) for i in range(b, a, -1)] + [(a, j) for j in range(d, c, -1)] return p, [0, b - a, (b - a) + (d - c), 2 * (b - a) + (d - c)] loops = {} keys, corners = walk(0, NI, 0, NJ) loops["face"] = ([vid[k] for k in keys], [prm[k] for k in keys], corners) for name, (rr, a, b, c, d) in zip(("screen", "cavity"), holes): keys, corners = walk(a, b, c, d) loops[name] = ([vid[k] for k in keys], [prm[k] for k in keys], corners) return loops, dict(cols=len(cols), rows=len(rows), cells=len(keep), min_cross=round(worst, 3)) def profile_loft(mb, base_idx, prms, rr, profile, mats): """loops of rr grown by each profile step (concentric, or with the explicit radius), bridged in order""" prev, made = base_idx, [] for k, step in enumerate(profile): if len(step) == 3: d, r, f = step g = rr.grown(d, r) else: d, f = step g = rr.grown(d) cur = [mb.v(W(*g.at(p), f)) for p in prms] mb.bridge(prev, cur, mats[k]) made.append(cur) prev = cur return made def build_body(): mb = MB() loops, info = face_grid(mb) # panel edge: from the face out to the silhouette and back to the wall idx, prms, _ = loops["face"] profile_loft(mb, idx, prms, FACE, PANEL_PROFILE, PANEL_MATS) # screen: recess, gap, bezel, lip, glass idx, prms, corners = loops["screen"] sl = profile_loft(mb, idx, prms, SCREEN, SCREEN_PROFILE, SCREEN_MATS) mb.coons(sl[-1], corners, "M_NF_Screen") # cavity frame, recess wall idx, prms, corners = loops["cavity"] cl = profile_loft(mb, idx, prms, CAVITY, CAV_PROFILE, CAV_MATS) # back plate: the recess's floor loop to the tunnel's foot, ray by ray from the tunnel's centre phis = [] for vi in cl[-1]: u, v = UV_OF(mb.co[vi]) phis.append(math.atan2(v - SQ_C[1], u - SQ_C[0])) foot = [mb.v(W(*sq_point(p, RIM_FOOT), 0.0)) for p in phis] mb.bridge(cl[-1], foot, "M_NF_Cavity") prev = foot def in_glow(p): return GLOW_LEFT[0] <= (p % (2 * math.pi)) <= GLOW_LEFT[1] or GLOW_RIGHT[0] <= p <= GLOW_RIGHT[1] for k, (rho, f) in enumerate(RIM_PROFILE): cur = [mb.v(W(*sq_point(p, rho), f)) for p in phis] if RIM_GLOW[0] <= k <= RIM_GLOW[1]: m = lambda i, k=k: "M_NF_Glow" if (in_glow(phis[i]) and in_glow(phis[(i + 1) % len(phis)])) else RIM_MATS[k] else: m = RIM_MATS[k] mb.bridge(prev, cur, m) prev = cur mb.coons(prev, corners, "M_NF_Tunnel") # the tunnel's back # cradle on the rim's lower part build_cradle(mb) # injector mount and body build_mount(mb) lathe(mb, BODY_LATHE, INJ_U, INJ_F, 32, BODY_LATHE_MATS, cap_start=False, cap_end=True) return mb, info def widen(a): """0 at the cradle's ends, 1 at the bottom""" return max(0.0, (math.sin(a) - math.sin(CRADLE_A[0])) / (1 - math.sin(CRADLE_A[0]))) def build_cradle(mb, steps=40): n = len(CRADLE_TOP) loops = [] for s_ in range(steps + 1): a = CRADLE_A[0] + (CRADLE_A[1] - CRADLE_A[0]) * s_ / steps w = widen(a) wd = CRADLE_W[0] + (CRADLE_W[1] - CRADLE_W[0]) * w h = CRADLE_H[0] + (CRADLE_H[1] - CRADLE_H[0]) * w top = [(CRADLE_IN + t * wd, 0.3 + q * h) for (t, q) in CRADLE_TOP] bottom = [(CRADLE_IN + wd * i / (n - 1), 0.3) for i in range(n)] # evenly under the top loops.append([mb.v(W(*sq_point(a, rho), f)) for (rho, f) in bottom + top[::-1]]) for a, b in zip(loops, loops[1:]): mb.bridge(a, b, "M_NF_Rim") for ring in (loops[0], loops[-1]): # flat ends: a ladder of quads for i in range(n - 1): mb.f([ring[i], ring[i + 1], ring[2 * n - 2 - i], ring[2 * n - 1 - i]], "M_NF_Rim") def build_mount(mb): """the picture's injector mount (reference/z_inj5.png): light shoulders u 571..610 and 736..774 down to v 757, dark sleeve sides beside the chrome cylinder, blue lights on ledges under the shoulders (v 757..763), dark cheeks below them to v 807 hanging over the mouth's lip, and a thin shelf the cylinder hangs from (v 704..709)""" cols = [571, 583, 601, 610, 628, 718, 736, 745, 762, 774] rows = [704, 709, 721, 750, 757, 763, 807] c = {u: i for i, u in enumerate(cols)} r = {v: j for j, v in enumerate(rows)} cells = {} def put(u0, u1, v0, v1, fb, ff, mat, fmat=None): for i in range(c[u0], c[u1]): for j in range(r[v0], r[v1]): cells[(i, j)] = (fb, ff, mat, fmat) put(571, 610, 709, 757, 0.0, 24.0, "M_NF_Rim") # left shoulder put(736, 774, 709, 757, 0.0, 24.0, "M_NF_Rim") # right shoulder put(610, 628, 709, 757, 0.0, 24.0, "M_NF_Bezel") # sleeve sides put(718, 736, 709, 757, 0.0, 24.0, "M_NF_Bezel") put(628, 718, 709, 721, 0.0, 16.0, "M_NF_Bezel") # bridge behind the cylinder's top put(610, 736, 704, 709, 0.0, 63.0, "M_NF_Bezel") # shelf the cylinder hangs from (over the sleeve too, # so no two blocks meet only along an edge) put(601, 628, 757, 763, 0.0, 24.0, "M_NF_Bezel", "M_NF_Glow") # light ledges put(718, 745, 757, 763, 0.0, 24.0, "M_NF_Bezel", "M_NF_Glow") put(601, 628, 763, 807, 17.0, 24.0, "M_NF_Bezel") # cheeks over the mouth's lip (16 mm clear) put(718, 745, 763, 807, 17.0, 24.0, "M_NF_Bezel") # the shoulders' rounded top-outer corners: pull the corner vertex in along the diagonal moved = {(c[571], r[709]): (575.5, 713.5), (c[774], r[709]): (769.5, 713.5)} cell_solid(mb, cols, rows, cells, moved) def build_moving(): nz = MB() lathe(nz, NOZZLE_LATHE, INJ_U, INJ_F, 24, NOZZLE_MATS, cap_start=True, cap_end=True) nd = MB() lathe(nd, NEEDLE_LATHE, INJ_U, INJ_F, 12, NEEDLE_MATS, cap_start=True) return nz, nd 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): """an object for mesh me; origin moves the pivot (the mesh is shifted so world positions stay)""" 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(BODY_MATS) sc = bpy.context.scene sc.unit_settings.system = 'METRIC' top = coll("NutritionFeeder") c_static, c_moving = coll("NF | static", top), coll("NF | moving", top) coll("NF | collision", top) mb, info = build_body() me, bad = mb.to_mesh("SM_NutritionFeeder_Body", BODY_MATS) body = obj("SM_NutritionFeeder_Body", me, c_static) nz, nd = build_moving() me_n, bad_n = nz.to_mesh("SM_NutritionFeeder_Nozzle", ["M_NF_Steel", "M_NF_Glow", "M_NF_Gap"]) me_d, bad_d = nd.to_mesh("SM_NutritionFeeder_Needle", ["M_NF_Steel", "M_NF_Glow"]) nozzle = obj("SM_NutritionFeeder_Nozzle", me_n, c_moving, origin=W(INJ_U, 830.0, INJ_F)) needle = obj("SM_NutritionFeeder_Needle", me_d, c_moving, origin=W(INJ_U, 919.0, INJ_F)) root = bpy.data.objects.new("NutritionFeeder_Root", None) root.empty_display_type = 'PLAIN_AXES' root.empty_display_size = 0.1 top.objects.link(root) for o in (body, nozzle): o.parent = root needle.parent = nozzle needle.location = needle.location - nozzle.location # parent at the origin with no rotation: local = offset rep = dict(face_grid=info, faces_not_made=dict(body=bad, nozzle=bad_n, needle=bad_d), audit={o.name: audit(o) for o in (body, nozzle, needle)}) exec(open(os.path.join(HERE, "nf_rig.py")).read(), {"__name__": "nf_rig", "bpy": bpy, "S": S, "NOZZLE_TRAVEL": NOZZLE_TRAVEL, "NEEDLE_TRAVEL": NEEDLE_TRAVEL}) path = os.path.join(FOLDER, "NutritionFeeder.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()