"""Spearfish: a Tender ("sub tub") built after the user's orthographic blueprint (reference/user_blueprint.webp), 2026-10-20: "Create this blueprint as subtub tender 3d model in blender using MCP precisely accordingly take your time check as the referance image". "Spearfish" is a working name after the long pointed nose; the user has not named it. All measurements and the choices where the blueprint's views disagree are in sf_measure.py. Axes as the Kazah and the Manta: 1 unit = 1 m, nose to -Y, port +X, Z up, origin 9 cm behind the docking clamp's axis. No booleans: the canopy and the side windows are left out of the hull grid and its edge vertices are moved onto the outlines; fins, rails and fittings are lofted or lathed quads, pruned where they sink into the hull. Runs inside the open Blender (exec through the MCP) or headless; it rebuilds only its own collections. """ import bpy, bmesh, math, os, sys, json, time from mathutils import Vector, Matrix HERE = r"D:\HADAL - The Descent\models\tender-spearfish\build" exec(open(os.path.join(HERE, "lib2.py")).read()) exec(open(os.path.join(HERE, "tender_lib.py")).read()) exec(open(os.path.join(HERE, "sf_measure.py")).read()) OUT = os.path.dirname(HERE) t_start = time.time() REPORT = {} PFX = "SPEARFISH | " for cname in (PFX + "Tender (exported)", PFX + "Collision (UCX)", PFX + "Sockets"): c = bpy.data.collections.get(cname) if c: for o in list(c.all_objects): bpy.data.objects.remove(o) bpy.data.collections.remove(c) for me in list(bpy.data.meshes): if me.users == 0: bpy.data.meshes.remove(me) # ================================================================== materials (look only; the surfaces pass dresses them) PAINT = MAT(PFX + "Hull paint", (0.66, 0.70, 0.74), rough=0.28, coat=1.0) SEAM = MAT(PFX + "Panel seam", (0.06, 0.07, 0.08), rough=0.55) GRAPH = MAT(PFX + "Graphite", (0.030, 0.034, 0.040), rough=0.42) SENSOR = MAT(PFX + "Sensor glass", (0.006, 0.008, 0.012), rough=0.05, coat=1.0) STEEL = MAT(PFX + "Steel", (0.60, 0.61, 0.63), metal=1.0, rough=0.28) CHROME = MAT(PFX + "Chrome", (0.86, 0.87, 0.89), metal=1.0, rough=0.10) RUBBER = MAT(PFX + "Rubber", (0.017, 0.017, 0.018), rough=0.80) YELLOW = MAT(PFX + "Yellow marking", (0.85, 0.55, 0.04), rough=0.40, coat=0.5) GLASS = MAT(PFX + "Glass", (0.20, 0.27, 0.34), rough=0.03) LENS = MAT(PFX + "Lamp lens", (0.95, 0.97, 1.0), rough=0.03) LAMP = MAT(PFX + "Lamp light", (0.9, 0.9, 0.9), rough=0.3, emit=(1.0, 0.90, 0.75), emit_str=25.0) CAB = MAT(PFX + "Cabin panel", (0.10, 0.11, 0.12), rough=0.6) FLOOR = MAT(PFX + "Cabin floor", (0.05, 0.05, 0.055), rough=0.65) SEAT = MAT(PFX + "Seat", (0.05, 0.035, 0.026), rough=0.55) SCREEN = MAT(PFX + "Screen", (0.01, 0.02, 0.025), rough=0.15, emit=(0.35, 0.75, 1.0), emit_str=3.0) CABGLOW = MAT(PFX + "Cabin light", (0.05, 0.06, 0.07), rough=0.4, emit=(0.70, 0.85, 1.0), emit_str=12.0) for m in (GLASS, LENS): b = m.node_tree.nodes["Principled BSDF"] b.inputs["Transmission Weight"].default_value = 1.0 b.inputs["IOR"].default_value = 1.49 C_ROOT = get_coll(PFX + "Tender (exported)") C_COL = get_coll(PFX + "Collision (UCX)") C_SOCK = get_coll(PFX + "Sockets") root = bpy.data.objects.new("Spearfish_Root", None) C_ROOT.objects.link(root) root.empty_display_type = 'ARROWS' # ================================================================== 1. the hull's shape: superellipse sections along Y def _f(tab, conv): return pchip(sorted((Yi(x), conv(v)) for x, v in tab)) TOPf, BOTf, Wf = _f(TOPY, Zi), _f(BOTY, Zi), _f(WPX, Lp) Y_NOSE, Y_TAIL = Yi(X_TIP), Yi(X_STERN) ZM_FRAC = 0.45 # widest line 45 % of the height down (REAR view: 49 %, FRONT view: 30 %) def ZMf(y): return TOPf(y) - ZM_FRAC * (TOPf(y) - BOTf(y)) def HTf(y): return max(1e-3, TOPf(y) - ZMf(y)) def HBf(y): return max(1e-3, ZMf(y) - BOTf(y)) def ximg(y): return X0 - y / S def NT(y): x = ximg(y) return 2.8 - 0.9 * smoothstep(990, 1125, x) - 0.2 * smoothstep(600, 530, x) # REAR view: a full top (n 3.5-4) def NB(y): return 2.3 - 0.7 * smoothstep(990, 1125, ximg(y)) # REAR: n 2.3-2.5; nose: a keel def sec(y, th, inset=0.0): c, s = math.cos(th), math.sin(th) n = NT(y) if s >= 0 else NB(y) W = max(0.004, Wf(y) - inset) H = max(0.004, (HTf(y) if s >= 0 else HBf(y)) - inset) x = W * math.copysign(abs(c) ** (2.0 / n), c) z = ZMf(y) + H * math.copysign(abs(s) ** (2.0 / n), s) return Vector((x, y, z)) def hull_z_at(x, y, top=True, inset=0.0): W = max(0.004, Wf(y) - inset); n = NT(y) if top else NB(y); H = max(0.004, (HTf(y) if top else HBf(y)) - inset) q = max(0.0, 1.0 - min(1.0, abs(x) / W) ** n) return ZMf(y) + (1 if top else -1) * H * q ** (1.0 / n) def hull_x_at(z, y, inset=0.0): W = max(0.004, Wf(y) - inset); zm = ZMf(y) top = z >= zm; n = NT(y) if top else NB(y); H = max(0.004, (HTf(y) if top else HBf(y)) - inset) q = max(0.0, 1.0 - min(1.0, abs(z - zm) / H) ** n) return W * q ** (1.0 / n) def _F(p): y = min(max(p.y, Y_NOSE + 1e-3), Y_TAIL) zm = ZMf(y); top = p.z >= zm n = NT(y) if top else NB(y); W = Wf(y); H = HTf(y) if top else HBf(y) return (abs(p.x) / W) ** n + (abs(p.z - zm) / H) ** n def normal_at(p, h=1e-4): g = Vector(((_F(p + Vector((h, 0, 0))) - _F(p - Vector((h, 0, 0)))), (_F(p + Vector((0, h, 0))) - _F(p - Vector((0, h, 0)))), (_F(p + Vector((0, 0, h))) - _F(p - Vector((0, 0, h)))))) return g.normalized() if g.length > 1e-12 else Vector((0, 0, 1)) def inside_depth(p): y = p.y if y < Y_NOSE or y > Y_TAIL + 0.005: return -1.0 W = Wf(y); zm = ZMf(y); top = p.z >= zm n = NT(y) if top else NB(y); H = HTf(y) if top else HBf(y) f = (abs(p.x) / W) ** n + (abs(p.z - zm) / H) ** n return (1.0 - f ** (1.0 / n)) * min(W, H) # projections: a point of a view's 2D outline onto the hull def on_top(lat, y, lift=0.0): p = Vector((lat, y, hull_z_at(lat, y, True))) return p + normal_at(p) * lift if lift else p def on_bot(lat, y, lift=0.0): p = Vector((lat, y, hull_z_at(lat, y, False))) return p + normal_at(p) * lift if lift else p def on_side(sg, y, z, lift=0.0): p = Vector((sg * hull_x_at(z, y), y, z)) return p + normal_at(p) * lift if lift else p # 2D outlines in model units def side_poly(img): return [(Yi(x), Zi(v)) for x, v in img] def pip(pt, poly): x, y = pt; inside = False; n = len(poly) for i in range(n): x1, y1 = poly[i]; x2, y2 = poly[(i + 1) % n] if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1: inside = not inside return inside def nearest_on_poly(pt, poly): best, bd = None, 1e9 px, py = pt for i in range(len(poly)): ax, ay = poly[i]; bx, by = poly[(i + 1) % len(poly)] dx, dy = bx - ax, by - ay t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / max(1e-12, dx * dx + dy * dy))) qx, qy = ax + dx * t, ay + dy * t d = (qx - px) ** 2 + (qy - py) ** 2 if d < bd: bd, best = d, (qx, qy) return best def resample(poly, step, closed=True): pts = list(poly) + ([poly[0]] if closed else []) out = [] for i in range(len(pts) - 1): (ax, ay), (bx, by) = pts[i], pts[i + 1] n = max(1, int(math.ceil(math.hypot(bx - ax, by - ay) / step))) for j in range(n): out.append((ax + (bx - ax) * j / n, ay + (by - ay) * j / n)) if not closed: out.append(pts[-1]) return out def centroid(poly): return (sum(p[0] for p in poly) / len(poly), sum(p[1] for p in poly) / len(poly)) P_CANOPY = side_poly(CANOPY) P_SIDEWIN = side_poly(SIDEWIN) # the top hatch's opening (TOP view; x lateral, y): the lid's trapezoid less 4.5 cm all round, corners cut def _hatch_hole(inset=0.045, cut=0.05): (xa, wa), (xf, wf) = HATCH ya, yf = Yi(xa) - inset, Yi(xf) + inset la, lf = wa * S - inset, wf * S - inset pts = [(-la, ya), (la, ya), (lf, yf), (-lf, yf)] out = [] for j in range(4): a, b, c = Vector(pts[j - 1] + (0,)), Vector(pts[j] + (0,)), Vector(pts[(j + 1) % 4] + (0,)) out += [tuple((b + (a - b).normalized() * cut)[:2]), tuple((b + (c - b).normalized() * cut)[:2])] return out P_HATCHHOLE = _hatch_hole() OPENINGS = [P_CANOPY, P_SIDEWIN, P_HATCHHOLE] KIND = ["side", "side", "top"] SIDE_OPENINGS = [P_CANOPY, P_SIDEWIN] # ================================================================== 2. hull grid with the openings left out STEP = 0.040 M = 192 n_st = int(round((Y_TAIL - Yi(1133.5)) / STEP)) YS = [Yi(1133.5) + (Y_TAIL - Yi(1133.5)) * j / n_st for j in range(n_st + 1)] TH = [-math.pi / 2 + 2 * math.pi * k / M for k in range(M)] rings = [[sec(y, th) for th in TH] for y in YS] NR = len(YS) def face_open(i, k): q = [rings[i][k], rings[i + 1][k], rings[i + 1][(k + 1) % M], rings[i][(k + 1) % M]] c = sum(q, Vector()) / 4 for j, poly in enumerate(OPENINGS): if KIND[j] == "side": if abs(c.x) > 0.02 and pip((c.y, c.z), poly): return j elif c.z > ZMf(c.y) and pip((c.x, c.y), poly): return j return None OPEN = {} for i in range(NR - 1): for k in range(M): j = face_open(i, k) if j is not None: OPEN[(i, k)] = j def corners(i, k): return [(i, k), (i + 1, k), (i + 1, (k + 1) % M), (i, (k + 1) % M)] # boundary grid vertices: shared by an open and a closed face; moved onto the outline (on the hull surface) v_open = {} v_closed = set() for i in range(NR - 1): for k in range(M): for ik in corners(i, k): if (i, k) in OPEN: v_open[ik] = OPEN[(i, k)] else: v_closed.add(ik) SNAP = {} for ik, j in v_open.items(): if ik in v_closed: p = rings[ik[0]][ik[1]] if KIND[j] == "side": qy, qz = nearest_on_poly((p.y, p.z), OPENINGS[j]) SNAP[ik] = on_side(1 if p.x > 0 else -1, qy, qz) else: qx, qy = nearest_on_poly((p.x, p.y), OPENINGS[j]) SNAP[ik] = on_top(qx, qy) def gp(ik): return SNAP.get(ik, rings[ik[0]][ik[1]]) def skin(mb, ring_pts, pick, mat, flip=False, offset=None): """quads of the grid for faces where pick(i, k); offset(ik, p) -> moved point""" bm = bmesh.new() V = {} def vert(ik): if ik not in V: p = ring_pts(ik) V[ik] = bm.verts.new(offset(ik, p) if offset else p) return V[ik] for i in range(NR - 1): for k in range(M): if not pick(i, k): continue ids = corners(i, k) if flip: ids = ids[::-1] bm.faces.new([vert(a) for a in ids]) bm.normal_update() mb.merge(bm, mat) return bm hull = MB("SM_Spearfish_Hull", [PAINT, SEAM, GRAPH]) skin(hull, gp, lambda i, k: (i, k) not in OPEN, PAINT, flip=True) # nose tip fan and stern face (closed below with the stern panel) bmn = bmesh.new() tipv = bmn.verts.new((0, Y_NOSE, Zi(419.0))) r0 = [bmn.verts.new(p) for p in rings[0]] for k in range(M): bmn.faces.new((r0[(k + 1) % M], r0[k], tipv)) bmn.normal_update() hull.merge(bmn, PAINT) REPORT["hull_stations"] = NR REPORT["open_faces"] = len(OPEN) # orientation check of the skin: a face at the top must face up hull.bm.normal_update() _top = max(hull.bm.faces, key=lambda f: f.calc_center_median().z) if _top.normal.z < 0: bmesh.ops.reverse_faces(hull.bm, faces=hull.bm.faces[:]) # ================================================================== 3. glass, window frames, cabin lining LINER = 0.06 CAB_Y0, CAB_Y1 = Yi(1104.0), Yi(669.0) # the cabin from the nose to the rear bulkhead (behind the side windows) I_CAB = [i for i, y in enumerate(YS) if CAB_Y0 - 1e-6 <= y <= CAB_Y1 + 1e-6] i_c0, i_c1 = I_CAB[0], I_CAB[-1] glass = MB("SM_Spearfish_Glass", [GLASS]) skin(glass, gp, lambda i, k: OPEN.get((i, k)) in (0, 1), GLASS, flip=True, offset=lambda ik, p: p - normal_at(p) * 0.022) cab = MB("SM_Spearfish_Cabin", [CAB, FLOOR, SEAT, SCREEN, CABGLOW, GRAPH, STEEL]) lin_rings = {i: [sec(YS[i], th, LINER) for th in TH] for i in I_CAB} def lin_pt(ik): if ik in SNAP: p = SNAP[ik] return p - normal_at(p) * LINER return lin_rings[ik[0]][ik[1]] skin(cab, lin_pt, lambda i, k: i_c0 <= i < i_c1 and (i, k) not in OPEN, CAB) # end caps of the lining (rear bulkhead flat, front fan) bmc = bmesh.new() rb = [bmc.verts.new(p) for p in lin_rings[i_c1]] cv = bmc.verts.new((0, YS[i_c1], ZMf(YS[i_c1]))) for k in range(M): bmc.faces.new((rb[k], rb[(k + 1) % M], cv)) rf = [bmc.verts.new(p) for p in lin_rings[i_c0]] cf = bmc.verts.new((0, YS[i_c0] - 0.03, ZMf(YS[i_c0]))) for k in range(M): bmc.faces.new((rf[(k + 1) % M], rf[k], cf)) bmc.normal_update() cab.merge(bmc, CAB) cab.bm.normal_update() for f in cab.bm.faces: # the lining is seen from inside: faces toward the hull's axis line c = f.calc_center_median() if f.normal.dot(Vector((0, c.y, ZMf(min(max(c.y, Y_NOSE), Y_TAIL)))) - c) < 0 and abs(f.normal.y) < 0.9: f.normal_flip() # window frames: a flange on the skin and a sleeve down to the lining, along each outline, both flanks frames = MB("SM_Spearfish_Frames", [GRAPH]) FR_PROF = [(0.048, -0.004), (0.045, 0.007), (0.032, 0.013), (0.010, 0.013), (-0.003, 0.006), (-0.006, -0.012), (-0.006, -0.064)] FR_PROF_HATCH = [(0.040, -0.004), (0.038, 0.004), (0.025, 0.007), (0.008, 0.007), (-0.003, 0.003), (-0.005, -0.012), (-0.005, -0.064)] # lower: the lid closes over it def frame_loop(poly, sg, step=0.018, kind="side", prof=FR_PROF): pts2 = resample(poly, step) c0, c1 = centroid(poly) P = [on_side(sg, y, z) for y, z in pts2] if kind == "side" else [on_top(x, y) for x, y in pts2] N = [normal_at(p) for p in P] loops = [] outs = [] n = len(P) for j in range(n): t = (P[(j + 1) % n] - P[j - 1]).normalized() o = N[j].cross(t).normalized() d = (o.y * (P[j].y - c0) + o.z * (P[j].z - c1)) if kind == "side" else (o.x * (P[j].x - c0) + o.y * (P[j].y - c1)) if d < 0: o = -o outs.append(o) for a, h in prof: loops.append([P[j] + outs[j] * a + N[j] * h for j in range(n)]) grid_rings(frames, loops, GRAPH, expect=(1, n // 4, tuple(N[n // 4]))) for sg in (1, -1): for poly in SIDE_OPENINGS: frame_loop(poly, sg) frame_loop(P_HATCHHOLE, 1, step=0.015, kind="top", prof=FR_PROF_HATCH) # canopy mullions: bars across the glass, flush with the flange def mullion(a, b, sg): pts2 = resample([(Yi(a[0]), Zi(a[1])), (Yi(b[0]), Zi(b[1]))], 0.02, closed=False) P = [on_side(sg, y, z) for y, z in pts2] loops = [] for (w, h) in ((-0.016, -0.03), (-0.016, 0.010), (-0.008, 0.016), (0.008, 0.016), (0.016, 0.010), (0.016, -0.03)): row = [] for j, p in enumerate(P): t = (P[min(j + 1, len(P) - 1)] - P[max(j - 1, 0)]).normalized() nrm = normal_at(p) bn = nrm.cross(t).normalized() row.append(p + bn * w + nrm * h) loops.append(row) grid_rings(frames, [list(r) for r in zip(*loops)], GRAPH, closed_u=False, expect=(len(P) // 2, 2, tuple(normal_at(P[len(P) // 2])))) for sg in (1, -1): for a, b in MULLIONS: mullion(a, b, sg) # ---- cabin interior (seen through the canopy and the side windows) ZF = -0.47 # deck fl_rows = [] for i in I_CAB: y = YS[i] if y < Yi(1040.0): continue w = hull_x_at(ZF, y, LINER) - 0.004 fl_rows.append([Vector((x, y, ZF)) for x in (-w, -w * 0.5, 0.0, w * 0.5, w)]) grid_rings(cab, fl_rows, FLOOR, closed_u=False, expect=(1, 1, (0, 0, 1))) # pilot seat, reclined, low (the canopy is low on the flanks), facing -Y SY = Yi(945.0) SEAT_TILT = math.radians(-28) cab.cyl(0.07, 0.07, 0.10, (0, SY + 0.05, ZF + 0.05), STEEL, segs=16) cab.box((0.50, 0.52, 0.09), (0, SY, ZF + 0.15), SEAT, bevel=0.03, seg=2) cab.box((0.50, 0.10, 0.66), (0, SY + 0.40, ZF + 0.44), SEAT, rot=(SEAT_TILT, 0, 0), bevel=0.03, seg=2) cab.box((0.26, 0.08, 0.16), (0, SY + 0.57, ZF + 0.82), SEAT, rot=(SEAT_TILT, 0, 0), bevel=0.03, seg=2) for s in (1, -1): cab.box((0.07, 0.40, 0.06), (s * 0.29, SY + 0.04, ZF + 0.33), SEAT, bevel=0.02) cab.cyl(0.018, 0.014, 0.12, (s * 0.29, SY - 0.14, ZF + 0.40), GRAPH, segs=10) # console ahead of the pilot, low, three screens CYc = Yi(1003.0) cab.box((0.62, 0.30, 0.26), (0, CYc, ZF + 0.30), GRAPH, rot=(math.radians(30), 0, 0), bevel=0.02) for x in (-0.19, 0.0, 0.19): cab.box((0.17, 0.012, 0.13), (x, CYc + 0.07, ZF + 0.42), SCREEN, rot=(math.radians(30), 0, 0)) cab.box((0.34, 0.30, 0.30), (0, CYc - 0.12, ZF + 0.15), GRAPH, bevel=0.02) # crew seat behind, by the side windows, and equipment racks CY2 = Yi(735.0) cab.box((0.50, 0.50, 0.10), (0, CY2, ZF + 0.38), SEAT, bevel=0.03, seg=2) cab.box((0.50, 0.10, 0.62), (0, CY2 + 0.28, ZF + 0.70), SEAT, rot=(math.radians(-12), 0, 0), bevel=0.03, seg=2) cab.box((0.30, 0.30, 0.33), (0, CY2 + 0.05, ZF + 0.165), GRAPH, bevel=0.02) for s in (1, -1): y0, y1 = Yi(845.0), Yi(800.0) w = hull_x_at(ZF + 0.30, (y0 + y1) / 2, LINER) cab.box((0.22, abs(y1 - y0), 0.55), (s * (w - 0.16), (y0 + y1) / 2, ZF + 0.30), GRAPH, bevel=0.02) # light strips along the cabin roof for s in (1, -1): pts = [] for i in I_CAB: y = YS[i] if Yi(1000.0) <= y <= Yi(690.0): x = 0.25 * s pts.append(Vector((x, y, hull_z_at(x, y, True, LINER) - 0.015))) cab.sweep(pts[::3], 0.012, CABGLOW, sides=6) # rear bulkhead door cab.box((0.62, 0.05, 1.00), (0, CAB_Y1 - 0.03, ZF + 0.52), GRAPH, bevel=0.02) cab.box((0.54, 0.02, 0.92), (0, CAB_Y1 - 0.06, ZF + 0.52), STEEL, bevel=0.01) REPORT["stage"] = "hull+glass+cabin" for f in ("sf_fittings.py", "sf_finish.py"): fp = os.path.join(HERE, f) if os.path.exists(fp): exec(open(fp).read())