# Spearfish: the hull, its openings, glass, cabin lining and cabin (exec'd by build_spearfish.py after section 1). # # Topology (rebuilt 2026-10-20 after the user: "the topology is terrible fix the topology and make this as a game ready asset"): # * one quad grid: 64 columns round, placed by arc length on each section (even faces, no bunching at the shoulders), and # stations spaced by need (about 13 cm amidships, 4 cm at the nose tip, 5 cm at the stern, 6 cm along the openings); # * the canopy, the side windows and the hatch are rectangles of that grid: on the stations along an opening, the band's # columns are pinned to the opening's outline and relax back to their even places within 0.35 m past its ends. No # snapped vertices, no slivers; # * each opening has a frame in the hull's own topology: the ring of faces round the hole, its inner edge raised 10 mm # (6 mm at the hatch), and a sleeve straight down to the cabin lining, whose hole meets it vertex for vertex; # * the glass is the hole's own grid sunk 22 mm; the lining is the grid sunk 60 mm (the same columns, one shell); # * ends: the stern closes with a recessed panel filled by a quad grid; the nose ends in a fan at its point (a cone tip); # * panel seams are not geometry: two per-vertex fields (seam_ring, seam_long: 5 + signed distance in metres, 0 where # there is none) that the surfaces pass draws into the paint and bakes into the bevel normal. exec(open(os.path.join(HERE, "sf_topo.py")).read()) M = 64 HALF = M // 2 # port columns j = 0 (keel) .. 32 (top), starboard mirrored: k = 64 - j J_B, J_T = 7, 12 # window band (the hole between them), frames on j 6..7 and 12..13 J_H = 29 # hatch: hole over j 29 .. 35 across the top, frame 28..29 FR_W, FR_H = 0.045, 0.040 # frame widths (arc on the skin) RIM_W, RIM_H = 0.010, 0.006 # how far each frame's inner edge stands proud GLASS_D, LINER = 0.022, 0.060 RELAX = 0.35 # columns relax to their even places this far past an opening HMIN = 0.06 # an opening's ends are cut where its outline is lower than this (the frame rounds them) NSEC = 360 def half_table(y): th = [-math.pi / 2 + math.pi * i / NSEC for i in range(NSEC + 1)] pts = [sec(y, t) for t in th] arc = [0.0] for i in range(1, len(pts)): arc.append(arc[-1] + (pts[i] - pts[i - 1]).length) return th, arc def interp(xs, ys, x): if x <= xs[0]: return ys[0] if x >= xs[-1]: return ys[-1] lo, hi = 0, len(xs) - 1 while hi - lo > 1: mid = (lo + hi) // 2 if xs[mid] <= x: lo = mid else: hi = mid t = (x - xs[lo]) / (xs[hi] - xs[lo]) return ys[lo] + (ys[hi] - ys[lo]) * t def theta_of_z(y, z): """port flank angle of the point at height z""" zm = ZMf(y); top = z >= zm n = NT(y) if top else NB(y); H = HTf(y) if top else HBf(y) q = min(1.0, abs(z - zm) / H) ** (n / 2.0) return math.copysign(math.asin(min(1.0, q)), z - zm) def theta_of_lat(y, lat): """angle of the point on the top at lateral offset lat""" n = NT(y); W = Wf(y) c = min(1.0, abs(lat) / W) ** (n / 2.0) return math.acos(min(1.0, c)) def side_span(poly, y): zs = [] n = len(poly) for i in range(n): (y1, z1), (y2, z2) = poly[i], poly[(i + 1) % n] if (y1 - y) * (y2 - y) <= 0 and y1 != y2: zs.append(z1 + (z2 - z1) * (y - y1) / (y2 - y1)) return (min(zs), max(zs)) if len(zs) >= 2 else None def opening_range(poly): ys = [p[0] for p in poly] a, b = min(ys), max(ys) ok = [] for i in range(801): y = a + (b - a) * i / 800 s = side_span(poly, y) if s and s[1] - s[0] >= HMIN: ok.append(y) return min(ok), max(ok) WINDOWS = [] for poly in (P_CANOPY, P_SIDEWIN): ya, yb = opening_range(poly) WINDOWS.append(dict(poly=poly, ya=ya, yb=yb)) (hxa, hwa), (hxf, hwf) = HATCH H_IN = 0.045 # the hole is the lid's trapezoid less 4.5 cm all round HATCH_YA, HATCH_YB = Yi(hxf) + H_IN, Yi(hxa) - H_IN def hatch_half(y): t = (y - Yi(hxf)) / (Yi(hxa) - Yi(hxf)) return (hwf + (hwa - hwf) * t) * S - H_IN def weight(y, a, b, pad): if a <= y <= b: return 1.0 d = (a - y) if y < a else (y - b) return 1.0 - smoothstep(pad, pad + RELAX, d) # ------------------------------------------------------------------ stations def spacing(y): x = ximg(y) h = 0.13 dn = y - Y_NOSE # distance from the nose tip h = min(h, 0.035 + 0.095 * smoothstep(0.0, 1.1, dn)) ds = Y_TAIL - y # distance from the stern face h = min(h, 0.05 + 0.08 * smoothstep(0.0, 0.5, ds)) for w in WINDOWS: if w["ya"] - 0.1 <= y <= w["yb"] + 0.1: h = min(h, 0.065) if HATCH_YA - 0.1 <= y <= HATCH_YB + 0.1: h = min(h, 0.07) return h Y0 = Yi(X_TIP - 2.5) # first ring, 4 cm behind the point special = [] for w in WINDOWS: special += [w["ya"] - FR_W, w["ya"], w["yb"], w["yb"] + FR_W] special += [HATCH_YA - FR_H, HATCH_YA, HATCH_YB, HATCH_YB + FR_H] ys = [Y0] while ys[-1] < Y_TAIL - 1e-6: ys.append(min(Y_TAIL, ys[-1] + spacing(ys[-1] + 0.5 * spacing(ys[-1])))) for s in special: ys = [y for y in ys if abs(y - s) > 0.45 * spacing(s)] + [s] ys = sorted(set(round(y, 6) for y in ys)) if Y_TAIL - ys[-1] > 1e-4: ys.append(Y_TAIL) YS = ys NR = len(YS) def st(y): return min(range(NR), key=lambda i: abs(YS[i] - y)) for w in WINDOWS: w["ia"], w["ib"] = st(w["ya"]), st(w["yb"]) w["fa"], w["fb"] = st(w["ya"] - FR_W), st(w["yb"] + FR_W) H_IA, H_IB = st(HATCH_YA), st(HATCH_YB) H_FA, H_FB = st(HATCH_YA - FR_H), st(HATCH_YB + FR_H) # ------------------------------------------------------------------ columns per station (arc length, pinned at the openings) def columns(y): th, arc = half_table(y) L = arc[-1] def a_of(t): return interp(th, arc, t) ctrl = {0: 0.0, HALF: L} dflt = lambda j: L * j / HALF ww, wo = 0.0, None for w in WINDOWS: g = weight(y, w["ya"], w["yb"], FR_W) if g > ww: ww, wo = g, w if wo is not None and ww > 0: yc = min(max(y, wo["ya"]), wo["yb"]) zlo, zhi = side_span(wo["poly"], yc) aB, aT = a_of(theta_of_z(y, zlo)), a_of(theta_of_z(y, zhi)) for j, tgt in ((J_B - 1, aB - FR_W), (J_B, aB), (J_T, aT), (J_T + 1, aT + FR_W)): ctrl[j] = dflt(j) + (tgt - dflt(j)) * ww wh = weight(y, HATCH_YA, HATCH_YB, FR_H) if wh > 0: yc = min(max(y, HATCH_YA), HATCH_YB) hw = hatch_half(yc) aE, aF = a_of(theta_of_lat(y, hw)), a_of(theta_of_lat(y, hw + FR_H)) for j, tgt in ((J_H - 1, aF), (J_H, aE)): ctrl[j] = dflt(j) + (tgt - dflt(j)) * wh keys = sorted(ctrl) vals = [ctrl[k] for k in keys] for i in range(1, len(vals)): # keep them in order vals[i] = max(vals[i], vals[i - 1] + 0.004) a_j = [interp(keys, vals, j) for j in range(HALF + 1)] t_j = [interp(arc, th, a) for a in a_j] t_j[0], t_j[HALF] = -math.pi / 2, math.pi / 2 # full ring: port 0..32, starboard 33..63 (mirror of 31..1) return t_j + [math.pi - t_j[HALF - k] for k in range(1, HALF)] TH = [columns(y) for y in YS] BASE = [[sec(YS[i], t) for t in TH[i]] for i in range(NR)] NRM = [[normal_at(p) for p in row] for row in BASE] def kband(j0, j1): """the ring indices of a port band j0..j1 and its starboard mirror""" return list(range(j0, j1)), [M - j for j in range(j1, j0, -1)] # roles of the grid faces (i, k): i between station i and i+1, k between column k and k+1 ROLE = {} for i in range(NR - 1): for k in range(M): ROLE[(i, k)] = "skin" for w in WINDOWS: for ks in kband(J_B, J_T): for i in range(w["ia"], w["ib"]): for k in ks: ROLE[(i, k)] = ("hole", w) for i in list(range(w["fa"], w["ia"])) + list(range(w["ib"], w["fb"])): for k in ks: ROLE[(i, k)] = "frame" for i in range(w["fa"], w["fb"]): for k in (J_B - 1, J_T, M - J_T - 1, M - J_B): ROLE[(i, k)] = "frame" for i in range(H_IA, H_IB): for k in range(J_H, M - J_H): ROLE[(i, k)] = ("hole", None) for i in list(range(H_FA, H_IA)) + list(range(H_IB, H_FB)): for k in range(J_H, M - J_H): ROLE[(i, k)] = "frame" for i in range(H_FA, H_FB): for k in (J_H - 1, M - J_H): ROLE[(i, k)] = "frame" def hole_loop(ia, ib, k0, k1): """the hole's boundary as grid vertices, in order round it""" return ([(i, k0) for i in range(ia, ib)] + [(ib, k) for k in range(k0, k1)] + [(i, k1) for i in range(ib, ia, -1)] + [(ia, k) for k in range(k1, k0, -1)]) HOLES = [] for w in WINDOWS: HOLES.append((w["ia"], w["ib"], J_B, J_T, RIM_W, True)) HOLES.append((w["ia"], w["ib"], M - J_T, M - J_B, RIM_W, True)) HOLES.append((H_IA, H_IB, J_H, M - J_H, RIM_H, False)) RAISE = {} for ia, ib, k0, k1, rim, _ in HOLES: for ik in hole_loop(ia, ib, k0, k1): RAISE[ik] = rim def P(ik): i, k = ik k %= M p = BASE[i][k] return p + NRM[i][k] * RAISE.get((i, k), 0.0) # seam fields (5 + signed distance; 0 where a vertex has none) SEAM_YS = [Yi(x) for x in SEAMS_X] def seam_ring(p): d = min((p.y - sy for sy in SEAM_YS), key=abs) return 5.0 + d if abs(d) < 0.30 else 0.0 dx0, dx1, dlat = DECK_SEAM def seam_long(p): if not (Yi(dx1) <= p.y <= Yi(dx0)) or p.z < ZMf(p.y): return 0.0 d = abs(p.x) - dlat * S return 5.0 + d if abs(d) < 0.30 else 0.0 # ------------------------------------------------------------------ the hull mesh: skin, frames, sleeves, nose tip, stern panel hull = MB("SM_Spearfish_Hull", [PAINT, SEAM, GRAPH]) MI_P, MI_G = hull.mi(PAINT), hull.mi(GRAPH) bm = bmesh.new() lay_r = bm.verts.layers.float.new("seam_ring") lay_l = bm.verts.layers.float.new("seam_long") V = {} def hv(ik): i, k = ik[0], ik[1] % M if (i, k) not in V: p = P((i, k)) v = bm.verts.new(p) v[lay_r] = seam_ring(p); v[lay_l] = seam_long(p) V[(i, k)] = v return V[(i, k)] for (i, k), role in ROLE.items(): if isinstance(role, tuple): continue f = bm.faces.new([hv(a) for a in ((i, k), (i, (k + 1) % M), (i + 1, (k + 1) % M), (i + 1, k))]) f.material_index = MI_G if role == "frame" else MI_P # sleeves: from each raised hole edge down to the lining (through the glass's edge) SLEEVE = {} for ia, ib, k0, k1, rim, glazed in HOLES: loop = hole_loop(ia, ib, k0, k1) rows = [[hv(ik) for ik in loop]] for d in (GLASS_D, LINER): row = [] for ik in loop: i, k = ik[0], ik[1] % M row.append(bm.verts.new(BASE[i][k] - NRM[i][k] * d)) rows.append(row) ring_rows(bm, rows, closed=True, mat_index=MI_G) SLEEVE[(ia, ib, k0, k1)] = loop # nose: a fan to the point (a cone tip) tipv = bm.verts.new((0, Y_NOSE, Zi(419.0))) for k in range(M): bm.faces.new((hv((0, (k + 1) % M)), hv((0, k)), tipv)) # stern: the last ring steps in and down into a recessed panel, filled by a quad grid iE = NR - 1 cen = Vector((0, YS[iE], ZMf(YS[iE]))) rE = [hv((iE, k)) for k in range(M)] rows = [rE] for sc_, dy in ((0.94, 0.0), (0.90, -0.045), (0.80, -0.045)): rows.append([bm.verts.new(cen + (v.co - cen) * sc_ + Vector((0, dy, 0))) for v in rE]) ring_rows(bm, rows[:3], closed=True, mat_index=MI_P) ring_rows(bm, rows[2:], closed=True, mat_index=MI_G) coons_cap(bm, rows[-1][::-1], MI_G) bm.normal_update() bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) # one connected surface: consistent winding top_f = max(bm.faces, key=lambda f: f.calc_center_median().z) if top_f.normal.z < 0: bmesh.ops.reverse_faces(bm, faces=bm.faces[:]) bm.normal_update() hull.merge(bm, None) REPORT["hull_stations"] = NR REPORT["hull_columns"] = M # ------------------------------------------------------------------ glass: each window's own grid, sunk to the sleeve glass = MB("SM_Spearfish_Glass", [GLASS]) bmg = bmesh.new() for ia, ib, k0, k1, rim, glazed in HOLES: if not glazed: continue G = {} def gv(i, k): if (i, k) not in G: G[(i, k)] = bmg.verts.new(BASE[i][k % M] - NRM[i][k % M] * GLASS_D) return G[(i, k)] for i in range(ia, ib): for k in range(k0, k1): bmg.faces.new((gv(i, k), gv(i, k + 1), gv(i + 1, k + 1), gv(i + 1, k))) bmg.normal_update() for f in bmg.faces: c = f.calc_center_median() if f.normal.dot(Vector((c.x, 0, c.z - ZMf(c.y)))) < 0: f.normal_flip() glass.merge(bmg, GLASS) # ------------------------------------------------------------------ cabin lining: the grid sunk 60 mm, from the nose to the rear bulkhead CAB_Y0, CAB_Y1 = Yi(1104.0), Yi(669.0) 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] cab = MB("SM_Spearfish_Cabin", [CAB, FLOOR, SEAT, SCREEN, CABGLOW, GRAPH, STEEL]) bml = bmesh.new() LV = {} def lv(i, k): k %= M if (i, k) not in LV: LV[(i, k)] = bml.verts.new(BASE[i][k] - NRM[i][k] * LINER) return LV[(i, k)] for i in range(i_c0, i_c1): for k in range(M): if isinstance(ROLE[(i, k)], tuple): continue bml.faces.new((lv(i, k), lv(i + 1, k), lv(i + 1, (k + 1) % M), lv(i, (k + 1) % M))) coons_cap(bml, [lv(i_c1, k) for k in range(M)]) coons_cap(bml, [lv(i_c0, k) for k in range(M)][::-1]) bml.normal_update() for f in bml.faces: # seen from inside: toward the axis line c = f.calc_center_median() ax = Vector((0, c.y, ZMf(min(max(c.y, Y_NOSE), Y_TAIL)))) if abs(f.normal.y) < 0.9 and f.normal.dot(ax - c) < 0: f.normal_flip() elif abs(f.normal.y) >= 0.9 and ((c.y > (CAB_Y0 + CAB_Y1) / 2) == (f.normal.y > 0)): f.normal_flip() cab.merge(bml, CAB) # canopy mullions: low bars across the glass, flush with the rim def mullion(a, b, sg): pts2 = resample([(Yi(a[0]), Zi(a[1])), (Yi(b[0]), Zi(b[1]))], 0.06, closed=False) Pp = [on_side(sg, y, z) for y, z in pts2] rows = [] for (w, h) in ((-0.014, -0.026), (-0.014, 0.006), (0.014, 0.006), (0.014, -0.026)): row = [] for j, p in enumerate(Pp): t = (Pp[min(j + 1, len(Pp) - 1)] - Pp[max(j - 1, 0)]).normalized() nrm = normal_at(p) bn = nrm.cross(t).normalized() row.append(p + bn * w + nrm * h) rows.append(row) grid_rings(frames, [list(r) for r in zip(*rows)], GRAPH, closed_u=False, expect=(len(Pp) // 2, 1, tuple(normal_at(Pp[len(Pp) // 2])))) frames = MB("SM_Spearfish_Frames", [GRAPH]) for sg in (1, -1): for a, b in MULLIONS: mullion(a, b, sg) # ------------------------------------------------------------------ cabin interior (seen through the canopy, the side windows, the hatch) ZF = -0.47 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))) SY = Yi(945.0) SEAT_TILT = math.radians(-28) qcyl(cab, 0.07, 0.10, (0, SY + 0.05, ZF + 0.05), STEEL) qbox(cab, (0.50, 0.52, 0.09), (0, SY, ZF + 0.15), SEAT, bevel=0.03) qbox(cab, (0.50, 0.10, 0.66), (0, SY + 0.40, ZF + 0.44), SEAT, rot=(SEAT_TILT, 0, 0), bevel=0.03) qbox(cab, (0.26, 0.08, 0.16), (0, SY + 0.57, ZF + 0.82), SEAT, rot=(SEAT_TILT, 0, 0), bevel=0.03) for s in (1, -1): qbox(cab, (0.07, 0.40, 0.06), (s * 0.29, SY + 0.04, ZF + 0.33), SEAT, bevel=0.015) qcyl(cab, 0.018, 0.12, (s * 0.29, SY - 0.14, ZF + 0.40), GRAPH, segs=8, r2=0.014) CYc = Yi(1003.0) qbox(cab, (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): qbox(cab, (0.17, 0.012, 0.13), (x, CYc + 0.07, ZF + 0.42), SCREEN, rot=(math.radians(30), 0, 0)) qbox(cab, (0.34, 0.30, 0.30), (0, CYc - 0.12, ZF + 0.15), GRAPH, bevel=0.02) CY2 = Yi(735.0) qbox(cab, (0.50, 0.50, 0.10), (0, CY2, ZF + 0.38), SEAT, bevel=0.03) qbox(cab, (0.50, 0.10, 0.62), (0, CY2 + 0.28, ZF + 0.70), SEAT, rot=(math.radians(-12), 0, 0), bevel=0.03) qbox(cab, (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) qbox(cab, (0.22, abs(y1 - y0), 0.55), (s * (w - 0.16), (y0 + y1) / 2, ZF + 0.30), GRAPH, bevel=0.02) 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, 0.012, CABGLOW, sides=6) qbox(cab, (0.62, 0.05, 1.00), (0, CAB_Y1 - 0.03, ZF + 0.52), GRAPH, bevel=0.02) qbox(cab, (0.54, 0.02, 0.92), (0, CAB_Y1 - 0.06, ZF + 0.52), STEEL, bevel=0.01) SIDE_OPENINGS = [P_CANOPY, P_SIDEWIN] OPENINGS = SIDE_OPENINGS REPORT["stage"] = "hull+glass+cabin"