""" 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. Writes build/check_report.json and prints a summary. """ 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 TOP = "10 INTERIOR | Season 2 layout" 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): me = ob.data co = np.empty(len(me.vertices) * 3, dtype=np.float64) me.vertices.foreach_get("co", co) m = np.array(ob.matrix_world) return co.reshape(-1, 3) @ m[:3, :3].T + m[:3, 3] 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_")] worst = [] n_out = 0 for ob in objs: if ob.name.startswith(("Bulkhead |", "Carousel pylon")): 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 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) 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) p = tender_points(0, 0.0, 5.3) 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("SM_DockArm") and "Mount" not in o.data.name] if parts: av = np.concatenate([world_verts(o)[::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 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)} with open(os.path.join(HERE, "check_report.json"), "w", encoding="utf-8") as f: json.dump(report, f, indent=1) print("CHECK " + json.dumps(report))