"""Aethel-9: the sheet's numbers and the hull surface, in sheet pixels (pure numpy; used by the Blender build and the checks). Sheet: reference/user_blueprint.png (1150 x 773). Views: SIDE (port side, nose left) x 175-712, y 85-400; TOP (nose left, starboard up) x 150-680, y 455-773; FRONT (port on the right) centre x 964, y 85-400. The bottom-right view is a second front view (acoustic rings); it is not used. Scale: the sheet's 6.5 m runs from the claw tip (x 177) to the stern (x 697): 0.0125 m per pixel. The labels 3.1 m, 4.2 m and TOP's 4.8 m / 2.8 m disagree with the drawing by up to 10 %; the drawing's own proportions are kept. Pixel frame used here ("px"): X = lateral (+ port), v = SIDE x (nose 233 to stern 698), y = SIDE y (down). World (metres, the Kazah's axes): X = X_px * S, Y = (v - V0) * S (nose to -Y), Z = (Y0 - y) * S (Z = 0 on the keel). TOP is drawn shorter than SIDE (nose 228 to stern 657 against 229 to 698): TOP x maps to SIDE x by top_to_side(). Where the views disagree, SIDE rules; TOP and FRONT give only what SIDE cannot show (widths, the front face). """ import numpy as np S = 0.0125 # metres per sheet pixel V0 = 465.0 # SIDE x of the world origin (mid hull) Y0 = 363.0 # SIDE y of the keel (world Z = 0) FRONT_CX = 964.0 # FRONT's centre line TOP_CY = 609.0 # TOP's centre line def top_to_side(tx): return 229.0 + (np.asarray(tx, float) - 228.0) * (469.0 / 429.0) def to_world(X, v, y): return np.stack([np.asarray(X) * S, (np.asarray(v) - V0) * S, (Y0 - np.asarray(y)) * S], -1) def interp(table, x): t = np.asarray(table, float) return np.interp(x, t[:, 0], t[:, 1]) # ---- hull lines (SIDE x -> SIDE y), read from reference/mask_side.png and the 3x/4x zooms ------------------------------- V_TIP, V_END = 233.0, 660.0 # nose cap (under the lidar belt) and stern end (under the stern cap) TOPLINE = [(233, 225), (236, 220), (239, 215), (243, 210), (247, 205), (252, 200), (256, 195), (261, 190), (267, 185), (272, 180), (278, 175), (285, 170), (293, 165), (301, 160), (310, 155), (318, 150), (330, 145), (345, 141), (360, 137.5), (380, 135), (400, 133), (430, 132.5), (470, 133), (510, 134), (560, 136), (600, 139), (620, 142), (640, 147), (652, 153), (660, 157)] BOTLINE = [(233, 250), (236, 265), (240, 275), (245, 285), (250, 295), (255, 305), (261, 315), (264, 320), (270, 333), (280, 346), (290, 354), (300, 359), (315, 362.5), (330, 363), (540, 363), (560, 362), (580, 356), (600, 346), (613, 340), (621, 335), (627, 330), (632, 320), (637, 310), (640, 300), (645, 285), (650, 268), (654, 252), (657, 240), (659, 230), (660, 225)] # half-width from TOP (TOP x mapped to SIDE x), the hull without pods, arms and strips # half-width from TOP (TOP x mapped to SIDE x), the hull without pods, arms and strips; the nose made blunter in the # concept pass (2026-10-22): the concept's front is a broad face between the belt's lamp blocks (FRONT +-51.5 px) HALFW = [(233, 32), (236, 39), (240, 46), (246, 52.5), (254, 59), (265, 66), (280, 74), (295, 80), (310, 85), (330, 89.5), (362, 94), (390, 97.5), (417, 99.5), (450, 100), (530, 100), (560, 99), (581, 96), (608, 88), (619, 82.5), (636, 77), (650, 73), (660, 71)] HALFW_BLUEPRINT = [(233, 24), (237, 29), (245, 40), (253, 47), (265, 58), (280, 69), (295, 78), (308, 84), (330, 89.5)] # ---- section shape ------------------------------------------------------------------------------------------------------ # Concept pass (2026-10-22): the user's concept render shows a flat roof, a crisp chamfer down to near-vertical flanks and # a rounded belly (the first version followed FRONT's rounder egg). Control points on the port half from the bottom centre # to the top centre: (g = half-width / station half-width, t = 0 top .. 1 bottom, crease). The two creases (the chamfer's # lower and upper edges) are put exactly on grid columns by the phi mapping below, so they stay crisp. NJ_PROFILE = 112 PROFILE_PTS = [(0.0, 1.0, False), (0.40, 1.0, False), (0.62, 0.978, False), (0.79, 0.93, False), (0.895, 0.86, False), (0.95, 0.77, False), (0.975, 0.66, False), (0.985, 0.52, False), (0.982, 0.38, False), (0.972, 0.24, False), (0.958, 0.15, False), (0.950, 0.115, True), (0.580, 0.0, True), (0.0, 0.0, False)] PROFILE_TG = [(t, g) for g, t, _ in PROFILE_PTS[::-1]] # (t, g) from the top, for the analytic inside test PROF_W, PROF_H = 104.0, 222.0 # FRONT's widest half-width and height (px), to weigh the arc length PROFILE_CREASE_PHI = [] # filled below: phi of the creases on the port half def _profile_polyline(n=4001): """Port half of the section from the bottom centre (phi 0) to the top centre (phi 0.5): (g, t) sampled at uniform phi. Soft runs are rounded (Chaikin, ends kept); creases stay sharp and land on grid columns.""" P = PROFILE_PTS crease_idx = [k for k, p in enumerate(P) if p[2]] runs, k0 = [], 0 for kc in crease_idx + [len(P) - 1]: runs.append([(g, t) for g, t, _ in P[k0:kc + 1]]); k0 = kc dense, marks = [], [] for r in runs: pts = np.array(r, float) if len(pts) > 2: for _ in range(3): q = [pts[0]] for a, b in zip(pts[:-1], pts[1:]): q += [0.75 * a + 0.25 * b, 0.25 * a + 0.75 * b] q.append(pts[-1]) pts = np.array(q) if dense: pts = pts[1:] dense += [tuple(p) for p in pts] marks.append(len(dense) - 1) pts = np.array(dense) w = np.stack([pts[:, 0] * PROF_W, pts[:, 1] * PROF_H], 1) s = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(w, axis=0), axis=1))]) s /= s[-1] # creases on columns: the half has NJ_PROFILE/2 columns; map phi-half h -> arc s through the crease points half_cols = NJ_PROFILE // 2 hs, ss = [0.0], [0.0] for m in marks[:-1]: hs.append(round(s[m] * half_cols) / half_cols); ss.append(s[m]) hs.append(1.0); ss.append(1.0) PROFILE_CREASE_PHI[:] = [h * 0.5 for h in hs[1:-1]] h = np.linspace(0, 1, n) su = np.interp(h, hs, ss) return np.interp(su, s, pts[:, 0]), np.interp(su, s, pts[:, 1]) _PG, _PT = _profile_polyline() def profile(phi): """phi in [0,1): 0 bottom centre, 0.25 port, 0.5 top centre, 0.75 starboard. Returns (g signed, t).""" p = np.mod(np.asarray(phi, float), 1.0) half = np.where(p <= 0.5, p, 1.0 - p) * 2.0 # 0..1 along the port half k = half * (len(_PG) - 1) g = np.interp(k, np.arange(len(_PG)), _PG) t = np.interp(k, np.arange(len(_PT)), _PT) return np.where(p <= 0.5, g, -g), t def lines(v): return interp(TOPLINE, v), interp(BOTLINE, v), interp(HALFW, v) def surf(v, phi): """Hull surface in px: returns X (lateral, + port), v, y (SIDE y).""" yt, yb, hw = lines(v) g, t = profile(phi) return g * hw, np.asarray(v, float) + 0 * g, yt + t * (yb - yt) def surf_world(v, phi): X, vv, y = surf(v, phi) return to_world(X, vv, y) # ---- inverses: a sheet point on a view -> (v, phi) on the hull -------------------------------------------------------- _PORT_T = _PT[:len(_PT)] # t along the port half (monotonic from 1 to 0) def side_to_param(sx, sy): """SIDE point (port side) -> (v, phi): the outermost hull point at that x and y.""" sx = np.asarray(sx, float); sy = np.asarray(sy, float) yt, yb, hw = lines(sx) t = np.clip((sy - yt) / (yb - yt), 0, 1) # the port-most point with this t: on the port half, the side part (not the flat ends) half = np.linspace(0, 1, len(_PG)) gi = _PG # for each t, pick the arc position on the port half whose t matches and whose g is largest out = np.empty_like(t) for k, tk in np.ndenumerate(t): d = np.abs(_PT - tk) cand = np.nonzero(d < max(d.min() * 1.5, 2e-3))[0] j = cand[np.argmax(gi[cand])] out[k] = half[j] * 0.5 return sx, out def _zbuffer(view, nphi=5000, res=0.25): # fine stations over the steep nose (the lower face drops ~5 px per px of x), coarser aft; only the half of the # hull that can face the viewer (no back-side samples filling holes) if view == 'front': v = np.arange(V_TIP, 420, 0.04) phi = np.linspace(0, 1, nphi, endpoint=False) else: v = np.arange(V_TIP, V_END + 1e-9, 0.15) phi = np.linspace(0.25, 0.75, nphi // 2) V, P = np.meshgrid(v, phi, indexing='ij') X, _, Y = surf(V, P) if view == 'front': a, b, depth = X, Y, V # nearest = smallest v else: # top: nearest = smallest y (highest) a, b, depth = X, V, Y ia = np.round(a / res).astype(int); ib = np.round(b / res).astype(int) key = ia * 100000 + ib order = np.lexsort((depth.ravel(), key.ravel())) k = key.ravel()[order] first = np.concatenate([[True], k[1:] != k[:-1]]) sel = order[first] return {int(kk): (V.ravel()[s], P.ravel()[s]) for kk, s in zip(k[first], sel)}, res _ZB = {} def view_to_param(view, a, b): """FRONT (X px from the centre line, SIDE-frame y) or TOP (X px, SIDE x) -> (v, phi) of the nearest visible hull point.""" if view not in _ZB: _ZB[view] = _zbuffer(view) zb, res = _ZB[view] ia, ib = int(round(a / res)), int(round(b / res)) best, bd = None, 1e9 for r in range(0, 12): # the nearest filled cell (each cell already holds its nearest-to-viewer hull point) for da in range(-r, r + 1): for db in range(-r, r + 1): if max(abs(da), abs(db)) != r: continue hit = zb.get((ia + da) * 100000 + (ib + db)) if hit is not None and da * da + db * db < bd: best, bd = hit, da * da + db * db if best is not None: return best raise ValueError(f'{view} point {a},{b} is off the hull') # ---- windows and openings, as drawn ---------------------------------------------------------------------------------- # FRONT: x relative to the centre line (port +), y as on the sheet (FRONT's y matches SIDE's y within a few px) WIN_FRONT_UPPER = [(-28, 164), (28, 164), (28, 219), (-28, 219)] # rounded rect (4x zoom: 936-992.5, 163.75-218.75) WIN_FRONT_LOWER = [(-33, 254), (33, 254), (33, 317), (-33, 317)] # rounded rect (931-997.5, 251-317.5); top 3 px # under the belt's lower edge (drawn touching it) so the frame has room # SIDE (port): the raked upper side window and the lower nose window, outer frame line (reference/z4_swin.png) WIN_SIDE_UPPER = [(259, 211), (272, 189), (300, 169), (320, 160), (373, 156), (377, 160), (376, 182), (311, 214), (265, 218)] WIN_SIDE_LOWER = [(248, 255), (292, 251), (296, 262), (283, 307), (275, 311), (269, 307), (248, 262)] # TOP: the roof window (trapezoid, round front end), as SIDE x and X px (reference/z4_troof.png) WIN_ROOF = [(316, -21), (401, -33), (401, 33), (316, 21)] HATCH = (466.0, 0.0, 18.0) # SIDE x of the centre, X, radius px (SIDE dome x 450-487; TOP ring r 20) # ---- parts (px) ----------------------------------------------------------------------------------------------------- BELT = dict(v0=V_TIP, v1=265.0, y0=226.0, y1=250.0) # the nose's lidar belt (SIDE 232-265 x 226-250; FRONT +-62) NOSE_LAMPS = dict(X=51.5, y=238.0, w=23, h=27, lamps=(231.5, 244.5), r=4.6) # FRONT 901-924 x 225-252, two lamps each NOSE_DISPLAY = dict(w=31.5, h=15.0, y=237.5) # FRONT 947.5-979 x 230-245 CHIN = dict(v0=262, v1=300, y0=328, y1=356, w=28, lamps=(-12.5, 12.5), r=7.5) # FRONT 935-992 x 328-355 SIDE_LAMP = dict(v0=325, v1=378, y0=238, y1=266, X_out=110.0, lamp_r=11.0) # SIDE pill; FRONT lamp at +-107, y 250 NACELLE = dict(X=111.0, y=324.0, r=38.0, v_duct0=312.0, v_duct1=345.0, v_box=395.0, v_box_end=548.0, v_tail=600.0, box_top=262.0, box_bot=366.0, box_in=72.0, box_out=150.0, tail_r=18.0, tail_y=336.0, tail_X=104.0) POD = dict(X=128.0, y=270.0, r=31.0, v0=548.0, v1=608.0, fair_bot=330.0) # upper thrusters: FRONT +-126 y 261; SIDE box # the upper thruster's wing (concept pass): root inside the flank under the lower strip, tip on the nacelle's top WING = dict(root_X=86.0, root_v=565.5, root_y=221.0, root_chord=59.0, root_th=6.0, tip_v=571.0, tip_y=239.5, tip_chord=30.0, tip_th=4.0) FIN = dict(root=((513, 137), (598, 137)), tip=((577, 96), (600, 96)), t_root=15.0, t_tip=6.0) # TOP: +-10 px ROOF_BLOCK = dict(v0=401.0, v1=434.5, w=33.0, h=5.0) STERN_CAP = dict(v0=656.0, v1=698.0, y0=157.0, y1=212.0, w0=66.0, w1=58.0) # the arm: SIDE draws the shoulder ring at (288, 337), elbow (252, 352), wrist (213, 366), claw to x 190; the concept's arm, # back-projected through its fitted camera onto the arm's plane, gives (297, 336), (231, 356), (201, 360), x 176: the # concept's longer upper arm and shorter forearm are taken (2026-10-22) ARM = dict(joint=(47.0, 276.0, 338.0), shoulder=(276.0, 338.0), X=57.0, r_sh=14.0, elbow=(233.0, 355.0), wrist=(203.0, 361.0), tip=(178.0, 364.0)) # light strips: SIDE polylines on the port flank (S1 starts on the roof, given in TOP terms as (SIDE x, X)) STRIP_S1_ROOF = [(401, 44), (407, 62), (411, 80), (414, 92)] STRIP_S1_SIDE = [(412, 165), (415, 185), (418, 205), (420, 211)] STRIP_S2 = [(311, 262), (310, 240), (311, 232), (317, 228), (387, 201), (418, 213), (480, 211.5), (545, 210)] # lettering on both flanks (SIDE boxes) TEXT_NAME = dict(text='AETHEL-9', box=(431, 156.5, 561, 168.5)) # glyph box, reference/z4_seams.png TEXT_SUB = dict(text='TERRAFORMING UNIT 02', box=(431, 172.5, 564, 178.5)) FIN_LOGO = dict(apex=(565.0, 101.5), base=((550.5, 126.0), (580.0, 126.0)), inner=0.45) # the fin's triangle mark