""" Checks the built interior against the ship (run on the saved fork, nothing is written to it): blender -b --factory-startup models/M_Ship_v2_interior_season2.blend --python build/check_interior.py 1. every vertex of everything under "10 INTERIOR | Season 2 layout" lies inside the shell (hull, or the dock room's lining between its two ends); 2. the carousel can turn a full circle with three stowed Tenders: least distance to the shell, the gallery, the stair and the two bulkheads; 3. where the launch Tender hangs relative to the well; 4. the parts made for this hull, edge by edge, against the ship's outer skin; the roof pod's edge against the hull's skin; the bridge's own shell (ceiling whole in front of the aft bulkhead, no trim curve under the skin inside the bridge); 5. the control room corridor's floor (level with the sills, nothing left under it) and the two quarters beside it (how far inside the hull, the meshes made for them, no furniture in the lining); the captain's cabin that was ported in (inside the hull, on the corridor door's axis, clear of the corridor's pieces) and the two bays that carry it to the room's ends (inside the hull, no part crossing another part or anything else in the ship). Writes build/check_report.json and prints a summary. What-if for the carousel's height: set the environment variable CAROUSEL_RAISE (metres) and only the carousel checks run, with the hub that much higher than it is in the file (the launch Tender kept at its place by lowering its hoist that much further). Nothing is written then; the numbers are printed. """ import json import math import os import sys import bpy import numpy as np from mathutils import Vector sys.dont_write_bytecode = True HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import shipfit # noqa: E402 import bridge_shell # noqa: E402 import carousel_rigged # noqa: E402 import captains_cabin # noqa: E402 import captains_look # noqa: E402 import captains_suite # noqa: E402 import corridor_level # noqa: E402 import fore_deck # noqa: E402 import lower_deck # noqa: E402 import boarding_gate # noqa: E402 import door_arms # noqa: E402 import wall_props # noqa: E402 import quarters # noqa: E402 import reactor_room # noqa: E402 TOP = "10 INTERIOR | Season 2 layout" RAISE = float(os.environ.get("CAROUSEL_RAISE", "0") or 0.0) # what-if: the carousel's hub this much higher STUDY = RAISE != 0.0 UP = np.array([0.0, 0.0, RAISE]) X_AFT, X_FORE = -9.99, 15.51 dg = bpy.context.evaluated_depsgraph_get() fit = shipfit.Fit(["M_SHIP_Hull", "Moonpool | hull lining", "M_SHIP_Dorsal_Roof", "M_SHIP_Canopy"]) OUTER = ("M_SHIP_Hull", "M_SHIP_Dorsal_Roof", "M_SHIP_Canopy") LINING = ("Moonpool | hull lining",) # ---- the shell as a table: for (x, z) the port and starboard limits --------------------------------------------- XS = np.arange(-60.0, 60.01, 0.5) ZS = np.arange(-11.0, 11.01, 0.25) def table(only): lo = np.full((len(XS), len(ZS)), np.nan) hi = np.full((len(XS), len(ZS)), np.nan) for i, x in enumerate(XS): for j, z in enumerate(ZS): a = fit.side_y(float(x), float(z), -1, only) b = fit.side_y(float(x), float(z), 1, only) if a is not None and b is not None and a < 0 < b: lo[i, j], hi[i, j] = a, b return lo, hi LO_H, HI_H = table(OUTER) LO_L, HI_L = table(LINING) def margins(p): """p: (n, 3) world points -> signed distance to the shell sideways (positive = inside), nan where no shell.""" x, y, z = p[:, 0], p[:, 1], p[:, 2] fi = np.clip((x - XS[0]) / 0.5, 0, len(XS) - 1.001) fj = np.clip((z - ZS[0]) / 0.25, 0, len(ZS) - 1.001) i0, j0 = fi.astype(int), fj.astype(int) tx, tz = fi - i0, fj - j0 in_bay = (x > X_AFT + 0.55) & (x < X_FORE - 0.55) # (the table is 0.5 m apart; at the two ends the hull stands in) def lerp(t): return ((t[i0, j0] * (1 - tx) + t[i0 + 1, j0] * tx) * (1 - tz) + (t[i0, j0 + 1] * (1 - tx) + t[i0 + 1, j0 + 1] * tx) * tz) lo = np.where(in_bay, lerp(LO_L), lerp(LO_H)) hi = np.where(in_bay, lerp(HI_L), lerp(HI_H)) return np.minimum(y - lo, hi - y) def world_verts(ob, posed=False): """World positions of a mesh object's vertices. posed=True: as the object stands in the file with its modifiers (the rigged carousel's parts are skinned: their own vertices are the stowed rest pose).""" if posed: ev = ob.evaluated_get(dg) me = ev.to_mesh() else: me = ob.data co = np.empty(len(me.vertices) * 3, dtype=np.float64) me.vertices.foreach_get("co", co) if posed: ev.to_mesh_clear() m = np.array(ob.matrix_world) return co.reshape(-1, 3) @ m[:3, :3].T + m[:3, 3] ARM_MESHES = ("SM_DockArm", "SK_DockArm") # the carousel's parts: unrigged model / rigged model report = {} top = bpy.data.collections[TOP] objs = [o for o in top.all_objects if o.type == "MESH" and not o.data.name.startswith(("DECAL_", "WGT_"))] worst = [] n_out = 0 for ob in ([] if STUDY else objs): if ob.name.startswith(("Bulkhead |", "Carousel pylon", "Ledge |")): continue # these are fitted to the shell and meant to touch it v = world_verts(ob) if len(v) == 0: continue m = margins(v) bad = np.nan_to_num(m, nan=-9.0) < -0.03 if bad.any(): n_out += 1 k = int(np.nanargmin(np.where(bad, np.nan_to_num(m, nan=-9.0), np.nan))) worst.append({"object": ob.name, "mesh": ob.data.name, "zone": ob.get("zone", ""), "out_by_m": round(float(-np.nan_to_num(m, nan=-9.0)[k]), 3), "at": [round(float(c), 2) for c in v[k]], "verts_out": int(bad.sum())}) worst.sort(key=lambda r: -r["out_by_m"]) report["outside_shell"] = {"objects_checked": len(objs), "objects_outside": n_out, "worst": worst[:25]} # ---- the parts made for this hull, edge by edge --------------------------------------------------------------------- # The test above looks at vertices only and leaves out the parts that are fitted to the shell. On 2026-10-12 the # bridge's aft bulkhead had every vertex inside and still stood 20 cm outside the ship: a straight edge between # two of its rows cut across the hollow corner where the roof pod meets the hull. So every edge of these parts is # sampled every 5 cm against the ship's outer skin (hull and roof pod as one). env = shipfit.Envelope() own = [] if STUDY else [o for o in objs if not o.get("ue_mesh") and not o.data.name.startswith(("SM_wlombbzdw",) + ARM_MESHES)] rows, n_bad = [], 0 for ob in own: r = shipfit.check_inside(ob, env, 0.05, 0.0) if r["outside"]: n_bad += 1 rows.append({"object": ob.name, "samples": r["samples"], "outside": r["outside"], "furthest_outside_m": r["furthest_outside_m"], "where": r["outside_sample"][:3], "least_distance_to_the_outer_skin_m": r["least_clearance_m"]}) rows.sort(key=lambda r: (-r["outside"], r["least_distance_to_the_outer_skin_m"])) report["own_parts_edge_by_edge"] = {"parts": len(own), "parts_with_a_point_outside_the_ship": n_bad, "rows": rows} # the roof pod itself: how far does anything of it reach under the hull's skin (inside the ship), and does any of # its edge stand above the skin (a gap seen from outside)? Against the hull as it is drawn (env's trees are # shipfit.drawn_tree: the first cut of the pod was measured against the hull's polygons and stood up to 3 mm above # the drawn skin in twenty places). pod = bpy.data.objects.get("M_SHIP_Dorsal_Roof") hull_tree = env.trees.get("hull") if pod is not None and hull_tree is not None and not STUDY: deep, n_deep = 0.0, 0 for v in pod.data.vertices: q = pod.matrix_world @ v.co loc, nrm, idx, dd = hull_tree.find_nearest(q) s = (q - loc).dot(nrm) if s < -0.0065: n_deep += 1 deep = min(deep, s) use = {} for p in pod.data.polygons: for k in p.edge_keys: use[k] = use.get(k, 0) + 1 along = [] for (a, b), c in use.items(): if c != 1: continue pa, pb = pod.matrix_world @ pod.data.vertices[a].co, pod.matrix_world @ pod.data.vertices[b].co for j in range(9): q = pa.lerp(pb, j / 8.0) loc, nrm, idx, dd = hull_tree.find_nearest(q) along.append((q - loc).dot(nrm)) report["roof_pod_under_the_hull_skin"] = {"mesh": pod.data.name, "vertices": len(pod.data.vertices), "deepest_m": round(-deep, 4), "vertices_more_than_6.5_mm_under": n_deep, "modifiers": [m.name for m in pod.modifiers], "edge_samples": len(along), "edge_samples_more_than_0.2_mm_above_the_skin": sum(1 for s in along if s > 0.0002), "edge_highest_mm": round(max(along) * 1000, 2) if along else None} # ---- the bridge's own shell seen from inside (bridge_shell.py) ----------------------------------------------------- hull_ob = bpy.data.objects.get("M_SHIP_Hull") if hull_ob is not None and not STUDY: rec = {} mask = hull_ob.modifiers.get("LAYOUT mask pod opening") if mask is not None: gi = hull_ob.vertex_groups[mask.vertex_group].index hm = hull_ob.data ing = set(v.index for v in hm.vertices for g in v.groups if g.group == gi and g.weight > mask.threshold) hid = [p for p in hm.polygons if any(i in ing for i in p.vertices)] rec["hull_faces_hidden_under_the_pod"] = len(hid) rec["hidden_strip_reaches_x"] = round(max((hull_ob.matrix_world @ hm.vertices[i].co).x for p in hid for i in p.vertices), 3) if hid else None # the ceiling in front of the aft bulkhead (its bridge face is at x 31.01): rays straight up from the bridge shown = shipfit.drawn_tree(hull_ob, evaluated=True) n_rays, miss = 0, 0 for i in range(25): for j in range(-41, 42): n_rays += 1 if shown.ray_cast(Vector((31.02 + i * 0.05, j * 0.1, 5.0)), Vector((0, 0, 1)), 6.0)[0] is None: miss += 1 rec["ceiling_rays_x31.02_to_32.22"] = n_rays rec["ceiling_rays_that_meet_no_hull"] = miss # every trim curve of the ship: is anything of it under the hull's skin inside the bridge? asset = bpy.data.collections.get("01 ASSET | M-Ship v2") inside = {} if asset is not None: for ob in asset.all_objects: if ob.type != "CURVE": continue r = bridge_shell.under_the_skin(ob, hull_tree, region=bridge_shell.in_bridge) if r["under_the_skin"]: inside[ob.name] = {"samples_under_the_skin": r["under_the_skin"], "deepest_mm": r["deepest_mm"], "at": r["at"], "seated": any(m.type == "SHRINKWRAP" for m in ob.modifiers)} rec["trim_curves_under_the_skin_in_the_bridge"] = inside rec["trims_seated_on_the_skin"] = sorted(o.name for o in bpy.data.objects if o.type == "CURVE" and o.modifiers.get(bridge_shell.SEAT)) report["bridge_shell"] = rec # ---- the control room corridor's floor and the two quarters (corridor_level.py, quarters.py) ---------------------- if not STUDY: floor = corridor_level.state() floor["still_below_the_floor"] = corridor_level.below_the_floor() report["control_room_corridor_floor"] = floor report["quarters"] = quarters.check() report["captains_cabin"] = captains_cabin.check() report["captains_suite"] = captains_suite.check() report["captains_look"] = captains_look.check() report["reactor_room"] = reactor_room.check() report["fore_deck"] = fore_deck.check() report["lower_deck"] = lower_deck.check() report["boarding_gate"] = boarding_gate.check() report["door_arms"] = door_arms.check() report["wall_props"] = wall_props.check() # ---- the carousel sweep ------------------------------------------------------------------------------------------- arm_root = next((o for o in bpy.data.objects if o.name.startswith("DockArm_Root")), None) tend = sorted([o for o in bpy.data.objects if o.name.startswith("_Tender")], key=lambda o: o.name) if arm_root is not None and tend: hub = np.array(arm_root.matrix_world.translation) + UP kz = tend[0].instance_collection pts = [] for o in kz.objects: if o.type == "MESH": pts.append(world_verts(o)[::3]) kz_pts = np.concatenate(pts) # the Tender in its own file's frame report["tender_bounds_own_frame"] = [[round(float(v), 2) for v in kz_pts.min(0)], [round(float(v), 2) for v in kz_pts.max(0)]] gal = bpy.data.objects.get("Gallery | command deck landing") gbb = world_verts(gal) if gal else None stair = [o for o in top.all_objects if o.type == "MESH" and o.data.name.startswith("SM_wlombbzdw")] sv = np.concatenate([world_verts(o)[::7] for o in stair]) if stair else None res = {"min_side_margin_m": 9.0, "min_roof_margin_m": 9.0, "min_to_gallery_m": 9.0, "min_to_stair_m": 9.0, "min_to_aft_bulkhead_m": 9.0, "min_to_fore_bulkhead_m": 9.0, "min_height_over_deck_m": 9.0} where = {} def tender_points(k, ang_deg, lower): th = math.radians(-90.0 - 120.0 * k + ang_deg) rz = math.radians(90.0 - 120.0 * k + ang_deg) c, s = math.cos(rz), math.sin(rz) r = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) t = hub + np.array([6.0 * math.cos(th), 6.0 * math.sin(th), -1.0 - lower]) return kz_pts @ r.T + t for ang in range(0, 360, 2): for k in range(3): p = tender_points(k, ang, 0.0) m = margins(p) mm = float(np.nanmin(m)) if mm < res["min_side_margin_m"]: res["min_side_margin_m"] = mm where["side"] = [ang, k + 1, [round(float(c), 2) for c in p[int(np.nanargmin(m))]]] # roof: straight up from the highest points hi_i = np.argsort(p[:, 2])[-40:] for q in p[hi_i][::8]: cz = fit.ceil_z(float(q[0]), float(q[1]), float(q[2]) - 0.01, LINING) if cz is not None and cz - q[2] < res["min_roof_margin_m"]: res["min_roof_margin_m"] = float(cz - q[2]) where["roof"] = [ang, k + 1, [round(float(c), 2) for c in q]] res["min_to_aft_bulkhead_m"] = min(res["min_to_aft_bulkhead_m"], float(p[:, 0].min() - (X_AFT + 0.30))) res["min_to_fore_bulkhead_m"] = min(res["min_to_fore_bulkhead_m"], float((X_FORE - 0.30) - p[:, 0].max())) res["min_height_over_deck_m"] = min(res["min_height_over_deck_m"], float(p[:, 2].min() - (-3.36))) if gbb is not None: g0, g1 = gbb.min(0), gbb.max(0) g1 = g1 + np.array([0, 0, 0.85]) # with its rails d = np.maximum(np.maximum(g0 - p, p - g1), 0.0) res["min_to_gallery_m"] = min(res["min_to_gallery_m"], float(np.sqrt((d ** 2).sum(1)).min())) if sv is not None: near = p[(p[:, 0] > sv[:, 0].min() - 1.5) & (p[:, 1] > sv[:, 1].min() - 1.5)] if len(near): step = max(1, len(near) // 400) for q in near[::step]: dd = float(np.sqrt(((sv - q) ** 2).sum(1)).min()) if dd < res["min_to_stair_m"]: res["min_to_stair_m"] = dd report["carousel_sweep_three_tenders_stowed"] = {k: round(v, 3) for k, v in res.items()} report["carousel_sweep_where"] = where # the launch Tender against the well (deck opening x -3.10..9.20, y -6.81..-0.51 at its widest) board = float(arm_root.get("board_lower_m", 5.3)) + RAISE # how far the launch arm's hoist is let down report["hoist_let_down_at_board_m"] = round(board, 3) launch = bpy.data.objects.get("_Tender1") if launch is not None and not STUDY: # the launch Tender where the rig holds it in the file (the launch arm reaches in since 2026-10-13, so it no # longer hangs straight under the boom tip) lm = np.array(launch.matrix_world) p = kz_pts @ lm[:3, :3].T + lm[:3, 3] try: report["launch_tender_in_the_well"] = carousel_rigged.launch_fit() except Exception as exc: # noqa: BLE001 report["launch_tender_in_the_well"] = "not measured: %s" % exc rig = bpy.data.objects.get("DockArm_Rig") if rig is not None and rig.pose.bones.get("CTRL_Arm1_Hoist") is not None: hb = rig.pose.bones["CTRL_Arm1_Hoist"] report["launch_arm"] = {"lowered_m": round(hb.location.y, 3), "reach_in_m": round(float(hb.get(carousel_rigged.REACH_PROP, 0.0)), 3), "joints_deg": carousel_rigged.joints(rig, 1)} else: p = tender_points(0, 0.0, board) LAUNCH_PTS = p report["launch_tender_at_board"] = {"x": [round(float(p[:, 0].min()), 2), round(float(p[:, 0].max()), 2)], "y": [round(float(p[:, 1].min()), 2), round(float(p[:, 1].max()), 2)], "lowest_z": round(float(p[:, 2].min()), 2), "well_y_at_its_middle": [-6.81, -0.51], "outboard_of_well_edge_m": round(float(-6.81 - p[:, 1].min()), 2)} # ---- the arm itself (turret, booms, links, heads as posed in the file) turned through a full circle ---------------- if arm_root is not None: hub = np.array(arm_root.matrix_world.translation) parts = [o for o in top.all_objects if o.type == "MESH" and o.data.name.startswith(ARM_MESHES) and "Mount" not in o.data.name] if parts: av = np.concatenate([world_verts(o, posed=True)[::2] for o in parts]) - hub side, roofm = 9.0, 9.0 for ang in range(0, 360, 3): c, s = math.cos(math.radians(ang)), math.sin(math.radians(ang)) p = av @ np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]).T + hub + UP side = min(side, float(np.nanmin(margins(p)))) for q in p[np.argsort(p[:, 2])[-600:]][::40]: cz = fit.ceil_z(float(q[0]), float(q[1]), float(q[2]) - 0.01, LINING) if cz is not None: roofm = min(roofm, float(cz - q[2])) report["arm_sweep"] = {"parts": len(parts), "min_side_margin_m": round(side, 3), "min_roof_margin_m": round(roofm, 3)} # ---- the sub bay's straight lining against the carousel ----------------------------------------------------------------- lin = bpy.data.collections.get("10.1 | Sub bay | straight lining") if lin is not None and arm_root is not None: hub = np.array(arm_root.matrix_world.translation, dtype=np.float64) + UP meshes = [o for o in lin.all_objects if o.type == "MESH" and not o.data.name.startswith("DECAL_")] lv = np.concatenate([world_verts(o) for o in meshes]) # what stands up from the deck on the starboard side (not the foot strip, not the cap ledge, not the lintels) stand = np.concatenate([world_verts(o) for o in meshes if not o.name.startswith("Ledge |") and "lintel" not in o.name]) stand = stand[(stand[:, 2] > -3.36 + 0.05)] top_z = float(lv[:, 2].max()) rec = {"objects": len(meshes), "top_z": round(top_z, 3), "clear_width_between_the_long_walls_m": round(float(2 * np.abs(stand[np.abs(stand[:, 1]) > 7.5][:, 1]).min()), 3)} if tend: z_low = float(hub[2] - 1.0 + kz_pts[:, 2].min()) rec["stowed_tender_lowest_z"] = round(z_low, 3) rec["stowed_tenders_pass_above_the_cap_by_m"] = round(z_low - top_z, 3) p = LAUNCH_PTS # the launch Tender at BOARD, where it hangs in the file near = stand[(stand[:, 0] > p[:, 0].min() - 0.5) & (stand[:, 0] < p[:, 0].max() + 0.5) & (stand[:, 1] < -7.5)] # lowering the launch arm: the Tender keeps its outline in plan, so this distance holds all the way down rec["launch_tender_to_starboard_wall_m"] = round(float(p[:, 1].min() - near[:, 1].max()), 3) arm_parts = [o for o in top.all_objects if o.type == "MESH" and o.data.name.startswith(ARM_MESHES) and "Mount" not in o.data.name] if arm_parts: av = np.concatenate([world_verts(o, posed=True) for o in arm_parts]) low = av[av[:, 2] < top_z + 0.3] # the lowered launch arm's parts at the lining's height if len(low): near = stand[(stand[:, 0] > low[:, 0].min() - 0.5) & (stand[:, 0] < low[:, 0].max() + 0.5) & (stand[:, 1] < -7.5)] rec["lowered_arm_to_starboard_wall_m"] = round(float(low[:, 1].min() - near[:, 1].max()), 3) report["straight_lining_vs_carousel"] = rec if STUDY: report["what_if_carousel_raise_m"] = RAISE else: with open(os.path.join(HERE, "check_report.json"), "w", encoding="utf-8") as f: json.dump(report, f, indent=1, default=float) print("CHECK " + json.dumps(report, default=float))