"""Bring the docking arm's new surfaces into a scene that already holds the RIGGED carousel, in place. (The round of 2026-10-13. Since the boom joint of 2026-10-16 the rig itself differs: use scene_update_rig.py for that; pylon_uvs() here is still what gives the ship's pylon the two UV layers the arm's materials read.) Written for the vessel interior (models/M_Ship_v2_interior_season2.blend), where the "Main vessel interior layout" session swapped the rigged carousel in on 2026-10-13 and gave its rig the reach_in motion. That file is edited by hand and by that session, so nothing is rebuilt or reloaded: the armature, its pose, its drivers and constraints, the 26 SK_DockArm_* objects, their modifiers and vertex groups stay exactly as they are. Only this changes: * each object's MESH datablock is replaced by the one of the same name from DockArm_Carousel_Rigged.blend (two UV layers, the light lines gone, the hose bands with their own material); * the M_DockArm_* MATERIAL datablocks are replaced by the textured ones under the same names, so anything else that uses them follows (the interior's carousel pylon borrows Paint, Graphite and Steel); * images: the scene's own datablock is used wherever it already has the same file in the same colour space; the two old hose maps are removed with the old hose material; * the pylon gets the two UV layers these materials read (added beside its own; nothing of it is changed). exec(open(r"...\\docking-arm\\build\\scene_update_surfaces.py").read()); result = update_surfaces() The geometry is checked against the scene's own state before the change (the hoses vertex by vertex, the rigid parts by their bounds); on a mismatch it raises AFTER having changed the scene, so run it on a copy first. """ import bpy, os, math from mathutils import Vector _SRC = r"D:\HADAL - The Descent\models\docking-arm\DockArm_Carousel_Rigged.blend" # written out: this file is exec'd, so it has no __file__ of its own PYLON = "Carousel pylon (bay roof to mount flange)" def _world(o): e = o.evaluated_get(bpy.context.evaluated_depsgraph_get()) M = e.matrix_world.copy() return [M @ v.co for v in e.data.vertices] def _bounds(vs): return [min(v[i] for v in vs) for i in range(3)] + [max(v[i] for v in vs) for i in range(3)] def _path(im): return os.path.normcase(os.path.normpath(bpy.path.abspath(im.filepath))) if im.filepath else "" def pylon_uvs(ob): """UV_Tile (metres: walls unrolled, flat rings as seen from above) and UVMap (a blank corner of the arm's atlas: no baked edges or crevices there) beside the pylon's own layer, which stays the active one.""" me = ob.data if "UV_Tile" in me.uv_layers and "UVMap" in me.uv_layers: return False own = [tuple(d.uv) for d in me.uv_layers[0].data] if len(me.uv_layers) else None keep_active = me.uv_layers.active.name if me.uv_layers.active else None keep_render = next((l.name for l in me.uv_layers if l.active_render), None) for n in ("UV_Tile", "UVMap"): if n not in me.uv_layers: me.uv_layers.new(name=n, do_init=False) tl, at = me.uv_layers["UV_Tile"].data, me.uv_layers["UVMap"].data for p in me.polygons: cs = [me.vertices[vi].co for vi in p.vertices] if abs(p.normal.z) > 0.7: uv = [(c.x, c.y) for c in cs] else: ca = math.atan2(p.center.y, p.center.x) r = sum(math.hypot(c.x, c.y) for c in cs) / len(cs) uv = [((ca + (math.atan2(c.y, c.x) - ca + math.pi) % (2 * math.pi) - math.pi) * r, c.z) for c in cs] for li, t in zip(p.loop_indices, uv): tl[li].uv = t at[li].uv = (0.85 + 0.10 * (own[li][0] % 1.0), 0.85 + 0.10 * (own[li][1] % 1.0)) if own else (0.90, 0.90) if keep_active: me.uv_layers.active = me.uv_layers[keep_active] if keep_render: me.uv_layers[keep_render].active_render = True return True def update_surfaces(src=_SRC): D = bpy.data if bpy.context.mode != 'OBJECT': raise RuntimeError("the scene is in %s mode; nothing was changed" % bpy.context.mode) objs = sorted((o for o in D.objects if o.type == 'MESH' and o.name.startswith("SK_DockArm_")), key=lambda o: o.name) if len(objs) != 26: raise RuntimeError("expected the 26 SK_DockArm_ objects of the rigged carousel, found %d; nothing was changed" % len(objs)) if any(o.data.users != 1 for o in objs) or any(s.link != 'DATA' for o in objs for s in o.material_slots): raise RuntimeError("a carousel mesh is shared or has object-linked material slots; nothing was changed") if any("UV_Tile" in o.data.uv_layers for o in objs): raise RuntimeError("the carousel here already has the new meshes; nothing was changed") wanted = [o.data.name for o in objs] before = {o.name: _world(o) for o in objs} old_mats = {m.name: m for m in D.materials if m.name.startswith("M_DockArm_")} mats_before = {m.as_pointer() for m in D.materials} imgs_before = {i.as_pointer() for i in D.images} with D.libraries.load(src, link=False) as (s, t): missing = [n for n in wanted if n not in s.meshes] if missing: raise RuntimeError("not in %s: %s; nothing was changed" % (os.path.basename(src), missing)) t.meshes = list(wanted) new = dict(zip(wanted, t.meshes)) rep = dict(source=src, meshes=0, materials_replaced=[], materials_added=[], images_reused=[], images_added=[], images_removed=[]) # images first: the scene's own datablock wherever it is the same file read the same way have = {} for i in D.images: if i.as_pointer() in imgs_before and i.filepath: have.setdefault((_path(i), i.colorspace_settings.name, i.alpha_mode), i) for i in [i for i in D.images if i.as_pointer() not in imgs_before]: twin = have.get((_path(i), i.colorspace_settings.name, i.alpha_mode)) if twin is not None: i.user_remap(twin) rep["images_reused"].append(twin.name) D.images.remove(i) else: if not os.path.isfile(bpy.path.abspath(i.filepath)): raise RuntimeError("texture not found: %s" % i.filepath) if not i.filepath.startswith("//") and D.filepath: i.filepath = bpy.path.relpath(bpy.path.abspath(i.filepath)) rep["images_added"].append(i.name + " " + i.filepath) # meshes: same objects, new data for o in objs: old, nm = o.data, o.data.name me = new[nm] old.user_remap(me) D.meshes.remove(old) me.name = nm rep["meshes"] += 1 # materials: same names, new trees; other users of the old ones (the pylon) follow for m in [m for m in D.materials if m.as_pointer() not in mats_before]: base = m.name[:-4] if len(m.name) > 4 and m.name[-4] == "." and m.name[-3:].isdigit() else m.name old = old_mats.get(base) if old is not None: old.user_remap(m) D.materials.remove(old) m.name = base rep["materials_replaced"].append(base) else: rep["materials_added"].append(m.name) for i in [i for i in D.images if i.name.startswith(("T_DockArm_HoseFold_Mask", "T_DockArm_HoseWrinkle_Normal")) and i.users == 0]: rep["images_removed"].append(i.name) D.images.remove(i) py = D.objects.get(PYLON) rep["pylon_uvs_added"] = bool(py and py.type == 'MESH' and pylon_uvs(py)) # the same carousel as before? bpy.context.view_layer.update() worst_hose, worst_box = 0.0, 0.0 for o in objs: a, b = before[o.name], _world(o) if "OreHose" in o.name: if len(a) != len(b): raise RuntimeError("%s: vertex count changed %d -> %d" % (o.name, len(a), len(b))) worst_hose = max(worst_hose, max((p - q).length for p, q in zip(a, b))) else: worst_box = max(worst_box, max(abs(x - y) for x, y in zip(_bounds(a), _bounds(b)))) rep["hose_vertices_moved_m"] = round(worst_hose, 7) rep["part_bounds_moved_m"] = round(worst_box, 7) rep["uv_layers"] = sorted({tuple(l.name for l in o.data.uv_layers) for o in objs}) rep["materials_now"] = sorted(m.name for m in D.materials if m.name.startswith("M_DockArm_")) rep["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))) if worst_hose > 1e-5 or worst_box > 2e-3 or rep["images_missing"]: raise RuntimeError("the carousel is not where it was: %s" % rep) return rep