"""Bring the boom-joint version of the rigged docking carousel into a scene that holds the older rigged one, in place. Written for the vessel interior (models/M_Ship_v2_interior_season2.blend): the "Main vessel interior layout" session put the rigged carousel there (its build/carousel_rigged.py), the launch arm at BOARD with reach_in 2.34, the three preview Tenders hanging on the head bones. That file is edited by hand and by other sessions, so nothing of it is rebuilt: only the carousel's own things are replaced: * the armature DockArm_Rig and its skinned objects (26 before, 29 now: the booms are parts of their own) and the handle shapes go, and the same from DockArm_Carousel_Rigged.blend comes in under the same collection names; * the pose that matters is carried over: every hoist handle's height, reach_in and auto, the claws, the turret's yaw; * the Tenders are hung on the new head bones at exactly their old places; * materials and images that come along a second time are dropped for the file's own (same names, same recipes); the two baked atlas images are reloaded because the atlas was laid out anew for the boom. DockArm_Root, the pylon and everything else stay as they are. The report says how far every part moved (the links, heads and claws must not move at all; the turret and the new booms differ by design). exec(open(r"...\\docking-arm\\build\\scene_update_rig.py").read()); rep = update_rig(); dsc = check_descent() check_descent() runs the launch arm from BOARD to POOL and measures the carousel and its Tender against the ship around the well: crossings and the smallest gap at every step. """ import bpy, os, math from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree RIG_FILE = r"D:\HADAL - The Descent\models\docking-arm\DockArm_Carousel_Rigged.blend" ROOT, RIG = "DockArm_Root", "DockArm_Rig" ZONE = "10.7 | Tender carousel" NEW_COLL = "10.7 | Tender carousel | docking arm, rigged (models/docking-arm)" WGT_COLL = "10.7 | Tender carousel | rig handle shapes (not exported)" BOARD, POOL = 5.3, 9.3 def _bounds(o): dg = bpy.context.evaluated_depsgraph_get() e = o.evaluated_get(dg) M = e.matrix_world vs = [M @ v.co for v in e.data.vertices] return [round(min(v[i] for v in vs), 4) for i in range(3)] + [round(max(v[i] for v in vs), 4) for i in range(3)] def _base(name): return name[:-4] if len(name) > 4 and name[-4] == "." and name[-3:].isdigit() else name def _drop_duplicates(blocks, before): """an appended block whose base name the file had already is remapped to the file's own and removed""" dropped = [] for b in [b for b in blocks if b.name not in before]: own = blocks.get(_base(b.name)) if own is not None and own.name in before and own is not b: b.user_remap(own) dropped.append(b.name) blocks.remove(b) return dropped def _layer_collection(lc, name): if lc.collection.name == name: return lc for ch in lc.children: f = _layer_collection(ch, name) if f is not None: return f return None def update_rig(src=RIG_FILE): D = bpy.data if bpy.context.mode != 'OBJECT': raise RuntimeError("the scene is in %s mode; nothing was changed" % bpy.context.mode) root, rig, zone = D.objects[ROOT], D.objects[RIG], D.collections[ZONE] coll, wgt = D.collections[NEW_COLL], D.collections.get(WGT_COLL) if rig.data.bones.get("Arm1_Boom") is not None: raise RuntimeError("the carousel here has the boom joint already; nothing was changed") # 1. what is to be carried over pose = {} for k in (1, 2, 3): h, c = rig.pose.bones["CTRL_Arm%d_Hoist" % k], rig.pose.bones["CTRL_Arm%d_Claws" % k] pose[k] = dict(y=h.location.y, reach=float(h.get("reach_in", 0.0)), auto=float(h.get("auto", 1.0)), claws=c.rotation_euler.x) yaw = rig.pose.bones["Turret"].rotation_euler.y tenders = {t.name: (t.matrix_world.copy(), t.parent_bone) for t in D.objects if t.parent is rig and t.parent_type == 'BONE' and t.parent_bone.endswith("_Head")} bpy.context.view_layer.update() before = {o.name: _bounds(o) for o in coll.all_objects if o.type == 'MESH'} mats_before = {m.name for m in D.materials} imgs_before = {i.name for i in D.images} rig_matrix = rig.matrix_world.copy() # 2. the old carousel goes (the Tenders are unparented first, keeping their place) for name, (mw, _b) in tenders.items(): t = D.objects[name] t.parent = None t.matrix_world = mw old_objs = [o for o in coll.all_objects if o is not root] old_meshes = {o.data for o in old_objs if o.type == 'MESH'} old_arm = rig.data for o in old_objs: D.objects.remove(o, do_unlink=True) gone = [] for me in old_meshes: if me.users == 0: gone.append(me.name); D.meshes.remove(me) if old_arm.users == 0: gone.append(old_arm.name); D.armatures.remove(old_arm) if wgt is not None: for o in list(wgt.all_objects): me = o.data D.objects.remove(o, do_unlink=True) if me is not None and me.users == 0: D.meshes.remove(me) D.collections.remove(wgt) coll.objects.unlink(root) D.collections.remove(coll) # 3. the new one comes in with D.libraries.load(src, link=False) as (s, t): t.collections = [c for c in s.collections if c in ("DockArm_Rigged", "DockArm_Widgets")] new_coll = next(c for c in t.collections if c is not None and c.name.startswith("DockArm_Rigged")) new_wgt = next((c for c in t.collections if c is not None and c.name.startswith("DockArm_Widgets")), None) rep = dict(source=src, removed_objects=len(old_objs), removed_data=gone, appended={new_coll.name: len(new_coll.objects)}) rep["materials_dropped_for_the_file's_own"] = _drop_duplicates(D.materials, mats_before) rep["images_dropped_for_the_file's_own"] = _drop_duplicates(D.images, imgs_before) for i in [i for i in D.images if i.name not in imgs_before and i.users == 0]: D.images.remove(i) rep["materials_new"] = sorted(m.name for m in D.materials if m.name not in mats_before) rep["images_new"] = sorted(i.name for i in D.images if i.name not in imgs_before) for nm in ("T_DockArm_Masks", "T_DockArm_BevelNormal"): # laid out anew for the boom: read the files again im = D.images.get(nm) if im is not None and not im.packed_file: im.reload() new_coll.name = NEW_COLL zone.children.link(new_coll) new_coll.objects.link(root) if new_wgt is not None: new_wgt.name = WGT_COLL zone.children.link(new_wgt) lc = _layer_collection(bpy.context.view_layer.layer_collection, WGT_COLL) if lc is not None: lc.exclude = True rig = next(o for o in new_coll.objects if o.type == 'ARMATURE') rig.name = RIG rig.data.name = RIG rig.parent = root rig.matrix_parent_inverse = Matrix.Identity(4) rig.location = (0.0, 0.0, 0.0) rig.rotation_euler = (0.0, 0.0, 0.0) bpy.context.view_layer.update() # 4. the pose carried over, the Tenders hung again for k, p in pose.items(): h, c = rig.pose.bones["CTRL_Arm%d_Hoist" % k], rig.pose.bones["CTRL_Arm%d_Claws" % k] h.location = (0.0, p["y"], 0.0) h["reach_in"] = p["reach"] h["auto"] = p["auto"] c.rotation_euler.x = p["claws"] rig.pose.bones["Turret"].rotation_euler.y = yaw rig.update_tag() bpy.context.view_layer.update() for name, (mw, bone) in tenders.items(): t = D.objects[name] t.parent = rig t.parent_type = 'BONE' t.parent_bone = bone t.matrix_parent_inverse = Matrix.Identity(4) bpy.context.view_layer.update() t.matrix_world = mw bpy.context.view_layer.update() rig["placed"] = "the rigged carousel of models/docking-arm with the boom joint (2026-10-16), swapped in place by build/scene_update_rig.py" root["carousel"] = "rigged: DockArm_Rig (Pose Mode handles CTRL_ArmN_Hoist 0..9.3 m: 5.3 = BOARD, 9.3 = POOL (the boom joint); CTRL_ArmN_Claws)" # 5. checks: the Tenders did not move, the unchanged parts are where they were, nothing is missing worst_t = 0.0 for name, (mw, bone) in tenders.items(): t = D.objects[name] worst_t = max(worst_t, max(abs(a - b) for ra, rb in zip(t.matrix_world, mw) for a, b in zip(ra, rb))) moved = {} for name, b in before.items(): o = D.objects.get(name) if o is None: moved[name] = "gone" continue d = max(abs(x - y) for x, y in zip(_bounds(o), b)) moved[name] = round(d * 1000, 2) rep.update(tenders_rehung={n: b for n, (m, b) in tenders.items()}, tenders_moved=round(worst_t, 6), pose_carried=pose, turret_yaw=round(yaw, 6), parts_moved_mm={n: v for n, v in moved.items() if n.startswith("SK_DockArm_") and (v == "gone" or v > 1.0)}, new_objects=sorted(o.name for o in new_coll.objects), bones=len(rig.data.bones), drivers=len(rig.animation_data.drivers), images_missing=sorted(i.name for i in D.images if i.name.startswith("T_DockArm_") and i.source == 'FILE' and not i.packed_file and not os.path.isfile(bpy.path.abspath(i.filepath)))) # links, claws and the mount are the same meshes as before and must not have moved; the turret lost its booms, the heads # got their hose coupling 10 cm further out and the hoses a new climb, so those differ by design unchanged = [n for n in moved if any(k in n for k in ("_UpperLink", "_LowerLink", "_Claw", "Mount"))] bad = [n for n in unchanged if moved[n] == "gone" or moved[n] > 1.0] bad += [n for n in moved if "_Head" in n and (moved[n] == "gone" or moved[n] > 120.0)] if worst_t > 1e-4 or bad or rep["images_missing"]: raise RuntimeError("the carousel is not where it was: %s" % rep) return rep # ------------------------------------------------------------------ the descent against the ship def _geo_of(o, dg): e = o.evaluated_get(dg) me = e.data me.calc_loop_triangles() M = e.matrix_world return [M @ v.co for v in me.vertices], [tuple(t.vertices) for t in me.loop_triangles] def check_descent(steps=9, arm=1, region=((-10.5, -10.5, -11.5), (16.5, 2.5, 5.5))): """Poses the launch arm from BOARD to POOL and measures its parts and its Tender against the ship's meshes in the region (ship frame) round the well. Returns the crossings and the smallest gap at every step, and the parts nearest.""" D = bpy.data rig = D.objects[RIG] h = rig.pose.bones["CTRL_Arm%d_Hoist" % arm] y0, r0 = h.location.y, float(h.get("reach_in", 0.0)) lo, hi = Vector(region[0]), Vector(region[1]) skip = ("SK_DockArm_", "_Tender", "Carousel pylon", "DockArm_", "WGT_", "KIT |") # KIT |: the kit's library pieces, parked, not the ship dg = bpy.context.evaluated_depsgraph_get() ship = {} for o in bpy.context.view_layer.objects: # what is in the view layer and visible: the ship as shown if o.type != 'MESH' or o.library is not None or o.name.startswith(skip) or o.hide_render or o.hide_viewport or not o.visible_get(): continue if any(c.name.startswith(("90", "99", "GAME")) for c in o.users_collection) or any(k in o.name for k in ("_GAME", "_DOCK", "_LOD")): continue bb = [o.matrix_world @ Vector(c) for c in o.bound_box] if max(v.x for v in bb) < lo.x or min(v.x for v in bb) > hi.x or max(v.y for v in bb) < lo.y or min(v.y for v in bb) > hi.y or max(v.z for v in bb) < lo.z or min(v.z for v in bb) > hi.z: continue vs, ts = _geo_of(o, dg) if ts: ship[o.name] = (BVHTree.FromPolygons(vs, ts), vs) mine = [o for o in D.objects if o.name.startswith("SK_DockArm_Arm%d_" % arm) or o.name == "SK_DockArm_Turret"] tender = D.objects.get("_Tender%d" % arm) rows = [] for i in range(steps + 1): y = BOARD + (POOL - BOARD) * i / steps h.location.y = y rig.update_tag() for o in mine: o.update_tag() bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() parts = {o.name: _geo_of(o, dg) for o in mine} if tender is not None: V, F = [], [] for it in dg.object_instances: if it.is_instance and it.parent and it.parent.original == tender and it.object.type == 'MESH': me = it.object.data me.calc_loop_triangles() M = it.matrix_world.copy() b = len(V) V += [M @ v.co for v in me.vertices] F += [tuple(b + j for j in t.vertices) for t in me.loop_triangles] if F: parts[tender.name] = (V, F) crossings, gap, near = [], 99.0, None for pn, (pv, pt) in parts.items(): ptree = BVHTree.FromPolygons(pv, pt) pb = (min(v.x for v in pv), min(v.y for v in pv), min(v.z for v in pv), max(v.x for v in pv), max(v.y for v in pv), max(v.z for v in pv)) for sn, (stree, sv) in ship.items(): n = len(ptree.overlap(stree)) if n: crossings.append((pn, sn, n)) for v in sv[::3]: if pb[0] - 1.5 < v.x < pb[3] + 1.5 and pb[1] - 1.5 < v.y < pb[4] + 1.5 and pb[2] - 1.5 < v.z < pb[5] + 1.5: d = ptree.find_nearest(v)[3] if d < gap: gap, near = d, (pn, sn) hd = rig.pose.bones["Arm%d_Head" % arm] rows.append(dict(y=round(y, 2), head_z=round((rig.matrix_world @ hd.head).z, 3), crossings=crossings, smallest_gap_m=round(gap, 3), nearest=near)) h.location.y = y0 rig.update_tag() bpy.context.view_layer.update() return dict(ship_meshes_checked=len(ship), steps=rows, worst_gap_m=round(min(r["smallest_gap_m"] for r in rows), 3), any_crossing=any(r["crossings"] for r in rows))