"""Install the door robot arm, version B (neck + plate), into the ship: models/M_Ship_v2_interior_season2.blend. The user, 2026-10-18, with a screenshot of version B in the showroom: "ok now install this version of the arm to the ship, you can change the rail system with this try to fit it accordingly how I described you before" (before: one arm per moon pool door, "Moonpool_Door_Fore" / "Moonpool_Door_Aft", following the doors' own open/close animation, on the rail). apply(), in the ship file (run headless on a copy first: build/run_ship_install.py): - appends from DoorRobotArm.blend: version B's two arms and door fixtures, their solver and cable-hook empties, the two rail kits (running flanges, rack, buffers, chain trays, 44 chain links each); - points every driver at the ship's own doors (append brings copies of the reference doors; they are remapped to the ship's doors and deleted) and drops the version switch's hide drivers (only B is installed, so it is always shown); - the rail system: the user's two plain rail boxes (`Pocket AFT/FORE | upper rail`) are hidden, not deleted, and replaced by the arm's rail beam in the kit's dark steel ("change the rail system with this"). Since the scale-up of 2026-10-18 the beam comes from DoorRobotArm.blend (`DoorArm_ | rail beam`): it runs on the new rail line (y -3.80, 2.0 m inboard of the user's boxes), 1.6x their section, its top on the deck's underside, from the pocket's end wall to 30 mm short of the deck's edge at the moon pool; - the earlier flush-hinge door arms (collection `02 MECHANISM | Starboard pocket doors | door arms`, another session's) ride the same rails: that collection is excluded from the view layers, not deleted; - collections under `02 MECHANISM | Starboard pocket doors`: `... | robot door arms (version B)` with `... | arms`, `... | solver (hidden)` (eye off) and `... | rail system`; image paths relative to the ship file. remove() undoes it from the record (text block RECORD). check() runs the doors' timeline and checks the arms. """ import json import math import os import re import sys import bpy from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) ARM_FILE = os.path.normpath(os.path.join(HERE, "..", "DoorRobotArm.blend")) VI_BUILD = os.path.normpath(os.path.join(HERE, "..", "..", "vessel-interior", "build")) RECORD = "ROBOT DOOR ARMS | what was added" TAG = "robot_door_arms" SCENE = "M-Ship v2 | REFERENCE ASSET" PARENT_COLL = "02 MECHANISM | Starboard pocket doors" COLL = PARENT_COLL + " | robot door arms (version B)" SUB = {"arms": COLL + " | arms", "solver": COLL + " | solver (hidden)", "rail": COLL + " | rail system"} OLD_ARMS_COLL = PARENT_COLL + " | door arms" ROOT = "M_SHIP_ROOT" DOORS = {"AFT": "Moonpool_Door_Aft", "FORE": "Moonpool_Door_Fore"} RAILS = {"AFT": "Pocket AFT | upper rail", "FORE": "Pocket FORE | upper rail"} ENDS = {"AFT": "Pocket AFT | conformal closed end", "FORE": "Pocket FORE | conformal closed end"} DECK = "Moonpool | deck around open well" PLACEHOLDERS = ("Pocket AFT | rail carriage", "Pocket AFT | rail carriage.001", "Pocket FORE | rail carriage", "Pocket FORE | rail carriage.001") BEAM_MAT = "M_DoorArmKit_Gunmetal" CONTROLS = "DoorArm | CONTROLS" SUFFIX = re.compile(r"\.\d{3}$") KINDS = ("meshes", "curves", "materials", "images", "actions", "node_groups", "cameras", "lights", "objects", "collections") def _want(name): """which of our collections an object of the arm file goes to, or None""" for d in ("AFT", "FORE"): if name == "DoorArm_%s | door fixture B" % d: return "arms" if name in ("DoorArm_%s | chain solver" % d, "DoorArm_%s | rail solver" % d): return "solver" if name.startswith("DoorArm_%s_B | IK" % d) or name.startswith("DoorArm_%s_B | hook " % d): return "solver" if name == "DoorArm_%s_B" % d or name.startswith("DoorArm_%s_B | " % d): return "arms" if name in ("DoorArm_%s | rail kit" % d, "DoorArm_%s | rail beam" % d, "DoorArm_%s | chain tray" % d) or \ name.startswith("DoorArm_%s | chain link" % d): return "rail" return None def exists(): return RECORD in bpy.data.texts def record(): return json.loads(bpy.data.texts[RECORD].as_string()) if exists() else None def _layer_collections(lc): yield lc for ch in lc.children: yield from _layer_collections(ch) def _find_lc(view_layer, name): for lc in _layer_collections(view_layer.layer_collection): if lc.collection.name == name: return lc return None def _depsgraph(scene): vl = scene.view_layers[0] dg = vl.depsgraph dg.update() return vl, dg def _uv_box(me): for nm in ("UVMap", "UV_Tile"): if nm not in me.uv_layers: me.uv_layers.new(name=nm) for nm in ("UVMap", "UV_Tile"): uv = me.uv_layers[nm] for p in me.polygons: n = p.normal ax = max(range(3), key=lambda i: abs(n[i])) for li in p.loop_indices: co = me.vertices[me.loops[li].vertex_index].co uv.data[li].uv = ((co.y, co.z) if ax == 0 else ((co.x, co.z) if ax == 1 else (co.x, co.y))) def apply(): if exists(): raise RuntimeError("already installed (text block %r); remove() takes it out" % RECORD) scene = bpy.data.scenes[SCENE] root = bpy.data.objects[ROOT] assert all(abs(root.matrix_world[i][j] - (1.0 if i == j else 0.0)) < 1e-9 for i in range(4) for j in range(4)), \ "M_SHIP_ROOT is not the identity" for n in list(DOORS.values()) + list(RAILS.values()) + list(ENDS.values()) + [DECK]: if bpy.data.objects.get(n) is None: raise RuntimeError("not in this file: " + n) for n in (PARENT_COLL, OLD_ARMS_COLL): if bpy.data.collections.get(n) is None: raise RuntimeError("no collection " + n) if not os.path.exists(ARM_FILE): raise RuntimeError("missing " + ARM_FILE) before = {k: set(getattr(bpy.data, k).keys()) for k in KINDS} img_before = {} for im in bpy.data.images: if im.source == 'FILE' and im.filepath: img_before[os.path.normcase(os.path.normpath(bpy.path.abspath(im.filepath)))] = im.name rec = {"added": {"objects": [], "collections": [], "data": {}}, "hidden": [], "excluded": [], "remapped": [], "drivers_removed": 0, "images": {}, "arm_file": ARM_FILE, "version": "B", "deleted_copies": []} # 1. append -------------------------------------------------------------------------------------------------------- with bpy.data.libraries.load(ARM_FILE, link=False) as (src, dst): wanted = [n for n in src.objects if _want(n)] dst.objects = list(wanted) got = {} for o in dst.objects: if o is not None: got[o.name] = o if len(got) != len(wanted): raise RuntimeError("appended %d of %d objects" % (len(got), len(wanted))) if any(n not in got for n in wanted): raise RuntimeError("an appended object was renamed (a name clash in the ship): %s" % [n for n in wanted if n not in got][:5]) # 2. the copies the append pulled in (driver targets, parents): ship ones are remapped, the rest deleted copies = [o for o in bpy.data.objects if o.name not in before["objects"] and o.name not in got] ctl_copies = set() for o in copies: base = SUFFIX.sub("", o.name) if base == CONTROLS: ctl_copies.add(o.name) continue if base in before["objects"]: o.user_remap(bpy.data.objects[base]) rec["remapped"].append([o.name, base]) # the version switch: only B is installed, so the hide drivers that read it go and B is always shown for o in got.values(): ad = o.animation_data if ad is None: continue kill = [] for fc in ad.drivers: if any(t.id is not None and t.id.name in ctl_copies for v in fc.driver.variables for t in v.targets): kill.append((fc.data_path, fc.array_index)) for path, idx in kill: if path in ("hide_viewport", "hide_render", "hide_select"): o.driver_remove(path) else: raise RuntimeError("%s: a driver on %s reads the version switch" % (o.name, path)) rec["drivers_removed"] += 1 o.hide_viewport = False o.hide_render = False for o in list(copies): if o.users_scene: raise RuntimeError("an appended copy is linked into a scene: " + o.name) rec["deleted_copies"].append(o.name) bpy.data.objects.remove(o, do_unlink=True) for _ in range(4): for kind in ("meshes", "curves", "actions", "materials", "images", "node_groups", "cameras", "lights"): coll = getattr(bpy.data, kind) for idb in list(coll): if idb.name not in before[kind] and idb.users == 0: coll.remove(idb) # 3. images: the same file as one the ship has -> use the ship's; the rest relative to the ship file --------------- arm_dir = os.path.dirname(ARM_FILE) for im in list(bpy.data.images): if im.name in before["images"] or im.source != 'FILE' or not im.filepath: continue p = im.filepath cand = [] if p.startswith("//"): cand.append(os.path.join(arm_dir, p[2:].replace("\\", os.sep).replace("/", os.sep))) cand.append(bpy.path.abspath(p)) real = next((os.path.normpath(c) for c in cand if os.path.isfile(c)), None) if real is None: raise RuntimeError("image file not found: %s (%s)" % (im.name, p)) key = os.path.normcase(real) if key in img_before: old = bpy.data.images[img_before[key]] name = im.name im.user_remap(old) bpy.data.images.remove(im) rec["images"][name] = "the ship's own " + old.name continue im.filepath = bpy.path.relpath(real) rec["images"][im.name] = im.filepath # 4. collections --------------------------------------------------------------------------------------------------- parent = bpy.data.collections[PARENT_COLL] top = bpy.data.collections.new(COLL) parent.children.link(top) subs = {} for k, nm in SUB.items(): c = bpy.data.collections.new(nm) top.children.link(c) subs[k] = c rec["added"]["collections"] = [top.name] + [c.name for c in subs.values()] for n, o in got.items(): subs[_want(n)].objects.link(o) o["installed_by"] = TAG rec["added"]["objects"].append(o.name) for o in got.values(): if o.parent is None: o.parent = root o.matrix_parent_inverse = Matrix.Identity(4) for s in bpy.data.scenes: for vl in s.view_layers: lc = _find_lc(vl, subs["solver"].name) if lc is not None: lc.hide_viewport = True # 5. the rail system: hide the user's plain boxes; the arm's own beam (appended above) runs on the new rail line vl, dg = _depsgraph(scene) for d, rn in RAILS.items(): r = bpy.data.objects[rn] rec["hidden"].append([rn, r.hide_viewport, r.hide_render, r.hide_get(view_layer=vl)]) r.hide_viewport = True r.hide_render = True beam = got.get("DoorArm_%s | rail beam" % d) if beam is None: raise RuntimeError("the arm file has no rail beam for " + d) beam["replaces"] = rn for pn in PLACEHOLDERS: o = bpy.data.objects.get(pn) if o is not None: rec["hidden"].append([pn, o.hide_viewport, o.hide_render, o.hide_get(view_layer=vl)]) o.hide_viewport = True o.hide_render = True # 6. the earlier flush-hinge arms: on the same rails, so their collection is excluded (kept in the file) ----------- for s in bpy.data.scenes: for v in s.view_layers: lc = _find_lc(v, OLD_ARMS_COLL) if lc is not None: rec["excluded"].append([s.name, v.name, lc.exclude]) lc.exclude = True for kind in KINDS: new = sorted(set(getattr(bpy.data, kind).keys()) - before[kind]) if new: rec["added"]["data"][kind] = new txt = bpy.data.texts.new(RECORD) txt.write(json.dumps(rec, indent=1)) vl, dg = _depsgraph(scene) bad, n = [], 0 for nm in rec["added"]["objects"]: o = bpy.data.objects.get(nm) if o is None or not o.animation_data: continue for fc in o.animation_data.drivers: n += 1 ok = fc.driver.is_simple_expression and fc.driver.is_valid for v in fc.driver.variables: for t in v.targets: if t.id is not None and isinstance(t.id, bpy.types.Object) and not t.id.users_scene: ok = False if not ok: bad.append([nm, fc.data_path, fc.array_index, fc.driver.expression[:60]]) return {"objects": len(rec["added"]["objects"]), "remapped": rec["remapped"], "deleted_copies": rec["deleted_copies"], "drivers": n, "drivers_bad": bad[:10], "drivers_removed": rec["drivers_removed"], "images": rec["images"], "excluded": rec["excluded"], "hidden": [h[0] for h in rec["hidden"]], "new_data": {k: len(v) for k, v in rec["added"]["data"].items()}} def remove(): rec = record() if rec is None: return {"present": False} n = 0 for name in reversed(rec["added"]["objects"]): o = bpy.data.objects.get(name) if o is not None: bpy.data.objects.remove(o, do_unlink=True) n += 1 for name in reversed(rec["added"]["collections"]): c = bpy.data.collections.get(name) if c is not None and not c.all_objects: bpy.data.collections.remove(c) for name, hv, hr, hs in rec["hidden"]: o = bpy.data.objects.get(name) if o is not None: o.hide_viewport, o.hide_render = hv, hr for sname, vlname, was in rec["excluded"]: s = bpy.data.scenes.get(sname) v = s.view_layers.get(vlname) if s else None lc = _find_lc(v, OLD_ARMS_COLL) if v else None if lc is not None: lc.exclude = was bpy.data.texts.remove(bpy.data.texts[RECORD]) for _ in range(4): for kind in ("meshes", "curves", "materials", "images", "actions", "node_groups", "cameras", "lights"): coll = getattr(bpy.data, kind) for nm in rec["added"]["data"].get(kind, []): idb = coll.get(nm) if idb is not None and idb.users == 0: coll.remove(idb) return {"taken_out": n} # ---- check ------------------------------------------------------------------------------------------------------------- def _geo(ev): try: me = ev.to_mesh() except RuntimeError: return None if me is None: return None mw = ev.matrix_world me.calc_loop_triangles() vs = [mw @ v.co for v in me.vertices] ts = [tuple(t.vertices) for t in me.loop_triangles] ev.to_mesh_clear() if not ts: return None tree = BVHTree.FromPolygons([tuple(v) for v in vs], ts, epsilon=0.0) lo = Vector((min(v.x for v in vs), min(v.y for v in vs), min(v.z for v in vs))) hi = Vector((max(v.x for v in vs), max(v.y for v in vs), max(v.z for v in vs))) return (vs, ts, tree, lo, hi) def check(step=1): """the doors' timeline (frames 1-120): each plate holds its door (its pose against the door pad, the pad's against the door), and no part of the arms, the rail system or the chains crosses anything in the ship that renders or shows within the pockets' box (hull, lining, deck, basin, rim, lamps, end walls, both doors, ...). Allowed contacts, the design's own: each pad on its door; each beam set into the deck and into its pocket's end wall, as the user's rail was.""" if VI_BUILD not in sys.path: sys.path.insert(0, VI_BUILD) import boarding_gate as bgate rec = record() if rec is None: return {"present": False} scene = bpy.data.scenes[SCENE] vl, dg = _depsgraph(scene) mine = [bpy.data.objects[n] for n in rec["added"]["objects"] if n in bpy.data.objects and bpy.data.objects[n].type in ('MESH', 'CURVE')] mine_names = set(o.name for o in mine) excluded_colls = set() for lc in _layer_collections(vl.layer_collection): if lc.exclude: for c in [lc.collection] + list(lc.collection.children_recursive): excluded_colls.add(c.name) box = (-9.8, 16.0, -10.4, 0.6, -11.2, -3.2) def skip_coll(o): return any(c.name.startswith(("11 KIT", "13 KIT", "06 KIT")) or c.name in excluded_colls for c in o.users_collection) others = [] for o in scene.objects: if o.type not in ('MESH', 'CURVE') or o.name in mine_names or o.name.startswith(("UCX_", "KIT | ")): continue if skip_coll(o): continue if o.hide_render and not o.visible_get(view_layer=vl): continue lo, hi = Vector((1e9,) * 3), Vector((-1e9,) * 3) for cc in o.bound_box: w = o.matrix_world @ Vector(cc) lo = Vector(map(min, lo, w)) hi = Vector(map(max, hi, w)) if hi.x < box[0] or lo.x > box[1] or hi.y < box[2] or lo.y > box[3] or hi.z < box[4] or lo.z > box[5]: continue others.append(o) # collection instances and other instanced geometry (2026-10-18: the well's contents, kit pieces placed as # instances): every instance the depsgraph shows inside the box, keyed by its instancer and persistent id def instances(dg_): out = {} for inst in dg_.object_instances: if not inst.is_instance or inst.object.type not in ('MESH', 'CURVE'): continue par = inst.parent.original if inst.parent is not None else None if par is None or par.name in mine_names or skip_coll(par): continue if par.hide_render and not par.visible_get(view_layer=vl): continue M = inst.matrix_world.copy() bb = [M @ Vector(c) for c in inst.object.bound_box] lo = Vector([min(p[i] for p in bb) for i in range(3)]) hi = Vector([max(p[i] for p in bb) for i in range(3)]) if hi.x < box[0] or lo.x > box[1] or hi.y < box[2] or lo.y > box[3] or hi.z < box[4] or lo.z > box[5]: continue key = "%s <%s> %s" % (par.name, inst.object.name, tuple(inst.persistent_id)) moving = par.animation_data is not None or (par.parent is not None and par.parent.animation_data is not None) out[key] = (inst.object, M, moving) return out inst0 = instances(dg) seats = set() for d in ("AFT", "FORE"): seats.add(("DoorArm_%s | door fixture B" % d, DOORS[d])) seats.add(("DoorArm_%s | rail beam" % d, DECK)) seats.add(("DoorArm_%s | rail beam" % d, ENDS[d])) frames = list(range(scene.frame_start, scene.frame_end + 1, step)) if scene.frame_end not in frames: frames.append(scene.frame_end) was = scene.frame_current clashes, pose, ref, static_cache, seat_hits = {}, {}, {}, {}, {} for f in frames: scene.frame_set(f) vl, dg = _depsgraph(scene) for d in ("AFT", "FORE"): tool = bpy.data.objects["DoorArm_%s_B | tool B" % d].evaluated_get(dg).matrix_world fix = bpy.data.objects["DoorArm_%s | door fixture B" % d].evaluated_get(dg).matrix_world door = bpy.data.objects[DOORS[d]].evaluated_get(dg).matrix_world rel = fix.inverted() @ tool drel = door.inverted() @ fix if d not in ref: ref[d] = (rel.copy(), drel.copy()) p = pose.setdefault(d, {"plate_to_pad_mm": 0.0, "plate_to_pad_deg": 0.0, "pad_to_door_mm": 0.0, "door_x": []}) p["plate_to_pad_mm"] = max(p["plate_to_pad_mm"], round((rel.translation - ref[d][0].translation).length * 1000.0, 4)) p["plate_to_pad_deg"] = max(p["plate_to_pad_deg"], round(math.degrees( rel.to_quaternion().rotation_difference(ref[d][0].to_quaternion()).angle), 4)) p["pad_to_door_mm"] = max(p["pad_to_door_mm"], round((drel.translation - ref[d][1].translation).length * 1000.0, 4)) if f in (1, 12, 48, 120): p["door_x"].append([f, round(door.translation.x, 4), round(door.translation.y, 4)]) geo = {o.name: _geo(o.evaluated_get(dg)) for o in mine} insts = instances(dg) if any(v[2] for v in inst0.values()) else inst0 for o in others: moving = (o.animation_data is not None or (o.parent is not None and o.parent.animation_data is not None) or o.name in DOORS.values()) if moving: g = _geo(o.evaluated_get(dg)) else: if o.name not in static_cache: static_cache[o.name] = _geo(o.evaluated_get(dg)) g = static_cache[o.name] if g is None: continue for a, ga in geo.items(): if ga is None: continue if ga[4].x < g[3].x or ga[3].x > g[4].x or ga[4].y < g[3].y or ga[3].y > g[4].y or ga[4].z < g[3].z or ga[3].z > g[4].z: continue k = bgate._crossing(ga, g) if not k: continue key = a + " x " + o.name if (a, o.name) in seats: s = seat_hits.setdefault(key, {"first": f, "last": f, "pairs": 0}) s["last"] = f s["pairs"] = max(s["pairs"], k) continue c = clashes.setdefault(key, {"first": f, "last": f, "pairs": 0}) c["last"] = f c["pairs"] = max(c["pairs"], k) for ik, (iob, M, moving) in insts.items(): if moving or ik not in static_cache: gi = _geo_m(iob, M) if not moving: static_cache[ik] = gi else: gi = static_cache[ik] if gi is None: continue for a, ga in geo.items(): if ga is None: continue if ga[4].x < gi[3].x or ga[3].x > gi[4].x or ga[4].y < gi[3].y or ga[3].y > gi[4].y or ga[4].z < gi[3].z or \ ga[3].z > gi[4].z: continue k = bgate._crossing(ga, gi) if k: c = clashes.setdefault(a + " x " + ik, {"first": f, "last": f, "pairs": 0}) c["last"] = f c["pairs"] = max(c["pairs"], k) scene.frame_set(was) return {"frames": len(frames), "checked_against": len(others), "others": sorted(o.name for o in others), "instances_checked": len(inst0), "instances": sorted(inst0)[:400], "pose": pose, "clashes": clashes, "allowed_contacts": seat_hits, "beam_under_deck": beam_under_deck(scene)} def _geo_m(ob, M): """like _geo for an instanced object at matrix M""" try: me = ob.to_mesh() except RuntimeError: return None if me is None: return None me.calc_loop_triangles() vs = [M @ v.co for v in me.vertices] ts = [tuple(t.vertices) for t in me.loop_triangles] ob.to_mesh_clear() if not ts: return None tree = BVHTree.FromPolygons([tuple(v) for v in vs], ts, epsilon=0.0) lo = Vector((min(v.x for v in vs), min(v.y for v in vs), min(v.z for v in vs))) hi = Vector((max(v.x for v in vs), max(v.y for v in vs), max(v.z for v in vs))) return (vs, ts, tree, lo, hi) def beam_under_deck(scene): """each rail beam and its rail kit (flanges, rack, end stops, chain tray) under the deck: every vertex, seen from below, must lie under the deck's underside (its downward faces), nothing above it; and how far the rail system's inner end is from the deck's edge at the moon pool (the least, over the vertices within 0.3 m of that end)""" vl, dg = _depsgraph(scene) deck = bpy.data.objects[DECK].evaluated_get(dg) me = deck.to_mesh() mw = deck.matrix_world me.calc_loop_triangles() vs2, ts2 = [], [] for t in me.loop_triangles: if (mw.to_3x3() @ t.normal).z < -0.9: k = len(vs2) for i in t.vertices: w = mw @ me.vertices[i].co vs2.append(Vector((w.x, w.y, 0.0))) ts2.append((k, k + 1, k + 2)) zdeck = min((mw @ me.vertices[i].co).z for t in me.loop_triangles for i in t.vertices if (mw.to_3x3() @ t.normal).z < -0.9) deck.to_mesh_clear() tree = BVHTree.FromPolygons(vs2, ts2) def under(x, y): hit = tree.ray_cast(Vector((x, y, -1.0)), Vector((0, 0, 1)), 2.0) return hit[0] is not None out = {} for d in ("AFT", "FORE"): vs = [] for nm in ("DoorArm_%s | rail beam" % d, "DoorArm_%s | rail kit" % d): ob = bpy.data.objects.get(nm) if ob is not None: vs += [ob.matrix_world @ v.co for v in ob.data.vertices] if not vs: continue sgn = 1.0 if d == "AFT" else -1.0 # toward the moon pool x_in = max(v.x for v in vs) if d == "AFT" else min(v.x for v in vs) n_out = sum(1 for v in vs if not under(v.x, v.y)) near = [v for v in vs if sgn * (x_in - v.x) < 0.3] step = max(1, len(near) // 400) best = 9.0 for v in near[::step]: m = 0.0 while under(v.x + sgn * (m + 0.002), v.y) and m < best: m += 0.002 best = min(best, m) top = max(v.z for v in vs) out[d] = dict(vertices_checked=len(vs), vertices_not_under_deck=n_out, inner_end_x=round(x_in, 4), margin_to_deck_edge_m=round(best, 3), top_z=round(top, 4), deck_underside_z=round(zdeck, 4), ok=n_out == 0 and best > 0.0 and top <= zdeck + 1e-4) return out