"""Door robot arm: real surfaces (Coder stage 3; stages 'surf' and 'bake' of dra_build.py). What the pictures show (ref1-ref3): satin white powder-coat paint with a fine texture, dark gunmetal and black metal on the bearings, inner plates and the carriage's stacked plates, black rubber cables, bright steel screws and pins, small orange accent rings and markings. The ship is a clean, grayish-white vessel: wear is low. Made from real texture data, as the project's other models (docking arm surfaces.py, pressure door pd_look.py): * Poly Haven `blue_metal_plate` (CC0, `source_polyhaven/`, fetched with curl): a scan of a painted steel plate. Its relief, roughness and scuffs lie under the white and orange paint (its colour is not used: the tint is), and under the dark metals as light and dark; * the user's own pack (models/vessel-interior/textures/ModSci_Engineer, read in place): StainedSteel for the bright steel, ScratchedMetal's roughness for the rubber, Glass_DirtRough as smudges, T_Scratch_Norm for fine scratches; * maps baked from the model itself into one atlas on UV0 (`UVMap`), shared by every arm mesh of both versions: textures/T_DoorArm_Masks.png (R ambient occlusion, G convex edges, B crevices) and textures/T_DoorArm_BevelNormal.png (hard edges rounded by 8 mm, tangent space, OpenGL). They put a little shade in crevices, chip the paint on exposed edges (sparingly) and let edges catch the light; * a fine orange-peel bump (procedural) on the powder coat. UV1 (`UV_Tile`, metres, box projection) carries the tiling sets. The rail kits, chain link, door fixtures and the showroom rail wear the same recipes without the baked atlas (their own materials, M_DoorArmKit_*); the cables and hoses (curves) a rubber in object space (M_DoorArm_Cable). surf pack the atlas (UV0 of every arm mesh in one square), dress every material, the stand-in colours for the ship context's missing images, the light rig '90 STUDIO | lights (not part of the asset)' in the ship-fit scene bake bake the two atlas maps (Cycles, GPU) into textures/ (run after surf; each in its own call) RED LOOK (2026-10-19, the user with ref8_red_metallic_paint.png: "texture the arms accoding to picture yes red color and that mettallic paint and everything bake it"). What ref8 shows, measured on the picture (crops in the scratchpad): * body: a pure red (G = B, no orange or crimson tilt), lit faces sRGB 243,41,41 .. 255,75,75, median linear 0.78 / 0.027 / 0.028 under the studio light; highlights go pink-white (a clear coat), soft and wide but clean; no visible wear. -> M_DoorArm_PaintRed: red base 0.50 / 0.020 / 0.018 linear, half metallic with fine flakes (a random normal per 0.4 mm cell and a little brightness noise, in the base layer), a full clear coat at roughness 0.10 with a slight orange peel on the coat's own normal, the scan's relief at a fifth of before, wear and grime low; * motor housings, caps, the tool plate: black, satin (sRGB 16..61 lit, soft sheen) -> M_DoorArm_Gunmetal; * the base plate: black, a little duller -> M_DoorArm_BlackMetal; * the bearing ring and screw heads: bright machined steel (sRGB 42..226 on the ring: a clean, sharp reflection) -> M_DoorArm_Steel brighter, smoother, less stained; * the cable sleeves: black braided sleeving, small glints on every crossing -> M_DoorArm_Cable (braided()); * the labels: light grey-white bow-tie "X" plates and small text -> M_DoorArm_Marking (the old orange markings and rings; no brand or maker text anywhere: the logo on ref8's tool plate is left out on purpose). Bake for Unreal (bake_look): T_DoorArm_BaseColor (sRGB), T_DoorArm_ORM (R occlusion, G roughness, B metallic, A clear coat weight), T_DoorArm_Normal (OpenGL), 4096, on UV0. """ import math import os import time import json import numpy as np import bpy import bmesh from mathutils import Vector HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) TEX = os.path.join(ROOT, "textures") SCAN = os.path.join(ROOT, "source_polyhaven", "textures", "blue_metal_plate") PACK = os.path.normpath(os.path.join(ROOT, "..", "vessel-interior", "textures", "ModSci_Engineer", "Textures")) ATLAS = 4096 MASKS, BEVEL = "T_DoorArm_Masks", "T_DoorArm_BevelNormal" LOOK = dict(col="T_DoorArm_BaseColor", orm="T_DoorArm_ORM", nor="T_DoorArm_Normal") EDGE_R, BEVEL_R = 0.010, 0.008 LIGHTS = "90 STUDIO | lights (not part of the asset)" KIT_MATS = {"M_DoorArm_Gunmetal": "M_DoorArmKit_Gunmetal", "M_DoorArm_Steel": "M_DoorArmKit_Steel", "M_DoorArm_Rubber": "M_DoorArmKit_Rubber", "M_DoorArm_Marking": "M_DoorArmKit_Marking"} # the red look renamed two materials (and the kit's copy of one); the slots keep pointing at the same datablocks RENAMES = {"M_DoorArm_PaintWhite": "M_DoorArm_PaintRed", "M_DoorArm_Orange": "M_DoorArm_Marking", "M_DoorArmKit_Orange": "M_DoorArmKit_Marking"} RANGES = os.path.join(HERE, "surf_ranges.json") # the texture sets' brightness bands, so a live session skips the pixels BAKE_FLAT = False # bake_look sets it: nothing finer than an atlas texel (the flakes), one normal for base and coat def scan(m): return os.path.join(SCAN, "blue_metal_plate_%s_2k.jpg" % m) def pack(*p): return os.path.join(PACK, *p) # ------------------------------------------------------------------ which meshes def arm_meshes(): import dra_rigdata as rd return [bpy.data.meshes[rd.mesh_name(l["name"])] for l in rd.LINKS if bpy.data.meshes.get(rd.mesh_name(l["name"]))] def kit_meshes(): names = ["DoorArm | rail kit AFT", "DoorArm | rail kit FORE", "DoorArm | rail beam AFT", "DoorArm | rail beam FORE", "DoorArm | chain link", "Showroom | floor rail"] names += ["DoorArm_%s | door fixture %s" % (k, v) for k in ("AFT", "FORE") for v in ("A", "B")] return [bpy.data.meshes[n] for n in names if bpy.data.meshes.get(n)] def decal_faces(me): """indices of the floating marking faces (they are not baked: no shade from them, none on them)""" dec = list(me.get("decals", [])) lay = me.attributes.get('part') if not dec or lay is None: return set() pv = np.empty(len(me.polygons), np.int64) lay.data.foreach_get("value", pv) ids = [i for i, d in enumerate(dec) if d] return set(np.nonzero(np.isin(pv, ids))[0].tolist()) # ------------------------------------------------------------------ the atlas: UV0 of every arm mesh in one square def pack_atlas(force=False): """once per build: a second pack would move the islands away from the baked maps""" t0 = time.time() meshes = arm_meshes() if not force and meshes and all(me.get("atlas_packed") for me in meshes): return dict(meshes=len(meshes), method="kept (already packed; the bake matches it)", seconds=0.0) sc = bpy.context.scene vl = bpy.context.view_layer tmp = [] for me in meshes: me.uv_layers.active = me.uv_layers["UVMap"] o = bpy.data.objects.new("_uv_" + me.name, me) sc.collection.objects.link(o) tmp.append(o) vl.update() for o in vl.objects: o.select_set(False) for o in tmp: o.select_set(True) vl.objects.active = tmp[0] bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.select_all(action='SELECT') bpy.ops.uv.average_islands_scale() # one texel density on every mesh (the smart projects were per mesh) try: bpy.ops.uv.pack_islands(rotate=True, rotate_method='CARDINAL', scale=True, margin_method='FRACTION', margin=6.0 / ATLAS, shape_method='CONCAVE') how = "concave" except Exception as ex: print("[dra] concave pack failed (%s), box pack" % ex) bpy.ops.uv.pack_islands(rotate=True, scale=True, margin_method='FRACTION', margin=6.0 / ATLAS, shape_method='AABB') how = "box" bpy.ops.object.mode_set(mode='OBJECT') for o in tmp: bpy.data.objects.remove(o) for me in meshes: me["atlas_packed"] = True # texel density (px per metre on the atlas) and fill a3 = a2 = 0.0 for me in meshes: uv = np.empty(len(me.loops) * 2, np.float32) me.uv_layers["UVMap"].data.foreach_get("uv", uv) uv = uv.reshape(-1, 2) for p in me.polygons: q = uv[p.loop_start:p.loop_start + p.loop_total] a2 += 0.5 * abs(float(np.sum(q[:, 0] * np.roll(q[:, 1], -1) - np.roll(q[:, 0], -1) * q[:, 1]))) a3 += p.area return dict(meshes=len(meshes), method=how, seconds=round(time.time() - t0, 1), fill=round(a2, 3), px_per_m=round(math.sqrt(a2 * ATLAS * ATLAS / max(a3, 1e-9)), 1)) # ------------------------------------------------------------------ node helpers (the docking arm's / pressure door's) _RANGE = {} def tex_image(path, colorspace="sRGB"): path = os.path.normpath(path) for im in bpy.data.images: if im.filepath and os.path.normcase(os.path.normpath(bpy.path.abspath(im.filepath))) == os.path.normcase(path): return im im = bpy.data.images.load(path, check_existing=True) im.colorspace_settings.name = colorspace relative(im, path) return im def relative(im, path): """a saved file keeps its textures by relative path (the ship: //door-robot-arm/...); another drive stays absolute""" if bpy.data.filepath: try: im.filepath = bpy.path.relpath(path) except ValueError: pass def _ranges_file(): try: return json.load(open(RANGES)) except Exception: return {} def tex_range(im, lo=3, hi=97): key = "%s|%d|%d" % (os.path.basename(bpy.path.abspath(im.filepath)).lower(), lo, hi) if im.name not in _RANGE: cached = _ranges_file().get(key) if cached: _RANGE[im.name] = tuple(cached) return _RANGE[im.name] if im.name not in _RANGE: n, ch = im.size[0] * im.size[1], im.channels px = np.empty(n * ch, np.float32) im.pixels.foreach_get(px) px = px.reshape(n, ch) if ch >= 3: c = px[:, :3] if im.colorspace_settings.name == 'sRGB': c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) v = c @ np.array([0.2126, 0.7152, 0.0722], np.float32) else: v = px[:, 0] _RANGE[im.name] = (round(float(np.percentile(v, lo)), 4), round(float(np.percentile(v, hi)), 4)) try: # remember it for the live script d = _ranges_file() d[key] = list(_RANGE[im.name]) json.dump(d, open(RANGES, "w"), indent=1, sort_keys=True) except OSError: pass return _RANGE[im.name] class Tree: def __init__(self, mat): mat.use_nodes = True self.nt = mat.node_tree self.nt.nodes.clear() def node(self, kind, label=None, **props): n = self.nt.nodes.new(kind) if label: n.label = label for k, v in props.items(): setattr(n, k, v) i = len(self.nt.nodes) - 1 n.location = (-2400 + 240 * (i // 5), 600 - 300 * (i % 5)) return n def link(self, a, b): self.nt.links.new(a, b) def put(self, sock, v): if isinstance(v, bpy.types.NodeSocket): self.nt.links.new(v, sock) else: dv = sock.default_value if isinstance(v, tuple) and len(v) == 3 and hasattr(dv, "__len__") and len(dv) == 4: v = v + (1.0,) # a colour socket takes RGBA sock.default_value = v def math(self, op, a, b=None, clamp=False, label=None): n = self.node('ShaderNodeMath', label, operation=op, use_clamp=clamp) self.put(n.inputs[0], a) if b is not None: self.put(n.inputs[1], b) return n.outputs[0] def ranged(self, v, lo, hi, label=None, f0=0.0, f1=1.0, smooth=False): n = self.node('ShaderNodeMapRange', label) if smooth: n.interpolation_type = 'SMOOTHSTEP' self.put(n.inputs["Value"], v) n.inputs["From Min"].default_value = f0 n.inputs["From Max"].default_value = f1 n.inputs["To Min"].default_value = lo n.inputs["To Max"].default_value = hi return n.outputs["Result"] def mixc(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='RGBA') self.put(n.inputs[0], fac) self.put(n.inputs[6], a if isinstance(a, bpy.types.NodeSocket) else tuple(a) + (1.0,)) self.put(n.inputs[7], b if isinstance(b, bpy.types.NodeSocket) else tuple(b) + (1.0,)) return n.outputs[2] def mixf(self, fac, a, b, label=None): n = self.node('ShaderNodeMix', label, data_type='FLOAT') self.put(n.inputs[0], fac) self.put(n.inputs[2], a) self.put(n.inputs[3], b) return n.outputs[0] def coords(self, space): """'UV_Tile' / 'UVMap' (a UV layer) or 'OBJECT' (texture coordinates in object space, for the curves)""" if space == 'OBJECT': return self.node('ShaderNodeTexCoord').outputs["Object"] return self.node('ShaderNodeUVMap', uv_map=space).outputs["UV"] def mapped(self, src, scale, label=None): n = self.node('ShaderNodeMapping', label) self.nt.links.new(src, n.inputs["Vector"]) n.inputs["Scale"].default_value = (scale, scale, scale) return n.outputs["Vector"] def tex(self, im, vec, label=None): n = self.node('ShaderNodeTexImage', label, image=im) self.nt.links.new(vec, n.inputs["Vector"]) return n def up(self): g = self.node('ShaderNodeNewGeometry', "where dust settles") z = self.node('ShaderNodeSeparateXYZ') self.link(g.outputs["Normal"], z.inputs["Vector"]) return self.ranged(z.outputs["Z"], 0.0, 1.0, "up-facing", 0.35, 0.95, smooth=True), g def noise(self, g_or_vec, scale, detail=3.0, rough=0.55, label=None): n = self.node('ShaderNodeTexNoise', label) n.inputs["Scale"].default_value = scale n.inputs["Detail"].default_value = detail n.inputs["Roughness"].default_value = rough self.link(g_or_vec, n.inputs["Vector"]) return n.outputs["Fac"] def masks_of(t, masks): if masks is None: return None, None, None sep = t.node('ShaderNodeSeparateColor') t.link(t.tex(masks, t.coords("UVMap"), "baked: R occlusion, G edges, B crevices").outputs["Color"], sep.inputs[0]) return sep.outputs[0], sep.outputs[1], sep.outputs[2] def bevel_normal(t, bevel): if bevel is None: return None nb = t.node('ShaderNodeNormalMap', "baked: rounded edges", uv_map="UVMap") t.link(t.tex(bevel, t.coords("UVMap"), "baked: bevel normal").outputs["Color"], nb.inputs["Color"]) return nb.outputs["Normal"] def join_normals(t, a, b): """two tangent-space normal results on one surface: add their deviations from the true normal""" if a is None or b is None: return a if a is not None else b g = t.node('ShaderNodeNewGeometry') s = t.node('ShaderNodeVectorMath', operation='ADD') t.link(a, s.inputs[0]) t.link(b, s.inputs[1]) d = t.node('ShaderNodeVectorMath', operation='SUBTRACT') t.link(s.outputs[0], d.inputs[0]) t.link(g.outputs["Normal"], d.inputs[1]) nz = t.node('ShaderNodeVectorMath', "both normals", operation='NORMALIZE') t.link(d.outputs[0], nz.inputs[0]) return nz.outputs[0] # ------------------------------------------------------------------ the recipes def painted(mat, masks, bevel, tint, tile=1.25, shade=(0.93, 1.04), rough=(0.40, 0.56), coat=0.14, coat_rough=0.30, relief=0.30, wear=0.18, wear_col=(0.36, 0.36, 0.37), grime=0.18, dust=0.05, peel=0.035, metallic=0.0, space="UV_Tile", label="paint", grain=0.0, peel_scale=260.0, peel_dist=0.0004, peel_on_coat=False, flake=0.0, flake_col=0.0, flake_scale=2500.0, smudge=(0.10, 0.06), spec=0.5): """paint on steel: the tint shaded by the scan's light and dark, the scan's roughness and relief, a fine orange-peel bump, a clear coat (coat, coat_rough); chips on exposed edges (low), shade and matte grime in crevices, a little dust on top. Red look (2026-10-19): coat_rough (was fixed at 0.30); peel_on_coat puts the orange peel on the coat's own normal (the clear coat is what is wavy) and leaves the base layer the scan's relief, the flakes and the bevels; flake is the tilt of a random normal per flake cell (flake_scale cells per metre, object space) in the base layer, flake_col its brightness noise; smudge = (colour, roughness) of the pack's dirt mask; spec the base layer's specular level (a black satin coating reflects less than 0.5 gives, or it reads grey under a bright studio). With BAKE_FLAT the flakes are left out (they are far below an atlas texel) and base and coat share one normal, so the bake has one normal to write.""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.coords(space) mp = t.mapped(u, 1.0 / tile, "one repeat = %.2f m" % tile) dim = tex_image(scan("diff")) lo, hi = tex_range(dim) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(dim, mp, "scan: painted steel (light and dark only)").outputs["Color"], bw.inputs[0]) vm = t.node('ShaderNodeVectorMath', "tint x the scan's shade", operation='SCALE') vm.inputs[0].default_value = tint t.link(t.ranged(bw.outputs[0], shade[0], shade[1], "shade", lo, hi), vm.inputs["Scale"]) col = vm.outputs["Vector"] if grain > 0: # the powder coat's fine speckle (ref2, ref3 close up) gv = t.node('ShaderNodeVectorMath', "fine grain", operation='SCALE') t.link(col, gv.inputs[0]) t.link(t.ranged(t.noise(t.node('ShaderNodeTexCoord').outputs["Object"], 380.0, detail=2.0, rough=0.6, label="grain"), 1.0 - grain, 1.0 + 0.4 * grain, None, 0.3, 0.7), gv.inputs["Scale"]) col = gv.outputs["Vector"] flakes = None if (flake > 0 or flake_col > 0) and not BAKE_FLAT: fv = t.node('ShaderNodeTexVoronoi', "metallic flakes (cells in object space)") fv.inputs["Scale"].default_value = flake_scale t.link(t.node('ShaderNodeTexCoord').outputs["Object"], fv.inputs["Vector"]) flakes = fv.outputs["Color"] if flake_col > 0: fb = t.node('ShaderNodeRGBToBW') t.link(flakes, fb.inputs[0]) fs = t.node('ShaderNodeVectorMath', "flakes: a little lighter and darker", operation='SCALE') t.link(col, fs.inputs[0]) t.link(t.ranged(fb.outputs[0], 1.0 - flake_col, 1.0 + flake_col), fs.inputs["Scale"]) col = fs.outputs["Vector"] rim = tex_image(scan("rough"), "Non-Color") rlo, rhi = tex_range(rim) ro = t.tex(rim, mp, "scan: roughness").outputs["Color"] rr = t.ranged(ro, rough[0], rough[1], "roughness range", rlo, rhi) dirt = t.tex(tex_image(pack("Base", "Glass", "T_Glass_DirtRough.png"), "Non-Color"), t.mapped(u, 1.0 / 2.4), "smudges").outputs["Color"] rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, smudge[1]), clamp=True, label="smudges dull the sheen") col = t.mixc(t.math('MULTIPLY', dirt, smudge[0], clamp=True), col, (0.30, 0.29, 0.27), "faint smudges") ao, edge, crev = masks_of(t, masks) coat_w = coat metal = metallic if crev is not None and grime > 0: g = t.math('MULTIPLY', t.math('POWER', t.math('SUBTRACT', 1.0, crev), 1.4), grime, clamp=True, label="grime") col = t.mixc(g, col, (0.05, 0.048, 0.045), "shade in crevices") rr = t.math('ADD', rr, t.math('MULTIPLY', g, 0.35), clamp=True, label="grime is matte") coat_w = t.mixf(g, coat, 0.0) if ao is not None: col = t.mixc(t.ranged(ao, 0.30, 0.0, "baked occlusion", 0.0, 1.0), col, (0.02, 0.02, 0.02), "occlusion") upf, geo = t.up() if dust > 0: d = t.math('MULTIPLY', t.math('MULTIPLY', upf, t.ranged(t.noise(geo.outputs["Position"], 2.2, label="dust patches"), 0.0, 1.0, None, 0.35, 0.75, smooth=True)), dust, clamp=True) col = t.mixc(d, col, (0.50, 0.49, 0.46), "dust") rr = t.math('ADD', rr, t.math('MULTIPLY', d, 0.6), clamp=True) if edge is not None and wear > 0: brk = t.ranged(ro, 1.3, 0.5, "scratches break the chips up", rlo, rhi) exposed = t.ranged(ao, 0.0, 1.0, "only exposed edges", 0.72, 0.94, smooth=True) w = t.math('MULTIPLY', t.math('MULTIPLY', t.math('MULTIPLY', edge, brk), exposed), wear) w = t.ranged(w, 0.0, 1.0, "chipped edge", 0.30, 0.70, smooth=True) col = t.mixc(w, col, wear_col, "metal under the coat") rr = t.mixf(w, rr, 0.40) metal = t.mixf(w, metallic, 0.85) coat_w = t.mixf(w, coat_w, 0.0) t.put(b.inputs["Base Color"], col) t.put(b.inputs["Roughness"], rr) t.put(b.inputs["Metallic"], metal) t.put(b.inputs["Coat Weight"], coat_w) b.inputs["Coat Roughness"].default_value = coat_rough b.inputs["Specular IOR Level"].default_value = spec nd = t.node('ShaderNodeNormalMap', "scan: relief", uv_map=space if space != 'OBJECT' else "UV_Tile") nd.inputs["Strength"].default_value = relief t.link(t.tex(tex_image(scan("nor_gl"), "Non-Color"), mp, "scan: normal").outputs["Color"], nd.inputs["Color"]) n = nd.outputs["Normal"] bn = bevel_normal(t, bevel) def peel_bump(under): bp = t.node('ShaderNodeBump', "orange peel") bp.inputs["Strength"].default_value = peel bp.inputs["Distance"].default_value = peel_dist t.link(t.noise(t.node('ShaderNodeTexCoord').outputs["Object"], peel_scale, detail=3.0, rough=0.55, label="peel"), bp.inputs["Height"]) if under is not None: t.link(under, bp.inputs["Normal"]) return bp.outputs["Normal"] coat_n = None if peel > 0 and (not peel_on_coat or BAKE_FLAT): n = peel_bump(n) # the powder coat's fine orange peel (or, for the bake, the coat's) elif peel > 0: coat_n = join_normals(t, peel_bump(None), bn) n = join_normals(t, n, bn) if flakes is not None and flake > 0: # each flake tilted a little: the sparkle under the coat fd = t.node('ShaderNodeVectorMath', "flake tilt", operation='SUBTRACT') t.link(flakes, fd.inputs[0]) fd.inputs[1].default_value = (0.5, 0.5, 0.5) fsc = t.node('ShaderNodeVectorMath', operation='SCALE') t.link(fd.outputs[0], fsc.inputs[0]) fsc.inputs["Scale"].default_value = 2.0 * flake fa = t.node('ShaderNodeVectorMath', operation='ADD') t.link(n, fa.inputs[0]) t.link(fsc.outputs[0], fa.inputs[1]) fz = t.node('ShaderNodeVectorMath', "base normal with flakes", operation='NORMALIZE') t.link(fa.outputs[0], fz.inputs[0]) t.link(fz.outputs[0], b.inputs["Normal"]) else: t.link(n, b.inputs["Normal"]) if BAKE_FLAT and coat > 0: t.link(n, b.inputs["Coat Normal"]) # one normal for the bake (base and coat the same) elif coat_n is not None: t.link(coat_n, b.inputs["Coat Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = float(metallic) mat.roughness = 0.5 mat["dra_surf"] = "%s: tint %s, metallic %.2f, scan blue_metal_plate (relief %.2f, tile %.2f m), coat %.2f at %.2f, " \ "flakes %.2f, wear %.2f" % (label, tuple(round(c, 3) for c in tint), metallic, relief, tile, coat, coat_rough, flake, wear) def steel(mat, masks, bevel, tint=(0.80, 0.80, 0.81), rough=(0.18, 0.34), tile=0.5, grime=0.20, space="UV_Tile", shade=(0.84, 1.06), aniso=0.3, scratch=0.45, prints=0.14, label="bright steel"): """bright brushed steel: the pack's StainedSteel set, fine scratches, fingerprints (red look: shade, aniso, scratch and prints are parameters; the bearing ring of ref8 is cleaner and smoother)""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.coords(space) mp = t.mapped(u, 1.0 / tile, "one repeat = %.2f m" % tile) dim = tex_image(pack("Base", "StainedSteel", "T_StainSteeled_Diff.png")) lo, hi = tex_range(dim) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(dim, mp, "pack: stained steel").outputs["Color"], bw.inputs[0]) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = tint t.link(t.ranged(bw.outputs[0], shade[0], shade[1], "shade", lo, hi), vm.inputs["Scale"]) col = vm.outputs["Vector"] rim = tex_image(pack("Base", "StainedSteel", "T_StainSteeled_Rough.png"), "Non-Color") rlo, rhi = tex_range(rim) rr = t.ranged(t.tex(rim, mp, "pack: roughness").outputs["Color"], rough[0], rough[1], "roughness range", rlo, rhi) dirt = t.tex(tex_image(pack("Base", "Glass", "T_Glass_DirtRough.png"), "Non-Color"), t.mapped(u, 1.0 / 0.9), "fingerprints").outputs["Color"] rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, prints), clamp=True) ao, edge, crev = masks_of(t, masks) if crev is not None: g = t.math('MULTIPLY', t.math('POWER', t.math('SUBTRACT', 1.0, crev), 1.4), grime, clamp=True) col = t.mixc(g, col, (0.03, 0.03, 0.03), "grime in crevices") rr = t.math('ADD', rr, t.math('MULTIPLY', g, 0.35), clamp=True) if ao is not None: col = t.mixc(t.ranged(ao, 0.30, 0.0, "baked occlusion", 0.0, 1.0), col, (0.02, 0.02, 0.02), "occlusion") t.put(b.inputs["Base Color"], col) t.put(b.inputs["Roughness"], rr) b.inputs["Metallic"].default_value = 1.0 b.inputs["Anisotropic"].default_value = aniso nd = t.node('ShaderNodeNormalMap', "fine scratches", uv_map=space if space != 'OBJECT' else "UV_Tile") nd.inputs["Strength"].default_value = scratch t.link(t.tex(tex_image(pack("Base", "Glass", "T_Scratch_Norm.png"), "Non-Color"), mp, "pack: scratch normal").outputs["Color"], nd.inputs["Color"]) t.link(join_normals(t, nd.outputs["Normal"], bevel_normal(t, bevel)), b.inputs["Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = 1.0 mat.roughness = 0.3 mat["dra_surf"] = "%s: pack StainedSteel + scratch normal, tint %s, roughness %s" % (label, tuple(tint), tuple(rough)) def rubber(mat, masks, bevel, tint=(0.016, 0.016, 0.017), rough=(0.62, 0.84), tile=0.4, dust=0.05, space="UV_Tile"): """black rubber (cables, hoses, belt, boots, buffers): a dull sheen broken by the pack's scratched-metal roughness, a little dust on top""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.coords(space) mp = t.mapped(u, 1.0 / tile) rim = tex_image(pack("Base", "ScratchedMetal", "T_ScratchedMetal_Rough.png"), "Non-Color") rlo, rhi = tex_range(rim) rr = t.ranged(t.tex(rim, mp, "pack: roughness").outputs["Color"], rough[1], rough[0], "roughness range", rlo, rhi) col = tuple(tint) upf, geo = t.up() if dust > 0: d = t.math('MULTIPLY', t.math('MULTIPLY', upf, t.ranged(t.noise(geo.outputs["Position"], 2.0), 0.0, 1.0, None, 0.4, 0.8, smooth=True)), dust, clamp=True) col = t.mixc(d, col, (0.30, 0.29, 0.27), "dust") rr = t.math('ADD', rr, t.math('MULTIPLY', d, 0.3), clamp=True) ao, edge, crev = masks_of(t, masks) if ao is not None: col = t.mixc(t.ranged(ao, 0.30, 0.0, None, 0.0, 1.0), col, (0.004, 0.004, 0.004), "occlusion") t.put(b.inputs["Base Color"], col) t.put(b.inputs["Roughness"], rr) b.inputs["Metallic"].default_value = 0.0 b.inputs["Specular IOR Level"].default_value = 0.35 bn = bevel_normal(t, bevel) if bn is not None: t.link(bn, b.inputs["Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = 0.0 mat.roughness = 0.75 mat["dra_surf"] = "black rubber: pack ScratchedMetal roughness, tint %s" % (tuple(tint),) def braided(mat, tint=(0.010, 0.010, 0.011), width=0.006, radius=0.0208, length=2.38, rough=(0.80, 0.36), height=0.0011, filaments=4.0): """black braided cable sleeve (ref8): two families of flat carriers crossing at 45 deg, one over one, each carrier a few parallel filaments, a glint on each crossing. The curves give a UV (U along the curve 0..1, V round it 0..1; measured, dra_look_sig.py). A shader cannot see a curve's radius or length: an Object attribute 'bevel_depth' or 'data.bevel_depth' reads 0 at render time (the evaluated curve is a mesh; tested 2026-10-19), and slots, objects and curves are not to be changed for the look. So the weave is set for the elbow cables (radius 20.8 mm, about 2.38 m long, measured on the 1.6x arm): an even number of carriers round it (the pattern closes on itself), each about `width` wide. On the short J3 hoses (r 16 mm, 0.19 m) the same numbers stretch the weave along the hose into fine, nearly lengthwise ribs.""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) sep = t.node('ShaderNodeSeparateXYZ') t.link(t.node('ShaderNodeTexCoord').outputs["UV"], sep.inputs[0]) U, V = sep.outputs["X"], sep.outputs["Y"] circ = 2.0 * math.pi * radius N = max(8, 2 * int(round(circ / (2.0 * width)))) # carriers round it, even pitch = circ / N # 45 deg: one carrier width along per carrier round a = t.math('MULTIPLY', V, float(N), label="round (%d carriers)" % N) s = t.math('MULTIPLY', U, length / pitch, label="along (%.2f m / %.1f mm)" % (length, pitch * 1000)) p1 = t.math('ADD', a, s) p2 = t.math('SUBTRACT', a, s) f1 = t.math('FRACT', p1) f2 = t.math('FRACT', p2) top = t.math('LESS_THAN', t.math('FLOORED_MODULO', t.math('ADD', t.math('FLOOR', p1), t.math('FLOOR', p2)), 2.0), 0.5, label="which carrier is on top") def band(f): # across a carrier: rounded, with filament ridges crown = t.math('POWER', t.math('SINE', t.math('MULTIPLY', f, math.pi)), 0.5) fil = t.math('ABSOLUTE', t.math('SINE', t.math('MULTIPLY', f, math.pi * filaments))) return t.math('MULTIPLY', crown, t.math('ADD', 0.72, t.math('MULTIPLY', fil, 0.28))) def arch(f): # along a carrier over one crossing: up over, down under return t.math('ADD', 0.45, t.math('MULTIPLY', t.math('SINE', t.math('MULTIPLY', f, math.pi)), 0.55)) h1 = t.math('MULTIPLY', band(f1), arch(f2)) h2 = t.math('MULTIPLY', band(f2), arch(f1)) h = t.mixf(top, h2, h1, "height of the weave") col = t.node('ShaderNodeVectorMath', "crowns a little lighter", operation='SCALE') col.inputs[0].default_value = tint t.link(t.ranged(h, 0.45, 1.6), col.inputs["Scale"]) t.link(col.outputs["Vector"], b.inputs["Base Color"]) t.link(t.ranged(h, rough[0], rough[1]), b.inputs["Roughness"]) b.inputs["Metallic"].default_value = 0.0 b.inputs["Specular IOR Level"].default_value = 0.5 bp = t.node('ShaderNodeBump', "weave") bp.inputs["Strength"].default_value = 1.0 bp.inputs["Distance"].default_value = height t.link(h, bp.inputs["Height"]) t.link(bp.outputs["Normal"], b.inputs["Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = 0.0 mat.roughness = 0.6 mat["dra_surf"] = "black braided sleeve: %d carriers %.1f mm wide at 45 deg, one over one, %d filaments, on the " \ "curves' own UV (set for the elbow cables)" % (N, pitch * 1000, filaments) def glass(mat): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) dirt = t.tex(tex_image(pack("Base", "Glass", "T_Glass_DirtRough.png"), "Non-Color"), t.mapped(t.coords("UV_Tile"), 1.0 / 0.6), "smudges").outputs["Color"] b.inputs["Base Color"].default_value = (0.010, 0.011, 0.013, 1) t.put(b.inputs["Roughness"], t.ranged(dirt, 0.10, 0.22)) b.inputs["Coat Weight"].default_value = 0.6 mat.diffuse_color = (0.02, 0.022, 0.026, 1.0) mat["dra_surf"] = "smoked lens glass" def lamp(mat): t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) b.inputs["Base Color"].default_value = (0.60, 0.66, 0.70, 1) b.inputs["Roughness"].default_value = 0.25 b.inputs["Emission Color"].default_value = (0.80, 0.90, 1.0, 1) b.inputs["Emission Strength"].default_value = 0.8 mat.diffuse_color = (0.85, 0.9, 1.0, 1.0) mat["dra_surf"] = "indicator: faint cool white (not a light source)" # the recipes per material (tints linear). # Red look (2026-10-19, ref8). The white look before it is in versions/v22_before_red_look/build/dra_surf.py. RECIPES = { # the body: car-style red metallic. Base red 0.56/0.010/0.010 (sRGB about 196/25/25; ref8's lit faces are brighter # because its studio is: 0.78/0.027/0.028 median there; its shadow sides go to 109/3/3, so green and blue stay near # zero; a first try at 0.50/0.020/0.018 read pinkish), half metallic, base roughness 0.26-0.34 under a full clear # coat at 0.10 (crisp but soft highlights). Flakes: a random tilt of 0.10 per 0.4 mm cell and +-8 % brightness in # the base. The scan's relief at 0.06 (was 0.55), a slight orange peel on the coat (7 mm waves). Wear 0.05, grime # 0.10, no dust: a clean ship. "M_DoorArm_PaintRed": ("painted", dict(tint=(0.56, 0.010, 0.010), shade=(0.97, 1.02), rough=(0.26, 0.34), coat=1.0, coat_rough=0.10, relief=0.06, wear=0.05, wear_col=(0.42, 0.42, 0.43), grime=0.10, dust=0.0, peel=0.05, peel_scale=150.0, peel_dist=0.0006, peel_on_coat=True, metallic=0.5, flake=0.10, flake_col=0.08, flake_scale=2500.0, smudge=(0.02, 0.03), label="red metallic paint under a clear coat")), # the labels (ref8's bow-tie "X" plates): light grey-white print, satin, no coat, no metal "M_DoorArm_Marking": ("painted", dict(tint=(0.60, 0.60, 0.59), shade=(0.97, 1.02), rough=(0.36, 0.46), coat=0.0, relief=0.04, wear=0.03, grime=0.10, dust=0.0, peel=0.0, smudge=(0.03, 0.04), label="white label")), # ref8's motor housings and caps: black, satin, a soft sheen that shows the form (sRGB 16-61 under its studio). # First try (metallic 0.25, roughness 0.34-0.48, specular 0.5) read mid grey (sRGB about 100) on the up faces. "M_DoorArm_Gunmetal": ("painted", dict(tint=(0.016, 0.016, 0.018), shade=(0.90, 1.15), rough=(0.42, 0.56), coat=0.0, relief=0.12, wear=0.06, wear_col=(0.30, 0.30, 0.31), grime=0.08, dust=0.0, peel=0.0, metallic=0.0, spec=0.30, tile=0.70, grain=0.05, smudge=(0.03, 0.05), label="black satin (motor housings, caps)")), # ref8's base plate: black, a little duller "M_DoorArm_BlackMetal": ("painted", dict(tint=(0.014, 0.014, 0.016), shade=(0.88, 1.18), rough=(0.50, 0.66), coat=0.0, relief=0.14, wear=0.06, wear_col=(0.28, 0.28, 0.29), grime=0.06, dust=0.0, peel=0.0, metallic=0.0, spec=0.30, tile=0.70, smudge=(0.03, 0.05), label="black matte (the carriage's stacked plates)")), # ref8's bearing ring and screw heads: bright machined steel, a clean sharp reflection "M_DoorArm_Steel": ("steel", dict(tint=(0.70, 0.70, 0.71), shade=(0.92, 1.04), rough=(0.12, 0.24), grime=0.10, aniso=0.5, scratch=0.25, prints=0.06, label="bright machined steel")), "M_DoorArm_Rubber": ("rubber", dict(tint=(0.011, 0.011, 0.012), rough=(0.60, 0.80), dust=0.0)), "M_DoorArm_Glass": ("glass", None), "M_DoorArm_Lamp": ("lamp", None), } CABLE = ("M_DoorArm_Cable", "braided", dict()) def dress(name, kind, kw, masks, bevel, space="UV_Tile", create=True): m = bpy.data.materials.get(name) or (bpy.data.materials.new(name) if create else None) if m is None: return None if kind == "painted": painted(m, masks, bevel, space=space, **kw) elif kind == "steel": steel(m, masks, bevel, space=space, **kw) elif kind == "rubber": rubber(m, masks, bevel, space=space, **kw) elif kind == "braided": braided(m, **kw) elif kind == "glass": glass(m) elif kind == "lamp": lamp(m) return m def rename_materials(): """old name -> new name where only the old one exists (slots point at datablocks: they follow)""" done = {} for old, new in RENAMES.items(): m = bpy.data.materials.get(old) if m is None: continue if bpy.data.materials.get(new) is None: m.name = new done[old] = new else: done[old] = "NOT renamed: %s exists too (%d users of the old one)" % (new, m.users) return done def atlas_images(load_only=False): """the two baked maps: loaded (or reloaded) from textures/ when baked, else blank placeholders the bake fills. An image already in the file is found by its file (the ship's are relative, //door-robot-arm/textures/...).""" out = [] for nm, rgba in ((MASKS, (1.0, 0.0, 1.0, 1.0)), (BEVEL, (0.5, 0.5, 1.0, 1.0))): path = os.path.normpath(os.path.join(TEX, nm + ".png")) im = next((i for i in bpy.data.images if i.source == 'FILE' and i.filepath and os.path.normcase(os.path.normpath(bpy.path.abspath(i.filepath))) == os.path.normcase(path)), None) im = im or bpy.data.images.get(nm) if os.path.exists(path): if im is None: im = bpy.data.images.load(path) im.name = nm elif im.source == 'FILE': im.reload() # the file on disk is the newest bake if im.source == 'FILE': relative(im, path) if im is None: if load_only: raise FileNotFoundError(path) im = bpy.data.images.new(nm, ATLAS, ATLAS, alpha=False) im.colorspace_settings.name = 'Non-Color' # before the pixels im.pixels.foreach_set(np.tile(np.array(rgba, np.float32), ATLAS * ATLAS)) im.filepath_raw = path im.file_format = 'PNG' im.colorspace_settings.name = 'Non-Color' out.append(im) return out def dress_materials(masks, bevel, create=True): """only the node trees: every recipe, the kit's copies (no atlas) and the cable; with create=False only the materials that exist (the live script: nothing new in the user's file)""" done = {} for name, (kind, kw) in RECIPES.items(): m = dress(name, kind, kw or {}, masks, bevel, create=create) if m is not None: done[name] = m.get("dra_surf") for src, dst in KIT_MATS.items(): kind, kw = RECIPES[src] m = dress(dst, kind, kw or {}, None, None, create=create and any( src in [x.name for x in me.materials if x] or dst in [x.name for x in me.materials if x] for me in kit_meshes())) if m is not None: done[dst] = m.get("dra_surf") m = dress(CABLE[0], CABLE[1], CABLE[2], None, None, create=create) if m is not None: done[CABLE[0]] = m.get("dra_surf") + " (the curves)" return done def dress_all(): # scale-up (2026-10-18, the user: no bake this time): the UV0 atlas was repacked for the new meshes, so the old baked # masks and bevel normals would land on the wrong faces; with DRA_NO_ATLAS=1 the arm's materials are dressed the way # the kit's are, without the atlas maps. Red look (2026-10-19): baked again, the atlas is back on. rename_materials() if os.environ.get("DRA_NO_ATLAS") == "1": masks, bevel = None, None else: masks, bevel = atlas_images() done = dress_materials(masks, bevel) # the carriage's stacked plates in black metal (the other gunmetal parts stay gunmetal) me = bpy.data.meshes.get("DoorArm | carriage") if me is not None: for i, m in enumerate(me.materials): if m and m.name == "M_DoorArm_Gunmetal": me.materials[i] = bpy.data.materials["M_DoorArm_BlackMetal"] # the kit: the same recipes without the arm's atlas for me in kit_meshes(): for i, m in enumerate(me.materials): if m and m.name in KIT_MATS: me.materials[i] = bpy.data.materials[KIT_MATS[m.name]] # the cables and hoses (curves) cab = bpy.data.materials[CABLE[0]] for cu in bpy.data.curves: if cu.name.startswith(("DoorArm_", "Showroom | arm")): if [m.name for m in cu.materials if m] != [cab.name]: cu.materials.clear() cu.materials.append(cab) return done def live_look(): """for dra_live_look.py: inside a live Blender (the arm file or the ship), only materials and images change: the two renames, the node trees of the M_DoorArm* / M_DoorArmKit* materials that exist, the baked atlas maps loaded or reloaded from textures/. No slot, mesh, curve or object is touched.""" t0 = time.time() rep = dict(file=bpy.data.filepath, renamed=rename_materials()) masks, bevel = atlas_images(load_only=True) rep["images"] = {im.name: im.filepath for im in (masks, bevel)} rep["materials"] = sorted(dress_materials(masks, bevel, create=False)) rep["left_alone"] = sorted(m.name for m in bpy.data.materials if m.name.startswith(("M_DoorArm_", "M_DoorArmKit_")) and m.name not in rep["materials"]) rep["seconds"] = round(time.time() - t0, 2) return rep # ------------------------------------------------------------------ the ship context's missing images def stand_ins(): """materials of the ship context whose image files are missing (an old absolute path in the ship): plain colours, so the reference shows in Rendered view (the context is not part of the asset)""" looks = {"PBR | Brushed titanium": ((0.50, 0.51, 0.53, 1), 1.0, 0.32), "PBR | Graphite lower hull": ((0.06, 0.065, 0.07, 1), 0.6, 0.45)} fixed = [] ctx_mats = {s.material for o in bpy.data.objects if o.get("reference_only") for s in o.material_slots if s.material} for m in ctx_mats: if not m.node_tree or m.name.startswith("M_DoorArm"): continue missing = [n for n in m.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image is not None and not n.image.has_data and not os.path.exists(bpy.path.abspath(n.image.filepath))] if not missing: continue bsdf = next((n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) for n in missing: for o in n.outputs: for lk in list(o.links): m.node_tree.links.remove(lk) if bsdf is not None: col, met, rough = looks.get(m.name, ((0.35, 0.36, 0.38, 1), 0.5, 0.4)) bsdf.inputs["Base Color"].default_value = col bsdf.inputs["Metallic"].default_value = met bsdf.inputs["Roughness"].default_value = rough m["stand_in"] = "the ship's image files for this material are missing; plain colour for the reference only" fixed.append(m.name) return fixed # ------------------------------------------------------------------ the pocket's light rig, saved in the file def mx(key, x): return x if key == "AFT" else 6.1 - x def ship_view(sf): """fix round (CRITIQUE_2 nice 13): the solver empties hidden in the viewport by their collection's eye (they stay evaluated: the drivers read them), and a camera of the Ship fit scene framing the AFT arm and its door under the deck (version A, frame 1), set as the scene's camera; it lives in the studio collection, not part of the asset""" for vl in sf.view_layers: def walk(lc): if lc.collection.name == "DoorArm | solver (hidden)": lc.hide_viewport = True for ch in lc.children: walk(ch) walk(vl.layer_collection) wm = bpy.context.window_manager if wm.windows: wm.windows[0].scene = sf sf.frame_set(1) dg = bpy.context.evaluated_depsgraph_get() obs = [o for o in sf.objects if o.name.startswith("DoorArm_AFT_A | ") and o.type == 'MESH'] obs.append(bpy.data.objects["Moonpool_Door_Aft"]) pts = [o.evaluated_get(dg).matrix_world @ Vector(c) for o in obs for c in o.evaluated_get(dg).bound_box] lo = Vector([min(p[i] for p in pts) for i in range(3)]) hi = Vector([max(p[i] for p in pts) for i in range(3)]) ctr = (lo + hi) / 2 name = "Cam | Ship fit (AFT arm and door)" cd = bpy.data.cameras.get(name) or bpy.data.cameras.new(name) cd.clip_start, cd.clip_end, cd.sensor_fit = 0.05, 60.0, 'HORIZONTAL' cam = bpy.data.objects.get(name) or bpy.data.objects.new(name, cd) c = bpy.data.collections[LIGHTS] if cam.name not in c.objects: c.objects.link(cam) cam["not_exported"] = True loc = Vector((ctr.x + 2.4, -1.4, -4.45)) # inboard of the pocket, just under the deck (as the rig renders) cam.location = loc cam.rotation_euler = (ctr - loc).normalized().to_track_quat('-Z', 'Y').to_euler() bpy.context.view_layer.update() M = cam.matrix_world.to_3x3() r, u, f = M.col[0], M.col[1], -M.col[2] mx_ = my_ = 1e-6 for p in pts: v = p - loc d = v.dot(f) if d > 0.05: mx_, my_ = max(mx_, abs(v.dot(r)) / d), max(my_, abs(v.dot(u)) / d) sw = cd.sensor_width cd.lens = min(sw / 2 / mx_, sw * 9 / 16 / 2 / my_) / 1.08 sf.camera = cam sf.render.resolution_x, sf.render.resolution_y = 1600, 900 return cam.name def light_rig(sf): """area lights under the deck along both pockets and a fill from the moon pool, in a collection that is not part of the asset (export skips it): the pocket under the deck is dark in the ship""" c = bpy.data.collections.get(LIGHTS) or bpy.data.collections.new(LIGHTS) if c.name not in [x.name for x in sf.collection.children]: sf.collection.children.link(c) c["not_exported"] = True def look(o, target): d = (Vector(target) - o.location).normalized() o.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() def area(name, loc, size, energy, target, color=(1.0, 0.97, 0.93)): ld = bpy.data.lights.get(name) or bpy.data.lights.new(name, 'AREA') ld.shape = 'RECTANGLE' ld.size, ld.size_y = size ld.energy = energy ld.color = color o = bpy.data.objects.get(name) or bpy.data.objects.new(name, ld) if o.name not in c.objects: c.objects.link(o) o.location = loc look(o, target) o["not_exported"] = True return o for key in ("AFT", "FORE"): x0 = mx(key, -5.3) area("Light | %s under deck inboard" % key, (x0, -4.55, -3.90), (7.5, 1.0), 750, (x0, -6.6, -6.2)) area("Light | %s under deck outboard" % key, (x0, -8.60, -3.95), (7.5, 0.6), 300, (x0, -6.0, -6.0), color=(0.90, 0.95, 1.0)) area("Light | %s fill from the well" % key, (mx(key, -2.1), -2.6, -5.6), (3.0, 3.0), 380, (mx(key, -5.0), -7.0, -6.0), color=(0.85, 0.92, 1.0)) area("Light | %s fill over the moon pool" % key, (mx(key, 0.5), -1.5, -4.6), (3.0, 2.0), 280, (mx(key, -1.5), -7.0, -6.5), color=(0.85, 0.92, 1.0)) w = bpy.data.worlds.get("DoorArm | pocket world") or bpy.data.worlds.new("DoorArm | pocket world") w.use_nodes = True bg = w.node_tree.nodes.get("Background") if bg: bg.inputs[0].default_value = (0.018, 0.021, 0.026, 1) bg.inputs[1].default_value = 1.0 sf.world = w return c # ------------------------------------------------------------------ the showroom's studio, kept in the file HDRI = os.path.join(ROOT, "source_polyhaven", "hdris", "studio_small_09_2k.hdr") def studio_look(): """the showroom as the references are lit: a mid-grey backdrop, a soft studio HDRI (Poly Haven, CC0) under the key, fill and rim lights, the fill low for some contrast""" m = bpy.data.materials.get("M_Studio_Floor") if m and m.node_tree: b = m.node_tree.nodes.get("Principled BSDF") if b: b.inputs["Base Color"].default_value = (0.40, 0.40, 0.40, 1) b.inputs["Roughness"].default_value = 0.7 m.diffuse_color = (0.40, 0.40, 0.40, 1) import dra_rigdata as rd for nm, e in (("Showroom | key", 800.0), ("Showroom | fill", 150.0), ("Showroom | rim", 500.0)): ld = bpy.data.lights.get(nm) if ld: ld.energy = e * rd.S * rd.S # the studio is scaled with the arm (2026-10-18) w = bpy.data.worlds.get("Showroom | world") if w and os.path.exists(HDRI): # the HDRI lights and reflects; the camera sees a plain grey (past the backdrop's edge it would show the studio) w.use_nodes = True nt = w.node_tree nt.nodes.clear() out = nt.nodes.new('ShaderNodeOutputWorld') env = nt.nodes.new('ShaderNodeTexEnvironment') env.image = bpy.data.images.load(HDRI, check_existing=True) lit = nt.nodes.new('ShaderNodeBackground') lit.inputs["Strength"].default_value = 0.30 nt.links.new(env.outputs["Color"], lit.inputs["Color"]) plain = nt.nodes.new('ShaderNodeBackground') plain.inputs["Color"].default_value = (0.21, 0.21, 0.21, 1) plain.inputs["Strength"].default_value = 1.0 lp = nt.nodes.new('ShaderNodeLightPath') mix = nt.nodes.new('ShaderNodeMixShader') nt.links.new(lp.outputs["Is Camera Ray"], mix.inputs[0]) nt.links.new(lit.outputs[0], mix.inputs[1]) nt.links.new(plain.outputs[0], mix.inputs[2]) nt.links.new(mix.outputs[0], out.inputs["Surface"]) return True # ------------------------------------------------------------------ the bake (the docking arm's method) def use_gpu(sc): sc.render.engine = 'CYCLES' try: prefs = bpy.context.preferences.addons['cycles'].preferences for kind in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'): try: prefs.compute_device_type = kind except Exception: continue (prefs.refresh_devices if hasattr(prefs, "refresh_devices") else prefs.get_devices)() if any(d.type == kind for d in prefs.devices): for d in prefs.devices: d.use = d.type == kind sc.cycles.device = 'GPU' return kind except Exception as ex: print("[dra] GPU not set:", ex) sc.cycles.device = 'CPU' return 'CPU' def _select_only(objs): vl = bpy.context.view_layer for o in vl.objects: o.select_set(False) for o in objs: o.select_set(True) vl.objects.active = objs[0] def _temp_copies(sc, only=None, strip_decals=False, mat=None): """one temporary object per arm mesh (a copy of the mesh at the origin, UV0 active), all baked in one call""" tmp = [] for src in arm_meshes(): if only is not None and src.name not in only: continue me = src.copy() if strip_decals: bm = bmesh.new() bm.from_mesh(me) drop = decal_faces(src) if drop: bm.faces.ensure_lookup_table() bmesh.ops.delete(bm, geom=[bm.faces[i] for i in drop], context='FACES') for f in bm.faces: f.material_index = 0 bm.to_mesh(me) bm.free() if mat is not None: me.materials.clear() me.materials.append(mat) me.uv_layers.active = me.uv_layers["UVMap"] me.uv_layers["UVMap"].active_render = True o = bpy.data.objects.new("_bake_" + src.name, me) sc.collection.objects.link(o) tmp.append(o) return tmp def _drop(tmp): for o in tmp: me = o.data bpy.data.objects.remove(o) bpy.data.meshes.remove(me) def _save_png(im, name): os.makedirs(TEX, exist_ok=True) path = os.path.join(TEX, name + ".png") im.filepath_raw = path im.file_format = 'PNG' im.save() try: im.filepath = bpy.path.relpath(path) if bpy.data.filepath else path except ValueError: # a file on another drive (a scratch copy): keep the absolute path im.filepath = path return path def bake(only=None): """the two atlas maps (masks, bevel normal). Red look (2026-10-19): every arm mesh in one bake call per map (the occlusion, edge and bevel nodes only see their own object, as before); the decal faces are left out as before; margin 3 px (the islands are 6 px apart)""" t0 = time.time() sc = bpy.context.scene kind = use_gpu(sc) masks, bevel = atlas_images() if only is None: # a fresh bake starts from the blank fill for im, rgba in ((masks, (1.0, 0.0, 1.0, 1.0)), (bevel, (0.5, 0.5, 1.0, 1.0))): if tuple(im.size) != (ATLAS, ATLAS): im.scale(ATLAS, ATLAS) im.pixels.foreach_set(np.tile(np.array(rgba, np.float32), ATLAS * ATLAS)) mm = bpy.data.materials.new("_bake_masks") mm.use_nodes = True nt = mm.node_tree nt.nodes.clear() ao = nt.nodes.new('ShaderNodeAmbientOcclusion') ao.samples = 16 ao.only_local = True ao.inputs["Distance"].default_value = 0.35 cv = nt.nodes.new('ShaderNodeAmbientOcclusion') cv.samples = 16 cv.only_local = True cv.inputs["Distance"].default_value = 0.035 bv = nt.nodes.new('ShaderNodeBevel') bv.samples = 8 bv.inputs["Radius"].default_value = EDGE_R geo = nt.nodes.new('ShaderNodeNewGeometry') dot = nt.nodes.new('ShaderNodeVectorMath') dot.operation = 'DOT_PRODUCT' nt.links.new(bv.outputs["Normal"], dot.inputs[0]) nt.links.new(geo.outputs["Normal"], dot.inputs[1]) inv = nt.nodes.new('ShaderNodeMath') inv.operation = 'SUBTRACT' inv.inputs[0].default_value = 1.0 nt.links.new(dot.outputs["Value"], inv.inputs[1]) edg = nt.nodes.new('ShaderNodeMath') edg.operation = 'MULTIPLY' edg.inputs[1].default_value = 3.5 edg.use_clamp = True nt.links.new(inv.outputs[0], edg.inputs[0]) cc = nt.nodes.new('ShaderNodeCombineColor') nt.links.new(ao.outputs["AO"], cc.inputs[0]) nt.links.new(edg.outputs[0], cc.inputs[1]) nt.links.new(cv.outputs["AO"], cc.inputs[2]) em = nt.nodes.new('ShaderNodeEmission') nt.links.new(cc.outputs[0], em.inputs["Color"]) om = nt.nodes.new('ShaderNodeOutputMaterial') nt.links.new(em.outputs[0], om.inputs["Surface"]) ti = nt.nodes.new('ShaderNodeTexImage') ti.image = masks nt.nodes.active = ti mn = bpy.data.materials.new("_bake_bevel") mn.use_nodes = True nt2 = mn.node_tree b2 = nt2.nodes["Principled BSDF"] bv2 = nt2.nodes.new('ShaderNodeBevel') bv2.samples = 8 bv2.inputs["Radius"].default_value = BEVEL_R nt2.links.new(bv2.outputs["Normal"], b2.inputs["Normal"]) ti2 = nt2.nodes.new('ShaderNodeTexImage') ti2.image = bevel nt2.nodes.active = ti2 sc.cycles.samples = 16 sc.cycles.use_denoising = False sc.render.bake.margin = 3 sc.render.bake.use_clear = False tmp = _temp_copies(sc, only, strip_decals=True, mat=mm) rows = {} try: _select_only(tmp) t1 = time.time() bpy.ops.object.bake(type='EMIT', margin=3, use_clear=False) rows["masks"] = round(time.time() - t1, 1) for o in tmp: o.data.materials[0] = mn t1 = time.time() bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', margin=3, use_clear=False) rows["bevel_normal"] = round(time.time() - t1, 1) finally: _drop(tmp) for im, nm in ((masks, MASKS), (bevel, BEVEL)): _save_png(im, nm) bpy.data.materials.remove(mm) bpy.data.materials.remove(mn) print("[dra] atlas baked", rows, flush=True) return dict(device=kind, seconds=round(time.time() - t0, 1), size=ATLAS, samples=16, meshes=len(tmp), passes=rows) # ------------------------------------------------------------------ the final look as PBR maps (red look, 2026-10-19) def _new_image(name, colorspace, fill, alpha=False): old = bpy.data.images.get(name) if old is not None: bpy.data.images.remove(old) im = bpy.data.images.new(name, ATLAS, ATLAS, alpha=alpha) im.colorspace_settings.name = colorspace # before the pixels: a later change regenerates a blank image if alpha: im.alpha_mode = 'CHANNEL_PACKED' # the alpha is data (the coat), not transparency im.pixels.foreach_set(np.tile(np.array(fill, np.float32), ATLAS * ATLAS)) return im def _feed(nt, sock_from, sock_to): if isinstance(sock_from, bpy.types.NodeSocket): nt.links.new(sock_from, sock_to) else: v = sock_from if hasattr(sock_to.default_value, "__len__") and not hasattr(v, "__len__"): v = (v, v, v, 1.0)[:len(sock_to.default_value)] sock_to.default_value = v def _bake_copy(mat, kind, img): """a copy of the material that shows one channel of its Principled BSDF as emission (col: base colour; rmc: roughness, metallic, coat weight as R, G, B), or the material itself (nor: the BSDF's own normals), with the target image as its active node""" m = mat.copy() m.name = "_bk_%s_%s" % (kind, mat.name) nt = m.node_tree b = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None) out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None) def src(name): s = b.inputs[name] return s.links[0].from_socket if s.is_linked else s.default_value if b is not None and kind in ("col", "rmc"): em = nt.nodes.new('ShaderNodeEmission') em.inputs["Strength"].default_value = 1.0 if kind == "col": _feed(nt, src("Base Color"), em.inputs["Color"]) else: cc = nt.nodes.new('ShaderNodeCombineColor') _feed(nt, src("Roughness"), cc.inputs[0]) _feed(nt, src("Metallic"), cc.inputs[1]) _feed(nt, src("Coat Weight"), cc.inputs[2]) nt.links.new(cc.outputs[0], em.inputs["Color"]) nt.links.new(em.outputs[0], out.inputs["Surface"]) ti = nt.nodes.new('ShaderNodeTexImage') ti.image = img nt.nodes.active = ti return m def bake_look(samples=None): """the final look on UV0 for Unreal: T_DoorArm_BaseColor (sRGB), T_DoorArm_ORM (R occlusion from the masks, G roughness, B metallic, A clear-coat weight), T_DoorArm_Normal (tangent, OpenGL: flip green in Unreal). The materials are dressed flat first (BAKE_FLAT: no flakes, one normal for base and coat; they are re-dressed for rendering afterwards). Colour, roughness/metallic/coat and normal are plain shader values: few samples.""" global BAKE_FLAT samples = samples or dict(col=1, rmc=1, nor=1) t0 = time.time() sc = bpy.context.scene kind = use_gpu(sc) masks, bevel = atlas_images(load_only=True) BAKE_FLAT = True try: dress_materials(masks, bevel, create=False) finally: BAKE_FLAT = False col = _new_image(LOOK["col"], 'sRGB', (0.0, 0.0, 0.0, 1.0)) rmc = _new_image("_bake_rmc", 'Non-Color', (0.5, 0.0, 0.0, 1.0)) nor = _new_image(LOOK["nor"], 'Non-Color', (0.5, 0.5, 1.0, 1.0)) sc.cycles.use_denoising = False sc.render.bake.margin = 3 sc.render.bake.use_clear = False tmp = _temp_copies(sc) orig = [list(o.data.materials) for o in tmp] rows = {} made = [] try: _select_only(tmp) for key, img, btype in (("col", col, 'EMIT'), ("rmc", rmc, 'EMIT'), ("nor", nor, 'NORMAL')): copies = {} for o, mats in zip(tmp, orig): for i, m in enumerate(mats): if m is None: continue if m.name not in copies: copies[m.name] = _bake_copy(m, key, img) made.append(copies[m.name]) o.data.materials[i] = copies[m.name] sc.cycles.samples = samples[key] t1 = time.time() if btype == 'NORMAL': bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', margin=3, use_clear=False) else: bpy.ops.object.bake(type='EMIT', margin=3, use_clear=False) rows[key] = round(time.time() - t1, 1) print("[dra] look pass", key, rows[key], "s", flush=True) finally: _drop(tmp) for m in made: bpy.data.materials.remove(m) # the packed ORM: occlusion from the masks bake, roughness / metallic / coat from the rmc pass n = ATLAS * ATLAS pm = np.empty(n * 4, np.float32) masks.pixels.foreach_get(pm) pr = np.empty(n * 4, np.float32) rmc.pixels.foreach_get(pr) pm, pr = pm.reshape(n, 4), pr.reshape(n, 4) orm = _new_image(LOOK["orm"], 'Non-Color', (1.0, 0.5, 0.0, 0.0), alpha=True) px = np.empty((n, 4), np.float32) px[:, 0] = pm[:, 0] px[:, 1] = pr[:, 0] px[:, 2] = pr[:, 1] px[:, 3] = pr[:, 2] orm.pixels.foreach_set(px.ravel()) paths = {} for im, nm in ((col, LOOK["col"]), (orm, LOOK["orm"]), (nor, LOOK["nor"])): paths[nm] = _save_png(im, nm) # what the maps hold, over the baked texels (the masks' blank fill marks the texels no face covers) covered = ~((pm[:, 0] > 0.999) & (pm[:, 1] < 0.001) & (pm[:, 2] > 0.999)) pc = np.empty(n * 4, np.float32) col.pixels.foreach_get(pc) pc = pc.reshape(n, 4) stats = dict(covered=round(float(covered.mean()), 3), base_colour_mean_srgb=[round(float(x), 3) for x in pc[covered, :3].mean(0)], roughness=[round(float(np.percentile(pr[covered, 0], q)), 3) for q in (5, 50, 95)], metallic=[round(float(np.percentile(pr[covered, 1], q)), 3) for q in (5, 50, 95)], coat_share=round(float((pr[covered, 2] > 0.5).mean()), 3)) bpy.data.images.remove(rmc) dress_materials(masks, bevel, create=False) # back to the render look (flakes, the coat's own normal) print("[dra] look baked", rows, stats, flush=True) return dict(device=kind, seconds=round(time.time() - t0, 1), size=ATLAS, samples=samples, passes=rows, files=paths, stats=stats) def surface_table(): """what each material is made of (for the README and whoever rebuilds the materials in Unreal)""" rows = [] for name, (kind, kw) in list(RECIPES.items()) + [(CABLE[0], (CABLE[1], CABLE[2]))]: r = dict(material=name, kind=kind) if kw: r.update({k: (list(v) if isinstance(v, tuple) else v) for k, v in kw.items()}) rows.append(r) return rows