"""A more realistic surface for the pressure door: scanned painted steel under the baked masks. The user, 2026-10-15, on the door as it stands in the ship: "Do a more realistic color and texture on it". The first surfaces (pd_surfaces.py) were one tint shaded by the pack's scratched-metal set: flat, pale, like plastic. This puts a real scan of a painted steel plate under everything painted - Poly Haven's `blue_metal_plate` (CC0; a 2.5 m scan of a scuffed, scratched navy-blue steel panel), fetched to models/pressure-door/source_polyhaven/ with a manifest - and: - lets the scan's own colour variation, scratches and scuffs show (lightened a little so it reads in the ship's dim light), - chips the paint on exposed edges down to bare steel, shades the crevices and the baked occlusion (the masks of pd_surfaces.py, UV layer `UVMap`), - lays a little dust on the faces that look up, with a procedural breakup, and smudges in the roughness, - gives the paint a thin semi-gloss coat (enamel), matte where it is dusty or grimy, - makes the bright steel parts brushed stainless instead of chrome, and the dark parts matte. The materials keep their names (M_PD_Paint, M_PD_Steel, M_PD_Dark, M_PD_RedPaint, M_PD_Rubber): every mesh that wears them changes with them, in the ship (the door, the wall plate, the sleeve, the collar) and in the door's own file. python -I build/pd_look.py --fetch # the scan (about 6 MB at 2K) in Blender (the open file or headless): import pd_look; pd_look.look() """ import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) FOLDER = os.path.dirname(HERE) SRC = os.path.join(FOLDER, "source_polyhaven") PACK = os.path.normpath(os.path.join(FOLDER, "..", "vessel-interior", "textures", "ModSci_Engineer", "Textures")) SCAN = "blue_metal_plate" SCAN_RES = "2k" SCAN_MAPS = ("Diffuse", "nor_gl", "Rough", "AO") UA = {"User-Agent": "TheDescent-PressureDoor/1.0 (Blender asset fetch)"} # what the scan covers, metres to one repeat (the plate was scanned at 2.5 m; a smaller repeat keeps the scratches fine) TILE_PAINT = 1.25 TILE_STEEL = 0.50 TILE_DARK = 0.70 # ------------------------------------------------------------------ the scan, from polyhaven.com def fetch(): import urllib.request def get_json(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=60) as r: return json.loads(r.read().decode("utf-8")) def get(url, path): os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.isfile(path) and os.path.getsize(path) > 0: return os.path.getsize(path), False req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=300) as r, open(path + ".part", "wb") as f: while True: chunk = r.read(1 << 20) if not chunk: break f.write(chunk) os.replace(path + ".part", path) return os.path.getsize(path), True mp = os.path.join(SRC, "manifest.json") manifest = json.load(open(mp, encoding="utf-8")) if os.path.isfile(mp) else \ {"source": "https://polyhaven.com", "licence": "CC0 1.0 (public domain)", "assets": {}} files = get_json("https://api.polyhaven.com/files/" + SCAN) got, total = [], 0 for m in SCAN_MAPS: u = files[m][SCAN_RES]["jpg"]["url"] if not u.startswith("https://dl.polyhaven.org/"): print("skipped (not from the site's own store):", u) continue name = u.rsplit("/", 1)[-1] size, new = get(u, os.path.join(SRC, "textures", SCAN, name)) got.append({"file": name, "bytes": size, "url": u}) total += size print("%-40s %7.1f MB %s" % (name, size / 1e6, "fetched" if new else "had it")) manifest["assets"][SCAN] = {"kind": "textures", "resolution": SCAN_RES, "page": "https://polyhaven.com/a/" + SCAN, "files": got} with open(mp, "w", encoding="utf-8") as f: json.dump(manifest, f, indent=1) print("in all %.1f MB in %s" % (total / 1e6, SRC)) return got def scan_path(m): return os.path.join(SRC, "textures", SCAN, "%s_%s_%s.jpg" % (SCAN, {"Diffuse": "diff", "nor_gl": "nor_gl", "Rough": "rough", "AO": "ao"}[m], SCAN_RES)) # ------------------------------------------------------------------ Blender try: import bpy except ImportError: # (python -I build/pd_look.py --fetch) bpy = None if bpy is not None: import numpy as np _RANGE = {} def tex_image(path, colorspace="sRGB"): """the image datablock for a file, loaded once; relative to the open file when there is one""" 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) if bpy.data.filepath: im.filepath = bpy.path.relpath(path) im.colorspace_settings.name = colorspace return im def tex_range(im, lo=3, hi=97): """the image's luminance between two percentiles (linear): maps that read their full range""" 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)) 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: 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 uv(self, name): return self.node('ShaderNodeUVMap', uv_map=name).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): """1 where the face looks up (dust settles there), 0 on walls and undersides""" 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 breakup(self, g, scale, label="breakup"): """a soft procedural patchiness in object space""" n = self.node('ShaderNodeTexNoise', label) n.inputs["Scale"].default_value = scale n.inputs["Detail"].default_value = 3.0 n.inputs["Roughness"].default_value = 0.55 self.link(g.outputs["Position"], n.inputs["Vector"]) return n.outputs["Fac"] def masks_rgb(t, masks): if masks is None: return None, None, None sep = t.node('ShaderNodeSeparateColor') t.link(t.tex(masks, t.uv("UVMap"), "baked: R occlusion, G edges, B crevices").outputs["Color"], sep.inputs[0]) return sep.outputs[0], sep.outputs[1], sep.outputs[2] def paint(mat, masks, tile=TILE_PAINT, lift=(1.38, 0.88), tint=None, wear=0.55, grime=0.30, dust=0.18, coat=0.22, metallic=0.0, grey=None, rough=(0.22, 0.58), chip=(0.30, 0.31, 0.32)): """painted steel: the scan's colour (lifted in value, eased in saturation; tint multiplies it where given), its roughness and relief, chips to bare steel on exposed edges, grime in crevices, dust on top, a thin enamel coat. grey=(r, g, b): instead of the scan's colour, that tint shaded by the scan's light and dark (a metallic finish with metallic > 0); chip: the bare metal under the paint.""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.uv("UV_Tile") mp = t.mapped(u, 1.0 / tile, "one repeat = %.2f m" % tile) diff_im = tex_image(scan_path("Diffuse")) diff = t.tex(diff_im, mp, "scan: painted steel").outputs["Color"] if grey is not None: lo, hi = tex_range(diff_im) bw = t.node('ShaderNodeRGBToBW') t.link(diff, bw.inputs[0]) sh = t.ranged(bw.outputs[0], 0.78, 1.18, "the scan's light and dark", lo, hi) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = grey t.link(sh, vm.inputs["Scale"]) col = vm.outputs["Vector"] else: hs = t.node('ShaderNodeHueSaturation', "lift the scan") hs.inputs["Value"].default_value = lift[0] hs.inputs["Saturation"].default_value = lift[1] t.link(diff, hs.inputs["Color"]) col = hs.outputs["Color"] if tint is not None: vm = t.node('ShaderNodeVectorMath', "tint", operation='MULTIPLY') t.link(col, vm.inputs[0]) vm.inputs[1].default_value = tint col = vm.outputs["Vector"] ao_scan = t.tex(tex_image(scan_path("AO"), "Non-Color"), mp, "scan: occlusion").outputs["Color"] col = t.mixc(t.ranged(ao_scan, 0.55, 0.0, "scan occlusion", 0.0, 1.0), col, (0.01, 0.01, 0.012), "scan occlusion") ro_im = tex_image(scan_path("Rough"), "Non-Color") rlo, rhi = tex_range(ro_im) ro = t.tex(ro_im, mp, "scan: roughness").outputs["Color"] rr = t.ranged(ro, rough[0], rough[1], "roughness range", rlo, rhi) dirt = t.tex(tex_image(os.path.join(PACK, "Base", "Glass", "T_Glass_DirtRough.png"), "Non-Color"), t.mapped(u, 1.0 / 2.6), "smudges").outputs["Color"] rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, 0.10), clamp=True, label="smudges dull the sheen") ao, edge, crev = masks_rgb(t, masks) coat_w = coat metal = metallic if crev is not None: g = t.math('MULTIPLY', t.math('POWER', t.math('SUBTRACT', 1.0, crev), 1.3), grime, clamp=True, label="grime in crevices") col = t.mixc(g, col, (0.018, 0.017, 0.016), "darken crevices") rr = t.math('ADD', rr, t.math('MULTIPLY', g, 0.45), clamp=True, label="grime is matte") coat_w = t.mixf(g, coat, 0.0, "no coat on grime") if ao is not None: col = t.mixc(t.ranged(ao, 0.40, 0.0, "baked occlusion", 0.0, 1.0), col, (0.008, 0.008, 0.009), "baked occlusion") upf, geo = t.up() if dust > 0: d = t.math('MULTIPLY', t.math('MULTIPLY', upf, t.ranged(t.breakup(geo, 2.3, "dust patches"), 0.0, 1.0, None, 0.35, 0.75, smooth=True)), dust, clamp=True, label="dust") col = t.mixc(d, col, (0.40, 0.385, 0.36), "dust") rr = t.math('ADD', rr, t.math('MULTIPLY', d, 0.9), clamp=True, label="dust is matte") coat_w = t.mixf(d, coat_w, 0.0, "no coat under dust") 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 where the part is exposed", 0.70, 0.93, 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) steel = t.tex(tex_image(os.path.join(PACK, "Base", "StainedSteel", "T_StainSteeled_Diff.png")), t.mapped(u, 1.0 / TILE_STEEL), "bare steel").outputs["Color"] steel = t.mixc(0.35, steel, chip, "bare metal") col = t.mixc(w, col, steel, "steel under the paint") rr = t.mixf(w, rr, 0.38) metal = t.mixf(w, metallic, 1.0) coat_w = t.mixf(w, coat_w, 0.0, "no coat on bare steel") 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 = 0.28 b.inputs["Specular IOR Level"].default_value = 0.45 nd = t.node('ShaderNodeNormalMap', "scan: relief", uv_map="UV_Tile") nd.inputs["Strength"].default_value = 0.75 t.link(t.tex(tex_image(scan_path("nor_gl"), "Non-Color"), mp, "scan: normal").outputs["Color"], nd.inputs["Color"]) t.link(nd.outputs["Normal"], b.inputs["Normal"]) mat.diffuse_color = (tuple(grey) + (1.0,)) if grey is not None else (0.10, 0.15, 0.21, 1.0) mat.metallic = float(metallic) mat.roughness = 0.4 mat["pd_look"] = "scan %s, tile %.2f m%s" % (SCAN, tile, ", dark grey metallic" if grey is not None else "") def steel(mat, masks, tile=TILE_STEEL, rough=(0.30, 0.50), tint=(0.78, 0.78, 0.79), grime=0.22): """brushed stainless: the pack's stained-steel set, a scratch relief, smudges; grime in crevices""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.uv("UV_Tile") mp = t.mapped(u, 1.0 / tile, "one repeat = %.2f m" % tile) dim = tex_image(os.path.join(PACK, "Base", "StainedSteel", "T_StainSteeled_Diff.png")) lo, hi = tex_range(dim) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(dim, mp, "surface, diffuse").outputs["Color"], bw.inputs[0]) sh = t.ranged(bw.outputs[0], 0.80, 1.08, "shade the tint", lo, hi) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = tint t.link(sh, vm.inputs["Scale"]) col = vm.outputs["Vector"] rim = tex_image(os.path.join(PACK, "Base", "StainedSteel", "T_StainSteeled_Rough.png"), "Non-Color") rlo, rhi = tex_range(rim) rr = t.ranged(t.tex(rim, mp, "surface, roughness").outputs["Color"], rough[0], rough[1], "roughness range", rlo, rhi) dirt = t.tex(tex_image(os.path.join(PACK, "Base", "Glass", "T_Glass_DirtRough.png"), "Non-Color"), t.mapped(u, 1.0 / 0.9), "smudges").outputs["Color"] rr = t.math('ADD', rr, t.math('MULTIPLY', dirt, 0.18), clamp=True, label="fingerprints dull the sheen") ao, edge, crev = masks_rgb(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, label="grime in crevices") col = t.mixc(g, col, (0.02, 0.019, 0.018), "darken crevices") rr = t.math('ADD', rr, t.math('MULTIPLY', g, 0.4), clamp=True, label="grime is matte") if ao is not None: col = t.mixc(t.ranged(ao, 0.35, 0.0, "baked occlusion", 0.0, 1.0), col, (0.01, 0.01, 0.01), "baked 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 = 0.35 nd = t.node('ShaderNodeNormalMap', "scratches", uv_map="UV_Tile") nd.inputs["Strength"].default_value = 0.5 t.link(t.tex(tex_image(os.path.join(PACK, "Base", "Glass", "T_Scratch_Norm.png"), "Non-Color"), mp, "scratch normal").outputs["Color"], nd.inputs["Color"]) t.link(nd.outputs["Normal"], b.inputs["Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = 1.0 mat.roughness = 0.4 mat["pd_look"] = "brushed stainless" def dark(mat, masks, tile=TILE_DARK, tint=(0.030, 0.031, 0.034), rough=(0.50, 0.72), grime=0.14, dust=0.12): """matte black fittings (tread, keypad, vents): the scan's scuffs and relief under a near-black tint""" t = Tree(mat) b = t.node('ShaderNodeBsdfPrincipled') out = t.node('ShaderNodeOutputMaterial') t.link(b.outputs[0], out.inputs["Surface"]) u = t.uv("UV_Tile") mp = t.mapped(u, 1.0 / tile, "one repeat = %.2f m" % tile) dim = tex_image(scan_path("Diffuse")) lo, hi = tex_range(dim) bw = t.node('ShaderNodeRGBToBW') t.link(t.tex(dim, mp, "scan: scuffs").outputs["Color"], bw.inputs[0]) sh = t.ranged(bw.outputs[0], 0.72, 1.30, "shade the tint", lo, hi) vm = t.node('ShaderNodeVectorMath', "tint x shade", operation='SCALE') vm.inputs[0].default_value = tint t.link(sh, vm.inputs["Scale"]) col = vm.outputs["Vector"] ro_im = tex_image(scan_path("Rough"), "Non-Color") rlo, rhi = tex_range(ro_im) rr = t.ranged(t.tex(ro_im, mp, "scan: roughness").outputs["Color"], rough[0], rough[1], "roughness range", rlo, rhi) ao, edge, crev = masks_rgb(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, label="grime in crevices") col = t.mixc(g, col, (0.012, 0.012, 0.011), "darken crevices") rr = t.math('ADD', rr, t.math('MULTIPLY', g, 0.3), clamp=True, label="grime is matte") if ao is not None: col = t.mixc(t.ranged(ao, 0.35, 0.0, "baked occlusion", 0.0, 1.0), col, (0.006, 0.006, 0.006), "baked occlusion") upf, geo = t.up() if dust > 0: d = t.math('MULTIPLY', t.math('MULTIPLY', upf, t.ranged(t.breakup(geo, 2.3, "dust patches"), 0.0, 1.0, None, 0.35, 0.75, smooth=True)), dust, clamp=True, label="dust") col = t.mixc(d, col, (0.30, 0.29, 0.27), "dust") rr = t.math('ADD', rr, t.math('MULTIPLY', d, 0.5), clamp=True, label="dust is matte") 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.4 nd = t.node('ShaderNodeNormalMap', "scan: relief", uv_map="UV_Tile") nd.inputs["Strength"].default_value = 0.6 t.link(t.tex(tex_image(scan_path("nor_gl"), "Non-Color"), mp, "scan: normal").outputs["Color"], nd.inputs["Color"]) t.link(nd.outputs["Normal"], b.inputs["Normal"]) mat.diffuse_color = tuple(tint) + (1.0,) mat.metallic = 0.0 mat.roughness = 0.6 mat["pd_look"] = "matte black, scan %s" % SCAN # the finishes of the painted parts. "navy": the scan's own colour (the first realistic look, 02:40 on 2026-10-15). # "gunmetal": the user, 02:50 the same night: "Do it dark grey mettalic" - a dark grey metallic finish, the scan's # scuffs as light and dark in it, chips to brighter steel, a thin clear coat. STYLES = { "navy": dict(), "gunmetal": dict(grey=(0.21, 0.215, 0.23), metallic=0.92, rough=(0.26, 0.50), chip=(0.58, 0.58, 0.60), coat=0.16, dust=0.12, wear=0.5, grime=0.32), } STYLE = "gunmetal" def look(which=None, style=STYLE, prefix="M_PD_", masks_name="T_PD_Masks"): """dress the door's Paint, RedPaint, Steel and Dark materials. The hinged door's are M_PD_* with the atlas T_PD_Masks; the sliding door's (models/pressure-door-sliding, the same two UV layers and the same baked channels) are M_PDS_* with T_PDS_Masks: look(prefix="M_PDS_", masks_name="T_PDS_Masks"). The user, 2026-10-15, on the sliding door in the ship: "Do the same realistic dark grey mettalic texture on the sliding door too".""" masks = bpy.data.images.get(masks_name) done = {} if which is None or prefix + "Paint" in which: m = bpy.data.materials.get(prefix + "Paint") if m: paint(m, masks, **STYLES[style]) done[prefix + "Paint"] = m["pd_look"] if which is None or prefix + "RedPaint" in which: m = bpy.data.materials.get(prefix + "RedPaint") if m: paint(m, masks, tile=0.60, lift=(1.0, 0.0), tint=(2.8, 0.22, 0.16), wear=0.5, grime=0.28, dust=0.1, coat=0.2) done[prefix + "RedPaint"] = m["pd_look"] if which is None or prefix + "Steel" in which: m = bpy.data.materials.get(prefix + "Steel") if m: steel(m, masks) done[prefix + "Steel"] = m["pd_look"] if which is None or prefix + "Dark" in which: m = bpy.data.materials.get(prefix + "Dark") if m: dark(m, masks) done[prefix + "Dark"] = m["pd_look"] missing = [i.name for i in bpy.data.images if i.filepath and not os.path.exists(bpy.path.abspath(i.filepath)) and (SCAN in i.name or i.name.startswith(masks_name[:5]))] return {"dressed": done, "missing_images": missing} if __name__ == "__main__" and "--fetch" in sys.argv: fetch()