"""Door robot arm: the one entry point of the build (models/door-robot-arm). Run headless, one stage per call (keep each call under about 9 minutes): "E:\\Steam\\steamapps\\common\\Blender\\blender.exe" -b --factory-startup --python build/dra_build.py -- --stage Stages geo new file: materials (plain colours), scenes, the ship context copied in, every link mesh of the arm (both tool versions), the rail kits, door fixtures and chain links in the ship scene (rest, no drivers), the showroom with both versions side by side, UVs. Backs up DoorRobotArm.blend first. (Coder stage 1) geo_audit reads DoorRobotArm.blend, writes build/geo_report.json (topology, open edges, duplicates, crossings) geo_preview Workbench pictures into previews/geo/ (--views ref1,ref2,... to pick) geo_sweep each joint turned through its range (others at rest) and the grip closed onto the knob fixture, real meshes -> build/geo_sweep.json rig (Coder stage 2) opens the file made by 'geo' and adds the rig in the ship scene: four arms (AFT/FORE x A/B) from dra_rigdata, solver empties, simple-expression drivers from the doors' location, the energy chains, door fixtures, the version switch and the grip (dra_rig.py); writes build/rig_drivers.json build geo + rig in one call (the usual way to remake the file) rig_check every frame 1-120, both doors, both versions: pose against the door, carriage travel, joint limits, crossings and clearances (dra_rigcheck.py) -> build/rig_report.json rig_render Cycles pictures of the arms in the ship scene -> previews/rig/ (dra_render.py) surf (stage 3) the UV0 atlas packed, every material dressed from real texture sets, stand-in colours for the ship context's missing images, the light rig '90 STUDIO | lights (not part of the asset)'; saved with the showroom scene active (dra_surf.py). Run bake after it whenever the geometry changed. bake (stage 3) bakes textures/T_DoorArm_Masks.png and T_DoorArm_BevelNormal.png (Cycles, GPU) ucx (stage 3) UCX_ convex hulls per link in 'DoorArm | collision' (eye off) (dra_export.py) export (stage 3) FBX into fbx/ (skeletal per version with LOD0-2, animations, static parts with UCX, the rail kits, fixtures, chain), DoorRobotArm_layout.json (dra_export.py); then verify_fbx verify_fbx re-imports every FBX headless and checks bones, meshes, materials, scale -> build/fbx_report.json render (stage 3) Cycles pictures into previews/ (dra_cycles.py) Options: --links name,name (geo: build only these links, for quick tests; nothing is saved over the real file unless all links are built, see --out), --out . """ import sys import os import json import math import time import shutil import importlib HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) if HERE not in sys.path: sys.path.insert(0, HERE) BLEND = os.path.join(ROOT, "DoorRobotArm.blend") CONTEXT_BLEND = os.path.join(ROOT, "source", "ship_context_2026-10-16.blend") DESIGN = os.path.join(ROOT, "design.json") import bpy # noqa: E402 from mathutils import Vector, Matrix # noqa: E402 def args(): a = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] out = {"stage": "geo", "links": None, "out": None, "views": None, "name": None, "prevdir": None, "res": None} i = 0 while i < len(a): k = a[i].lstrip("-") if k in out and i + 1 < len(a): out[k] = a[i + 1] i += 2 else: i += 1 return out def log(*m): print("[dra]", *m, flush=True) def reload_all(): import dra_lib, dra_shapes, dra_parts_base, dra_rigdata for m in (dra_lib, dra_shapes, dra_parts_base, dra_rigdata): importlib.reload(m) for name in ("dra_parts_turret", "dra_parts_arm", "dra_parts_wrist", "dra_parts_scene"): try: importlib.reload(importlib.import_module(name)) except ImportError: pass # ------------------------------------------------------------------ backup def backup(name="auto"): if not os.path.exists(BLEND): return None vdir = os.path.join(ROOT, "versions") os.makedirs(vdir, exist_ok=True) nums = [int(d[1:3]) for d in os.listdir(vdir) if d[:1] == "v" and d[1:3].isdigit()] nn = (max(nums) + 1) if nums else 1 dst = os.path.join(vdir, "v%02d_%s" % (nn, name)) os.makedirs(dst, exist_ok=True) shutil.copy2(BLEND, os.path.join(dst, "DoorRobotArm.blend")) log("backup ->", dst) return dst # ------------------------------------------------------------------ materials (plain colours; the surf stage dresses them) MAT_COLORS = { "M_DoorArm_PaintWhite": ((0.80, 0.80, 0.78, 1), 0.0, 0.45), "M_DoorArm_Gunmetal": ((0.21, 0.215, 0.23, 1), 0.9, 0.38), "M_DoorArm_Steel": ((0.62, 0.62, 0.63, 1), 1.0, 0.22), "M_DoorArm_Rubber": ((0.025, 0.025, 0.027, 1), 0.0, 0.7), "M_DoorArm_Orange": ((0.86, 0.16, 0.012, 1), 0.0, 0.45), "M_DoorArm_Glass": ((0.02, 0.02, 0.022, 1), 0.0, 0.08), "M_DoorArm_Lamp": ((0.85, 0.9, 1.0, 1), 0.0, 0.3), } def make_materials(): for name, (col, met, rough) in MAT_COLORS.items(): m = bpy.data.materials.get(name) or bpy.data.materials.new(name) m.diffuse_color = col m.metallic = met m.roughness = rough m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") if b: b.inputs["Base Color"].default_value = col b.inputs["Metallic"].default_value = met b.inputs["Roughness"].default_value = rough if name == "M_DoorArm_Lamp": b.inputs["Emission Color"].default_value = col b.inputs["Emission Strength"].default_value = 2.0 if name == "M_DoorArm_Glass": b.inputs["Coat Weight"].default_value = 1.0 # ------------------------------------------------------------------ collections and scenes def coll(name, parent): c = bpy.data.collections.get(name) if c is None: c = bpy.data.collections.new(name) if c.name not in [x.name for x in parent.children]: parent.children.link(c) return c def scenes(): sc_show = bpy.context.scene sc_show.name = "DoorArm | Showroom" sc_fit = bpy.data.scenes.get("DoorArm | Ship fit") or bpy.data.scenes.new("DoorArm | Ship fit") for sc in (sc_show, sc_fit): sc.unit_settings.system = 'METRIC' sc.render.fps = 30 sc_fit.frame_start, sc_fit.frame_end = 1, 120 sc_show.frame_start, sc_show.frame_end = 1, 1 sc_fit.timeline_markers.clear() for nm, f in (("CLOSED", 1), ("RETRACTED INBOARD", 12), ("OPEN / PANELS IN POCKETS", 48), ("CLOSE", 108)): sc_fit.timeline_markers.new(nm, frame=f) return sc_show, sc_fit CONTEXT_COLL = "00 CONTEXT | from the ship (reference, not part of the asset)" def copy_context(sc_fit): """append the ship context objects (doors with their actions, rails, pocket, deck, lamps) as a reference copy""" c = coll(CONTEXT_COLL, sc_fit.collection) with bpy.data.libraries.load(CONTEXT_BLEND, link=False) as (src, dst): dst.objects = list(src.objects) for o in dst.objects: if o is None: continue c.objects.link(o) if "rail carriage" in o.name or o.name.endswith("| upper rail"): # the user's carriage stand-ins, and (scale-up 2026-10-18) the user's rail boxes: the arm brings its own # rail beam on the new rail line; in the ship they are hidden the same way (dra_ship_install.py) o.hide_viewport = True o.hide_render = True o["reference_only"] = True log("context objects:", len([o for o in dst.objects if o])) return c # ------------------------------------------------------------------ links -> meshes def build_links(only=None): import dra_rigdata as rd meshes = {} for spec in rd.LINKS: name = spec["name"] if only and name not in only: continue t0 = time.time() L = spec["build"]() me = rd.finish_link(L, spec) meshes[name] = me log("link %-28s %7d faces %6.1fs" % (name, len(me.polygons), time.time() - t0)) return meshes def add_uvs(meshes): """UV_Tile (metres, box projection) and UVMap (smart project, packed per mesh) on every mesh""" import dra_lib sc = bpy.context.scene vl = bpy.context.view_layer for name, me in meshes.items(): while len(me.uv_layers): me.uv_layers.remove(me.uv_layers[0]) uv = me.uv_layers.new(name="UVMap") # UV0: the bake atlas (repacked at the bake) me.uv_layers.active = uv for p in me.polygons: p.select = True ob = bpy.data.objects.new("tmp uv " + name, me) sc.collection.objects.link(ob) vl.update() vl.objects.active = ob ob.select_set(True) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=math.radians(60), island_margin=0.004, area_weight=0.0, correct_aspect=True, scale_to_bounds=False) bpy.ops.object.mode_set(mode='OBJECT') bpy.data.objects.remove(ob) dra_lib.uv_tile(me) # UV1: metres, for the tiling scans me.uv_layers.active = me.uv_layers["UVMap"] me.uv_layers["UVMap"].active_render = True def stage_geo(a): reload_all() import dra_rigdata as rd only = set(a["links"].split(",")) if a["links"] else None out = a["out"] or (BLEND if not only else os.path.join(ROOT, "build", "_test_geo.blend")) if out == BLEND and os.path.exists(BLEND): backup(a.get("name") or "before_geo") bpy.ops.wm.read_factory_settings(use_empty=True) make_materials() sc_show, sc_fit = scenes() t0 = time.time() meshes = build_links(only) if not only: add_uvs(meshes) log("uvs %.1fs" % (time.time() - t0)) import dra_parts_scene as ps if not only: copy_context(sc_fit) ps.build_showroom(sc_show, meshes) if not only: ps.build_ship_fit(sc_fit, meshes) extra = {me.name: me for me in bpy.data.meshes if me.name not in [m.name for m in meshes.values()] and (me.name.startswith("DoorArm") or me.name == "Showroom | floor rail")} add_uvs(extra) log("uvs on %d scene meshes" % len(extra)) bpy.ops.wm.save_as_mainfile(filepath=out, compress=True) log("saved", out, "%.1fs" % (time.time() - t0)) def stage_geo_audit(a): reload_all() import dra_audit importlib.reload(dra_audit) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) rep = dra_audit.audit() with open(os.path.join(HERE, "geo_report.json"), "w") as f: json.dump(rep, f, indent=1) log("audit written", rep.get("summary")) def stage_geo_preview(a): reload_all() import dra_preview importlib.reload(dra_preview) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) dra_preview.render(a["views"].split(",") if a["views"] else None, a["prevdir"] or os.path.join(ROOT, "previews", "geo"), res=int(a["res"]) if a["res"] else 1000) def stage_geo_sweep(a): reload_all() import dra_audit, dra_sweep importlib.reload(dra_audit) importlib.reload(dra_sweep) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) rep = dra_sweep.run() with open(os.path.join(HERE, "geo_sweep.json"), "w") as f: json.dump(rep, f, indent=1) log("sweep written", rep.get("summary")) def ship_scene_active(): sf = bpy.data.scenes["DoorArm | Ship fit"] wm = bpy.context.window_manager if wm.windows: wm.windows[0].scene = sf return sf def stage_rig(a, path=None, backup_first=True): reload_all() import dra_rig importlib.reload(dra_rig) import dra_rigdata as rd path = path or a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) if bpy.data.objects.get(dra_rig.CONTROLS) is not None: raise SystemExit("the file already has a rig: run --stage geo (or build) first") sf = ship_scene_active() sf.frame_set(1) meshes = {spec["name"]: bpy.data.meshes.get(rd.mesh_name(spec["name"])) for spec in rd.LINKS} t0 = time.time() arms, report = dra_rig.build(sf, meshes) sf.frame_set(2) sf.frame_set(1) rep = dra_rig.finish_report(report) with open(os.path.join(HERE, "rig_drivers.json"), "w") as f: json.dump(rep, f, indent=1) log("rig: %d drivers, not simple or invalid: %d (%.1fs)" % (rep["drivers"], len(rep["not_simple_or_invalid"]), time.time() - t0)) if path == BLEND and backup_first: backup(a.get("name") or "before_rig") bpy.ops.wm.save_as_mainfile(filepath=path, compress=True) log("saved", path) def stage_build(a): out = a["out"] or BLEND a2 = dict(a) a2["out"] = out stage_geo(a2) stage_rig(a2, path=out, backup_first=False) # the geo stage has just backed up the old file stage_surf(a2, path=out) # materials and the atlas; the bake is its own call def stage_rig_check(a): reload_all() import dra_rigcheck importlib.reload(dra_rigcheck) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) sf = ship_scene_active() rep = dra_rigcheck.run(sf, step=int(a["res"]) if a["res"] else 1, only=a["views"]) out = os.path.join(HERE, "rig_report.json") if not a["prevdir"] else a["prevdir"] with open(out, "w") as f: json.dump(rep, f, indent=1) log("rig check written", out, json.dumps(rep.get("summary"))[:2000]) def stage_rig_render(a): reload_all() import dra_render importlib.reload(dra_render) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) sf = ship_scene_active() dra_render.render(sf, a["views"].split(",") if a["views"] else None, a["prevdir"] or os.path.join(ROOT, "previews", "rig"), res=int(a["res"]) if a["res"] else None) def showroom_active(): sc = bpy.data.scenes["DoorArm | Showroom"] wm = bpy.context.window_manager if wm.windows: wm.windows[0].scene = sc return sc def stage_surf(a, path=None): reload_all() import dra_surf importlib.reload(dra_surf) path = path or a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) t0 = time.time() rep = {"atlas": dra_surf.pack_atlas()} rep["materials"] = dra_surf.dress_all() rep["stand_ins"] = dra_surf.stand_ins() dra_surf.light_rig(bpy.data.scenes["DoorArm | Ship fit"]) rep["ship_camera"] = dra_surf.ship_view(bpy.data.scenes["DoorArm | Ship fit"]) dra_surf.studio_look() rep["table"] = dra_surf.surface_table() rep["seconds"] = round(time.time() - t0, 1) with open(os.path.join(HERE, "surf_report.json"), "w") as f: json.dump(rep, f, indent=1) showroom_active() bpy.ops.wm.save_as_mainfile(filepath=path, compress=True) try: bpy.ops.file.make_paths_relative() # the textures by relative path (the folder can move with its neighbours) bpy.ops.wm.save_as_mainfile(filepath=path, compress=True) except Exception as ex: log("paths kept absolute:", ex) log("surf:", json.dumps(rep["atlas"]), "saved", path) def stage_bake(a): reload_all() import dra_surf importlib.reload(dra_surf) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) only = set(a["views"].split(",")) if a["views"] else None rep = dra_surf.bake(only) with open(os.path.join(HERE, "bake_report.json"), "w") as f: json.dump(rep, f, indent=1) showroom_active() bpy.ops.wm.save_as_mainfile(filepath=path, compress=True) log("bake:", rep["device"], rep["seconds"], "s; saved", path) def stage_ucx(a): reload_all() import dra_export importlib.reload(dra_export) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) rep = dra_export.make_ucx() with open(os.path.join(HERE, "ucx_report.json"), "w") as f: json.dump(rep, f, indent=1) showroom_active() bpy.ops.wm.save_as_mainfile(filepath=path, compress=True) log("ucx:", rep.get("summary"), "saved", path) def stage_export(a): reload_all() import dra_export importlib.reload(dra_export) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) rep = dra_export.export(a["views"].split(",") if a["views"] else None) with open(os.path.join(HERE, "export_report.json"), "w") as f: json.dump(rep, f, indent=1) log("export:", json.dumps(rep.get("summary"))[:1500]) def stage_verify_fbx(a): reload_all() import dra_verify_fbx importlib.reload(dra_verify_fbx) rep = dra_verify_fbx.run() with open(os.path.join(HERE, "fbx_report.json"), "w") as f: json.dump(rep, f, indent=1) log("verify_fbx:", rep.get("verdict")) def stage_render(a): reload_all() import dra_cycles importlib.reload(dra_cycles) path = a["out"] or BLEND bpy.ops.wm.open_mainfile(filepath=path) dra_cycles.render(a["views"].split(",") if a["views"] else None, a["prevdir"] or os.path.join(ROOT, "previews"), res=int(a["res"]) if a["res"] else None) def main(): a = args() st = a["stage"] fn = {"geo": stage_geo, "geo_audit": stage_geo_audit, "geo_preview": stage_geo_preview, "geo_sweep": stage_geo_sweep, "rig": stage_rig, "build": stage_build, "rig_check": stage_rig_check, "rig_render": stage_rig_render, "surf": stage_surf, "bake": stage_bake, "ucx": stage_ucx, "export": stage_export, "verify_fbx": stage_verify_fbx, "render": stage_render}.get(st) if fn is None: raise SystemExit("unknown stage '%s'" % st) t0 = time.time() fn(a) log("stage %s done in %.1fs" % (st, time.time() - t0)) if __name__ == "__main__": main()