Files
Hartmut d843162e5f feat(PBR): extract Blender PBR properties and apply in 3D viewer
Extract Base Color, Metallic, Roughness, Transmission, IOR from Blender
asset library materials via catalog_assets.py. Store in catalog JSON and
serve via /api/asset-libraries/pbr-map endpoint. Frontend viewers apply
PBR properties to Three.js MeshStandardMaterial using hex color strings
(avoiding Three.js ColorManagement sRGB/linear issues).

Key fixes:
- RLS bypass for material alias lookup in pbr-map endpoint
- pbrMap empty guard prevents premature grey fallback in viewers
- Cache-Control: no-cache on pbr-map requests to avoid stale data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:37:23 +01:00

104 lines
3.1 KiB
Python

"""Blender headless script: extract asset catalog from a .blend library file.
Usage:
blender --background --python catalog_assets.py -- <blend_path>
Outputs a single JSON line to stdout:
{"materials": [{"name": "Mat1", "base_color": [R,G,B], "metallic": 0.0, ...}, ...],
"node_groups": ["NG1", ...]}
Only assets marked via Blender's asset system (asset_data is not None) are included.
PBR properties are extracted from the Principled BSDF node of each material.
"""
from __future__ import annotations
import json
import math
import sys
import traceback
def _linear_to_srgb(v: float) -> float:
"""Convert a single linear color channel to sRGB."""
if v <= 0.0031308:
return v * 12.92
return 1.055 * math.pow(v, 1.0 / 2.4) - 0.055
def _extract_pbr(mat) -> dict:
"""Extract PBR properties from a Blender material's Principled BSDF node.
Returns dict with: name, base_color (sRGB), metallic, roughness, transmission, ior.
Falls back to diffuse_color if no Principled BSDF found.
"""
entry = {
"name": mat.name,
"base_color": [0.5, 0.5, 0.5],
"metallic": 0.0,
"roughness": 0.5,
"transmission": 0.0,
"ior": 1.45,
}
# Find Principled BSDF node
bsdf = None
if mat.use_nodes and mat.node_tree:
for node in mat.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
bsdf = node
break
if bsdf:
# Base Color — convert linear → sRGB
bc = bsdf.inputs["Base Color"].default_value
entry["base_color"] = [
round(_linear_to_srgb(bc[0]), 4),
round(_linear_to_srgb(bc[1]), 4),
round(_linear_to_srgb(bc[2]), 4),
]
entry["metallic"] = round(bsdf.inputs["Metallic"].default_value, 4)
entry["roughness"] = round(bsdf.inputs["Roughness"].default_value, 4)
# Transmission Weight (Blender 4.0+) or Transmission (older)
for tx_name in ("Transmission Weight", "Transmission"):
if tx_name in bsdf.inputs:
entry["transmission"] = round(bsdf.inputs[tx_name].default_value, 4)
break
if "IOR" in bsdf.inputs:
entry["ior"] = round(bsdf.inputs["IOR"].default_value, 4)
else:
# Fallback: use viewport diffuse_color (already sRGB)
dc = mat.diffuse_color
entry["base_color"] = [round(dc[0], 4), round(dc[1], 4), round(dc[2], 4)]
return entry
def main() -> None:
argv = sys.argv
if "--" not in argv:
print(json.dumps({"error": "No blend path provided after --"}))
sys.exit(1)
blend_path = argv[argv.index("--") + 1]
import bpy # type: ignore[import]
bpy.ops.wm.open_mainfile(filepath=blend_path)
materials = [
_extract_pbr(m) for m in bpy.data.materials if m.asset_data is not None
]
node_groups = [ng.name for ng in bpy.data.node_groups if ng.asset_data is not None]
catalog = {"materials": materials, "node_groups": node_groups}
print(json.dumps(catalog))
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)