"""Wall props in place of the pack's Storage Area Network boxes. The user, 2026-10-19, on two pictures of the pack's wall box `EN_SM_StorageAreaNetwork` (the "STORAGE AREA NETWORK" box): "I want you to replace every box of that object in ship with inside "MachineParts_Kit" "HardSurfaceKit" objects ... the object must fit to the scene like dont put pistons to the wall choose attachable objects to the wall or hangable. you can set it randomly. an you can modify it randomly. you can scale the objects if its neccacery." The props are the game-ready assets of models/wall-props (WallProps.blend: 16 wall-mounting parts of the two kits, each with its own baked texture set; build/wp_build.py there). apply(): - appends their meshes (with their materials and textures) into this file, - for every box in the ship (26; the kit's source copy in `11 KIT ...` stays) picks one of them at random (seeded by the box's name, so a rebuild picks the same; no two neighbours in a row the same), turns it to the box's wall (its back on the box's back plane, 2 mm off the wall, its front out of the wall), turns it in the wall's plane by a random 0/90/180/270 where it fits, scales it to fit the box's face (0.42 x 0.71 m, at most 0.25 m deep) by a random 78-98 % and shifts it a little at random within what is left, - tests it against everything round it (the exact crossing test of boarding_gate.py); if it crosses anything it tries the next prop, then smaller, - takes the box out (recorded for remove()), and stands the prop in the box's collections, under its parent, in its zone. check(): the props against the ship round them. """ import json import math import os import random import sys import zlib import bpy from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) import reactor_room as rr # noqa: E402 (the record's snapshots) import boarding_gate as bg # noqa: E402 (the exact crossing test) TAG = "wall_props" RECORD = "WALL PROPS | what the boxes were" BOX_MESH = "EN_SM_StorageAreaNetwork" SOURCE = os.path.normpath(os.path.join(HERE, "..", "..", "wall-props", "WallProps.blend")) SCENE = "M-Ship v2 | REFERENCE ASSET" # the box in its own frame: its back on x = 0 (the wall), the box out toward -x, up +z, across y FACE_Y = (-0.200, 0.218) FACE_Z = (-0.303, 0.408) DEPTH_MAX = 0.25 OFF_WALL = 0.002 SCALE_RANGE = (0.78, 0.98) S_MAX = 1.7 # props that hang keep gravity's way round: the cable box's cable down, the yoke's canisters under it, the filter's bowl # down (it lies toward the part's +x), the twin necks up; the others may turn UPRIGHT = {"CableBox": [0], "TwinCanisterYoke": [0], "FilterBowlPlate": [3], "TwinNeckHousing": [0]} def exists(): return RECORD in bpy.data.texts def record(): return json.loads(bpy.data.texts[RECORD].as_string()) if exists() else None def forget(): """Drop the record without touching any object (for a rebuild, which throws the whole layout away first).""" if not exists(): return False bpy.data.texts.remove(bpy.data.texts[RECORD]) return True def _boxes(): sc = bpy.data.scenes.get(SCENE) or bpy.context.scene return sorted([o for o in sc.objects if o.type == 'MESH' and o.data.name == BOX_MESH and not any(c.name.startswith("11 KIT") for c in o.users_collection)], key=lambda o: o.name) def _append(): """the props' meshes from WallProps.blend, with their materials and textures (textures re-pointed from this file)""" have = {m.name: m for m in bpy.data.meshes if m.get("wp_asset")} with bpy.data.libraries.load(SOURCE, link=False) as (src, dst): dst.meshes = [n for n in src.meshes if n.startswith("SM_WP_") and n not in have] tex_dir = os.path.join(os.path.dirname(SOURCE), "textures") for m in bpy.data.meshes: if not m.get("wp_asset"): continue for mat in m.materials: if mat is None or not mat.use_nodes: continue for n in mat.node_tree.nodes: if n.type == 'TEX_IMAGE' and n.image and n.image.name.startswith("T_WP_"): p = os.path.join(tex_dir, n.image.name + ".png") if os.path.exists(p): n.image.filepath = bpy.path.relpath(p) if bpy.data.filepath else p return sorted([m for m in bpy.data.meshes if m.get("wp_asset")], key=lambda m: m.name) def _bounds(me): lo, hi = me["wp_bounds"] return Vector(lo), Vector(hi) R_WALL = Matrix(((0, 0, -1, 0), (-1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 0, 1))) # prop X -> -Y, Y -> +Z, Z (front) -> -X def _fit(me, quarter, f, rng): """the prop's matrix in the box's frame for a quarter turn in the wall's plane and a fill factor, or None""" lo, hi = _bounds(me) size = hi - lo cxy = Vector(((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, 0.0)) w, h = (size.x, size.y) if quarter % 2 == 0 else (size.y, size.x) # across (box y) and up (box z) W, H = FACE_Y[1] - FACE_Y[0], FACE_Z[1] - FACE_Z[0] s = min(W / w, H / h, DEPTH_MAX / max(size.z, 1e-6), S_MAX) * f if s < 0.45: return None # what is left of the face, for a small random shift free_y, free_z = (W - w * s) / 2, (H - h * s) / 2 dy = rng.uniform(-0.5, 0.5) * free_y dz = rng.uniform(-0.5, 0.5) * free_z c = Vector((-OFF_WALL, (FACE_Y[0] + FACE_Y[1]) / 2 + dy, (FACE_Z[0] + FACE_Z[1]) / 2 + dz)) rot = Matrix.Rotation(quarter * math.pi / 2, 4, 'Z') # in the prop's own front plane = the wall's plane return Matrix.Translation(c) @ R_WALL @ rot @ Matrix.Scale(s, 4) @ Matrix.Translation(-cxy), s, quarter def _geo_of(ob): me = ob.data me.calc_loop_triangles() mw = ob.matrix_world vs = [mw @ v.co for v in me.vertices] ts = [tuple(t.vertices) for t in me.loop_triangles] 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 _neighbours(box, cache): """everything visible within 1.2 m of the box (but the box itself and the kit's source copies)""" sc = bpy.data.scenes.get(SCENE) or bpy.context.scene c = box.matrix_world.translation out = [] for o in sc.objects: if o is box or o.type != 'MESH' or o.name.startswith(("UCX_", "KIT | ")) or o.get("made_by") == TAG: continue if any(k.name.startswith(("11 KIT", "13 KIT")) for k in o.users_collection) or not o.visible_get(): continue lo, hi = Vector((1e9,) * 3), Vector((-1e9,) * 3) for b in o.bound_box: w = o.matrix_world @ Vector(b) lo = Vector(map(min, lo, w)) hi = Vector(map(max, hi, w)) if any(lo[i] > c[i] + 1.2 or hi[i] < c[i] - 1.2 for i in range(3)): continue if o.name not in cache: cache[o.name] = _geo_of(o) if cache[o.name] is not None: out.append((o, cache[o.name])) return out def _clashes(geo, near): hits = [] for o, g in near: if bg._crossing(geo, g): hits.append(o.name) return hits def apply(seed=1019): if exists(): raise RuntimeError("the wall props are already there (text block %r); remove() puts the boxes back" % RECORD) if not os.path.exists(SOURCE): raise RuntimeError("no WallProps.blend: " + SOURCE) props = _append() if not props: raise RuntimeError("no props in " + SOURCE) boxes = _boxes() rec = {"removed": [], "added": [], "meshes": [p.name for p in props], "placed": {}} cache = {} order = list(props) random.Random(seed).shuffle(order) last = None tmp = bpy.data.objects.new("WP tmp", props[0]) bpy.data.scenes[SCENE].collection.objects.link(tmp) try: for i, box in enumerate(boxes): rng = random.Random(zlib.crc32(box.name.encode()) ^ seed) near = _neighbours(box, cache) # candidates: the props in a random order for this box, the last one used put to the back cand = list(order) rng.shuffle(cand) if last in cand: cand.remove(last) cand.append(last) placed = None for me in cand: lo, hi = _bounds(me) tall = (hi.y - lo.y) >= (hi.x - lo.x) quarters = [0, 2] if tall else [1, 3] # the long side up, as the box's face is tall if rng.random() < 0.25: quarters = quarters + [(q + 1) % 4 for q in quarters] if me.get("wp_asset") in UPRIGHT: quarters = list(UPRIGHT[me["wp_asset"]]) rng.shuffle(quarters) for f in (rng.uniform(*SCALE_RANGE), 0.72, 0.62): for q in quarters: r = _fit(me, q, f, rng) if r is None: continue M, s, qq = r tmp.data = me tmp.matrix_world = box.matrix_world @ M bpy.context.view_layer.update() g = _geo_of(tmp) hits = _clashes(g, near) if not hits: placed = (me, M, s, qq) break if placed: break if placed: break if placed is None: rec["placed"][box.name] = {"left": "nothing fits without crossing"} continue me, M, s, q = placed ob = bpy.data.objects.new("WallProp | %s | %s" % (me["wp_asset"], box.name), me) for c in box.users_collection: c.objects.link(ob) ob.parent = box.parent ob.matrix_world = box.matrix_world @ M for k in ("zone",): if k in box: ob[k] = box[k] ob["made_by"] = TAG ob["replaces"] = box.name rec["added"].append(ob.name) rec["placed"][box.name] = {"prop": me.name, "scale": round(s, 3), "quarter_turns": q, "at": [round(v, 3) for v in ob.matrix_world.translation]} last = me rec["removed"].append(rr._snap(box)) bpy.data.objects.remove(box, do_unlink=True) finally: bpy.data.objects.remove(tmp, do_unlink=True) txt = bpy.data.texts.new(RECORD) txt.write(json.dumps(rec, indent=1)) bpy.context.view_layer.update() used = {} for v in rec["placed"].values(): if "prop" in v: used[v["prop"]] = used.get(v["prop"], 0) + 1 return {"boxes": len(boxes), "replaced": len(rec["added"]), "left": [k for k, v in rec["placed"].items() if "prop" not in v], "props_used": used} def remove(): rec = record() if rec is None: return {"present": False} for n in rec["added"]: o = bpy.data.objects.get(n) if o is not None: bpy.data.objects.remove(o, do_unlink=True) back = 0 box_me = bpy.data.meshes.get(BOX_MESH) for d in rec["removed"]: if d["name"] in bpy.data.objects or box_me is None: continue o = bpy.data.objects.new(d["name"], box_me) for c in d["collections"]: if c in bpy.data.collections: bpy.data.collections[c].objects.link(o) if d["parent"] and d["parent"] in bpy.data.objects: o.parent = bpy.data.objects[d["parent"]] o.matrix_world = Matrix(d["matrix"]) for k, v in d["props"].items(): o[k] = v for slot, (link, mat) in zip(o.material_slots, d["slots"]): if link == "OBJECT": slot.link = "OBJECT" slot.material = bpy.data.materials.get(mat) if mat else None back += 1 for n in rec["meshes"]: me = bpy.data.meshes.get(n) if me is not None and me.users == 0: mats = [m for m in me.materials if m] bpy.data.meshes.remove(me) for m in mats: if m.users == 0: bpy.data.materials.remove(m) for im in [i for i in bpy.data.images if i.name.startswith("T_WP_") and i.users == 0]: bpy.data.images.remove(im) bpy.data.texts.remove(bpy.data.texts[RECORD]) return {"taken_out": len(rec["added"]), "put_back": back} def check(): rec = record() if rec is None: return {"present": False} cache = {} hits = {} off = [] for n in rec["added"]: ob = bpy.data.objects.get(n) if ob is None: continue near = _neighbours(ob, cache) h = _clashes(_geo_of(ob), near) if h: hits[n] = h # the back still on the box's wall plane: the prop's local z = 0 face d = rec["placed"].get(ob.get("replaces", ""), {}) off.append(n) return {"present": True, "props": len(rec["added"]), "crossings": hits, "replaced_boxes": len(rec["removed"])}