""" Props for Anchorage, made ready once from the user's assets (2026-10-21). The user: "There is a another asset "...scifi-fan-convector-heater.zip" put it meaningfully inside the facility in the corners couple of them", "add and place nutrition feeder to the facility walls", and "also use" nine more models from Assets\ and the Fab library. Each goes into source_fab//.blend as one collection, "PROP | " (build/furnish.py places them): * the model as its author made it, without its showroom: the ground plane it was shown on, cameras, lights, and helpers nothing refers to * at a real-world size (the Sketchfab scenes come in their authors' units; SIZE below says which measure sets it), standing on its base at the origin * its textures as files beside it (textures/), relinked by file name (the files name their authors' disks), at most 2048 px (MAX_TEX, ImageMagick): like the kit's 1K and the scans' 2K copies. The props come with 4K maps, about 120 of them; at full size the furnished station did not fit the GPU next to the user's open Blender (EEVEE stalled without an error, 2026-10-21). The 4K originals stay in Assets/ and the Fab library. * names prefixed with the key, so nothing clashes with the station's blender -b --factory-startup --python build/props_prepare.py -- <key> [<key> ...] (keys: ASSETS) The source files are not changed. """ import os import re import shutil import subprocess import sys import bpy from mathutils import Matrix, Vector sys.dont_write_bytecode = True HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) OUT = os.path.join(ROOT, "source_fab") MAX_TEX = 2048 MAGICK = r"E:\Tools\ImageMagick-7.1.2-Q16-HDRI\magick.exe" def shrink(path): """The texture file at most MAX_TEX on its longer side (in place); True if it was made smaller.""" if not os.path.isfile(MAGICK): return False w = subprocess.run([MAGICK, "identify", "-format", "%w %h", path], capture_output=True, text=True).stdout.split() if len(w) != 2 or max(int(w[0]), int(w[1])) <= MAX_TEX: return False tmp = path + ".tmp" + os.path.splitext(path)[1] cmd = [MAGICK, path, "-resize", "%dx%d>" % (MAX_TEX, MAX_TEX)] if path.lower().endswith((".jpg", ".jpeg")): cmd += ["-quality", "92"] subprocess.run(cmd + [tmp], check=True) os.replace(tmp, path) return True A = os.path.join(os.path.dirname(os.path.dirname(ROOT)), "Assets") FAB = os.path.join(A, "VaultCache", "FabLibrary") # key: title, source, texture folders, objects left out, the measure that sets the size ("z": height, "x"/"y": the # box's extent along that axis; of the named object if one is given), split (part key -> the empty heading the part) ASSETS = { "fan_heater": dict( title="Fan convector heater", src=os.path.join(OUT, "fan_heater", "raw", "source", "present_76_fan_low.fbx"), tex=[os.path.join(OUT, "fan_heater", "raw", "textures")], drop=["ground_low"], size=("z", 1.10, None), note="Assets/scifi-fan-convector-heater.zip"), "triangle_container": dict( title="Triangle container", src=os.path.join(A, "sci_fi_triangle_container.glb"), tex=[], drop=[], size=None, note="Assets/sci_fi_triangle_container.glb (1.0 m as its author made it)"), "crate_container": dict( title="Crate container", src=os.path.join(A, "scifi_crate_container.glb"), tex=[], drop=[], size=None, split={"crate_container_a": "crate.008_low_2", "crate_container_b": "crate.004_low.001_0", "crate_container_c": "crate.008_low.004_1"}, note="Assets/scifi_crate_container.glb: three crates shown side by side; each is a prop of its own"), "epsilon_container": dict( title="Epsilon container", src=os.path.join(A, "sci_fi_container_epsilon.glb"), tex=[], drop=[], size=None, note="the Fab library's Sci-fi container Epsilon (its .blend names four textures the cache does not have); " "Assets/sci_fi_container_epsilon.glb is the same model with them"), "scifi_crate": dict( title="Scifi crate", src=os.path.join(FAB, "Scifi_-_Crate-d3f1828b", "fbx", "scifi-crate_extracted", "source", "p_80_crate_sf.fbx"), tex=[os.path.join(FAB, "Scifi_-_Crate-d3f1828b", "fbx", "scifi-crate_extracted", "textures")], drop=["ground_low"], size=("x", 1.30, "crate_low"), note="Fab: Scifi - Crate (the crate under its net)"), "container_005": dict( title="Scifi container 005", src=os.path.join(FAB, "Star_Citizen_Workflow_to_Sketchfab_-_Decals_10-25cebcbc", "blender", "star-citizen-workflow-to_extracted", "source", "p_137_ScifiContainer_005.blend"), tex=[os.path.join(FAB, "Star_Citizen_Workflow_to_Sketchfab_-_Decals_10-25cebcbc", "blender", "star-citizen-workflow-to_extracted", "textures")], drop=[], size=("z", 1.20, None), note="Fab: Star Citizen Workflow to Sketchfab - Decals 10"), "lantern": dict( title="Lantern", src=os.path.join(FAB, "Star_Citizen_Workflow_to_Sketchfab__-_Decals_3-efa5a102", "blender", "star-citizen-workflow-to_extracted", "source", "p_124_Lantern_2.blend"), tex=[os.path.join(FAB, "Star_Citizen_Workflow_to_Sketchfab__-_Decals_3-efa5a102", "blender", "star-citizen-workflow-to_extracted", "textures")], drop=["ground_low", "Cube_stash_0"], size=("x", 0.55, None), note="Fab: Star Citizen Workflow to Sketchfab - Decals 3", # its tubes glow orange in the Fab picture; the .blend has them green emission={"M_Emit": ((1.0, 0.42, 0.08, 1.0), 6.0)}), "irrigation_robot": dict( title="Irrigation control robot", src=os.path.join(FAB, "Irrigation_control_Robot-4d2f7212", "fbx", "irrigation-control-robot_extracted", "source", "present_74_irrigation_sf.fbx"), tex=[os.path.join(FAB, "Irrigation_control_Robot-4d2f7212", "fbx", "irrigation-control-robot_extracted", "textures")], drop=["ground_low"], size=("z", 1.30, None), note="Fab: Irrigation control Robot (with its potted plant)"), "battery": dict( title="Portable battery", src=os.path.join(FAB, "PPB_-_Pro_Portable_Battery-9afef9cc", "fbx", "ppb-pro-portable-battery_extracted", "source", "present_38_battery_sf.fbx"), tex=[os.path.join(FAB, "PPB_-_Pro_Portable_Battery-9afef9cc", "fbx", "ppb-pro-portable-battery_extracted", "textures")], drop=["ground_low"], size=("z", 0.62, None), note="Fab: PPB - Pro Portable Battery (with its hoses)"), # the wall unit of models/nutrition-feeder (2026-10-21), without its collision hulls and studio; its origin stays on # the wall's face at the panel's bottom edge, the injector at rest (inject 0) "nutrition_feeder": dict( title="Nutrition feeder", src=os.path.join(os.path.dirname(ROOT), "nutrition-feeder", "NutritionFeeder.blend"), tex=[], drop=["Studio_Wall"], drop_prefix=["UCX_"], size=None, keep_origin=True, note="models/nutrition-feeder/NutritionFeeder.blend (the user's picture, 2026-10-21): on a wall, its back to it"), "assist_cart": dict( title="Assist cart", src=os.path.join(FAB, "Cart-34e119a0", "blender", "cart_extracted", "source", "present_37_assist-cart_sf.blend"), tex=[os.path.join(FAB, "Cart-34e119a0", "blender", "cart_extracted", "textures")], drop=["ground_low"], size=("z", 1.05, None), note="Fab: Cart (with its books)", map={"books_low_albedo.jpg": "present_37_assist-cart_low_books_1_BaseCol.jpg", "books_low_metallic.jpg": "present_37_assist-cart_low_books_1_Metalli.jpg", "books_low_normal.jpg": "present_37_assist-cart_low_books_1_Normal.jpg", "books_low_roughness.jpg": "present_37_assist-cart_low_books_1_Roughne.jpg", "books_low_rouhgness.jpg": "present_37_assist-cart_low_books_1_Roughne.jpg", "book_pile.jpg": "present_37_assist-cart_low_books_pile_Base.jpg", "books_pile_low_albedo.jpg": "present_37_assist-cart_low_books_pile_Base.jpg", "books_pile_low_metallic.jpg": "present_37_assist-cart_low_books_pile_Meta.jpg", "books_pile_low_normal.jpg": "present_37_assist-cart_low_books_pile_Norm.jpg", "books_pile_low_roughness.jpg": "present_37_assist-cart_low_books_pile_Roug.jpg", "books_pile_low_rouhgness.jpg": "present_37_assist-cart_low_books_pile_Roug.jpg"}), } def load(path): if path.lower().endswith(".blend"): bpy.ops.wm.open_mainfile(filepath=path, load_ui=False) else: bpy.ops.wm.read_factory_settings(use_empty=True) if path.lower().endswith((".glb", ".gltf")): bpy.ops.import_scene.gltf(filepath=path) else: bpy.ops.import_scene.fbx(filepath=path) bpy.data.use_autopack = False def refs(ob): out = set() if ob.parent is not None: out.add(ob.parent) for m in list(ob.modifiers) + list(ob.constraints): for p in m.bl_rna.properties: if p.type == "POINTER": v = getattr(m, p.identifier, None) if isinstance(v, bpy.types.Object): out.add(v) return out def norm(s): return re.sub(r"[^a-z0-9]+", "", s.lower()) def users_of(im): out = [] for m in bpy.data.materials: if m.node_tree and any(getattr(n, "image", None) == im for n in m.node_tree.nodes): out.append(m.name) return out def relink(tex_dirs, explicit=None): """Point every image whose file is missing at a file of the same name (or the best match) in tex_dirs: the same file name; the same name without punctuation; a file name the exporter cut short (its name begins the wanted one); names like "normal.p_137_X_Normal" for the file p_137_X_Normal_normal.png; or `explicit`.""" files = {} for d in tex_dirs: for dp, dn, fn in os.walk(d): for f in fn: files.setdefault(f.lower(), os.path.join(dp, f)) fixed, still = 0, [] for im in bpy.data.images: if im.packed_file is not None or im.source != "FILE" or not im.filepath: continue if os.path.isfile(bpy.path.abspath(im.filepath)): continue base = os.path.basename(bpy.path.abspath(im.filepath).replace("\\", "/")).lower() hit = None if explicit: want = explicit.get(base) or explicit.get(im.name.lower()) hit = files.get(want.lower()) if want else None hit = hit or files.get(base) if hit is None: key = norm(im.name.split(".")[-1] + im.name.split(".")[0]) if "." in im.name else norm(im.name) stem = norm(os.path.splitext(base)[0]) for f, p in files.items(): n = norm(os.path.splitext(f)[0]) if n == stem or n == key or (len(stem) > 6 and stem in n) or (len(n) >= 12 and stem.startswith(n)): hit = p break if hit: im.filepath = hit im.reload() fixed += 1 elif users_of(im): still.append("%s (in %s)" % (im.name, ", ".join(users_of(im))[:60])) return fixed, still COLOR_WORDS = ("albedo", "basecolor", "base_color", "basecol", "diffuse", "color") def fix_materials(tex_dirs, emission=None): """What an import leaves wrong, set right: * a base colour texture the FBX did not connect (its file lies beside the others): connected * image nodes whose file is not there (the decal atlases name maps the Fab cache does not hold): disconnected, so the input falls back to its default instead of drawing magenta * emission: {material name: (colour, strength)} where the file's own setting differs from its published look""" files = {} for d in tex_dirs: for dp, dn, fn in os.walk(d): for f in fn: files[f.lower()] = os.path.join(dp, f) linked, cut = 0, 0 for m in bpy.data.materials: if not m.users or not m.node_tree: continue nt = m.node_tree for n in list(nt.nodes): if n.type == "TEX_IMAGE" and n.image is not None and n.image.packed_file is None and n.image.source == "FILE" \ and not os.path.isfile(bpy.path.abspath(n.image.filepath)): for out in n.outputs: for l in list(out.links): nt.links.remove(l) cut += 1 for n in nt.nodes: if n.type != "BSDF_PRINCIPLED" or n.inputs["Base Color"].is_linked: continue stem = norm(m.name.split(" ", 1)[-1]) hit = None for f, p in files.items(): nf = norm(os.path.splitext(f)[0]) if nf.startswith(stem) and any(nf.endswith(w.replace("_", "")) for w in COLOR_WORDS): hit = p break if hit: tex = nt.nodes.new("ShaderNodeTexImage") tex.image = bpy.data.images.load(hit, check_existing=True) tex.location = (n.location.x - 400, n.location.y + 200) nt.links.new(tex.outputs["Color"], n.inputs["Base Color"]) linked += 1 if emission and m.name.split(" ", 1)[-1] in emission: col, strength = emission[m.name.split(" ", 1)[-1]] for n in nt.nodes: if n.type == "BSDF_PRINCIPLED": n.inputs["Emission Color"].default_value = col n.inputs["Emission Strength"].default_value = strength return linked, cut def visible_box(objs, dg): lo, hi = Vector((1e9,) * 3), Vector((-1e9,) * 3) tris = 0 for o in objs: if o.type != "MESH" or not o.visible_get(): continue ev = o.evaluated_get(dg) me = ev.to_mesh() tris += sum(len(p.vertices) - 2 for p in me.polygons) for v in me.vertices: p = o.matrix_world @ v.co lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) ev.to_mesh_clear() return lo, hi, tris def prepare(key, cfg, part=None, head=None): load(cfg["src"]) dg = bpy.context.evaluated_depsgraph_get() obs = list(bpy.data.objects) drop = {o for o in obs if o.name in cfg["drop"] or o.type in ("CAMERA", "LIGHT") or any(o.name.startswith(p) for p in cfg.get("drop_prefix", ()))} if head is not None: top = bpy.data.objects[head] sub = {top} todo = [top] while todo: o = todo.pop() for c in o.children: sub.add(c) todo.append(c) drop |= {o for o in obs if o not in sub} # a showroom floor: a flat mesh far wider than the rest meshes = [o for o in obs if o.type == "MESH" and o not in drop and o.visible_get()] for o in meshes: d = o.dimensions if d.z < 0.002 * max(d.x, d.y, 1e-6) and max(d.x, d.y) > 8.0 * sorted(max(m.dimensions) for m in meshes)[len(meshes) // 2]: drop.add(o) keep = {o for o in obs if o not in drop and o.visible_get()} need, todo = set(), list(keep) while todo: o = todo.pop() for r in refs(o): if r not in keep and r not in need and r not in drop: need.add(r) todo.append(r) for o in need: if not o.visible_get(): o.hide_render = o.hide_viewport = True keep |= need # with a head empty, keep its parents' transform (glTF nests the parts in scaled/rotated empties) fixed, still = relink(cfg["tex"], cfg.get("map")) lo, hi, tris = visible_box(keep, dg) s = 1.0 if cfg.get("size"): axis, want, named = cfg["size"] if named: o = bpy.data.objects[named] l2, h2, _ = visible_box([o], dg) else: l2, h2 = lo, hi have = (h2 - l2)["xyz".index(axis)] s = want / have c = Vector(((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0, lo.z)) if not cfg.get("keep_origin") else Vector() kk = part or key root = bpy.data.objects.new("%s ROOT" % kk, None) root.empty_display_size = 0.2 col = bpy.data.collections.new("PROP | %s" % (cfg["title"] if part is None else "%s %s" % (cfg["title"], part[-1].upper()))) bpy.context.scene.collection.children.link(col) col.objects.link(root) for o in keep: for cc in list(o.users_collection): cc.objects.unlink(o) col.objects.link(o) for o in keep: if o.parent is None or o.parent not in keep: mw = o.matrix_world.copy() o.parent = root o.matrix_parent_inverse = Matrix.Identity(4) o.matrix_world = mw root.matrix_world = Matrix.Translation(-c * s) @ Matrix.Scale(s, 4) dropped = sorted(o.name for o in obs if o not in keep) for o in obs: if o not in keep: bpy.data.objects.remove(o) for cc in list(bpy.data.collections): if cc is not col and not cc.objects and not cc.children: bpy.data.collections.remove(cc) for o in keep: o.name = "%s %s" % (kk, o.name) for m in bpy.data.materials: if m.users and not m.name.startswith(kk + " "): m.name = "%s %s" % (kk, m.name) n_link, n_cut = fix_materials(cfg["tex"], cfg.get("emission")) col["prop_key"] = kk col["prop_title"] = col.name[len("PROP | "):] col["prop_source"] = cfg["note"] col["prop_scale"] = s # textures beside it out_dir = os.path.join(OUT, kk) tex_dir = os.path.join(out_dir, "textures") os.makedirs(tex_dir, exist_ok=True) n_tex = 0 for im in bpy.data.images: if not im.users: continue if im.packed_file is not None: data = bytes(im.packed_file.data) ext = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg" name = re.sub(r"[^A-Za-z0-9._+-]+", "_", im.name).strip("_") or "image" p = os.path.join(tex_dir, name if name.lower().endswith(ext) else name + ext) with open(p, "wb") as fh: fh.write(data) im.filepath = p try: im.unpack(method="REMOVE") except Exception: # noqa: BLE001 pass n_tex += 1 elif im.source == "FILE" and im.filepath and os.path.isfile(bpy.path.abspath(im.filepath)): src = bpy.path.abspath(im.filepath) p = os.path.join(tex_dir, os.path.basename(src)) if os.path.normcase(src) != os.path.normcase(p): shutil.copyfile(src, p) im.filepath = p n_tex += 1 else: continue if shrink(im.filepath): im.reload() dg = bpy.context.evaluated_depsgraph_get() lo2, hi2, tris2 = visible_box(list(col.objects), dg) path = os.path.join(out_dir, kk + ".blend") bpy.ops.wm.save_as_mainfile(filepath=path, relative_remap=True, compress=True) size = hi2 - lo2 print("[prop] %-20s scale %.4f -> %.2f x %.2f x %.2f m (base z %.3f), %d triangles, %d objects kept, %d left out (%s); " "%d textures, %d relinked, %d colour maps connected, %d links of missing maps cut, still missing %s; %s" % ( kk, s, size.x, size.y, size.z, lo2.z, tris2, len(col.objects), len(dropped), ", ".join(dropped[:8]), n_tex, fixed, n_link, n_cut, still[:4], path)) keys = sys.argv[sys.argv.index("--") + 1:] for key in keys: cfg = ASSETS[key] try: if cfg.get("split"): for part, head in cfg["split"].items(): prepare(key, cfg, part, head) else: prepare(key, cfg) except Exception as e: # noqa: BLE001 import traceback print("[prop] %s FAILED: %s" % (key, e)) traceback.print_exc()