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>
This commit is contained in:
2026-03-13 10:37:23 +01:00
parent 577dd1ca7e
commit d843162e5f
12 changed files with 764 additions and 351 deletions
+47 -1
View File
@@ -4,13 +4,15 @@ import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from sqlalchemy import text
from app.config import settings
from app.domains.materials.models import AssetLibrary
from app.domains.materials.models import AssetLibrary, Material, MaterialAlias
from app.utils.auth import require_admin_or_pm
router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"])
@@ -58,6 +60,50 @@ def _to_out(lib: AssetLibrary) -> dict:
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/pbr-map")
async def get_material_pbr_map(db: AsyncSession = Depends(get_db)):
"""PBR properties for all materials in the active asset library.
Public (no auth) — needed by all 3D viewers.
Returns keyed by canonical name AND all known aliases so the viewer can
look up materials by raw Excel names (e.g. "Steel--Stahl") without needing
a separate alias resolution step.
"""
result = await db.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) # noqa: E712
)
lib = result.scalar_one_or_none()
if not lib or not lib.catalog:
return JSONResponse(content={}, headers={"Cache-Control": "public, max-age=3600"})
materials = lib.catalog.get("materials", [])
pbr_map: dict = {}
for m in materials:
if isinstance(m, str):
continue # old format — skip
pbr_map[m["name"]] = {
"base_color": m.get("base_color", [0.5, 0.5, 0.5]),
"metallic": m.get("metallic", 0.0),
"roughness": m.get("roughness", 0.5),
"transmission": m.get("transmission", 0.0),
"ior": m.get("ior", 1.45),
}
# Also index by aliases so frontend can look up by raw Excel names
# (e.g. "Steel--Stahl" → same PBR as "SCHAEFFLER_010101_Steel-Bare")
# Bypass RLS — this is public data and aliases may have NULL tenant_id
if pbr_map:
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
alias_result = await db.execute(
select(MaterialAlias.alias, Material.name)
.join(Material, MaterialAlias.material_id == Material.id)
)
for alias, canonical_name in alias_result.all():
if canonical_name in pbr_map and alias not in pbr_map:
pbr_map[alias] = pbr_map[canonical_name]
return JSONResponse(content=pbr_map, headers={"Cache-Control": "public, max-age=3600"})
@router.get("", response_model=list[AssetLibraryOut])
async def list_asset_libraries(
db: AsyncSession = Depends(get_db),