d843162e5f
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>
245 lines
8.0 KiB
Python
245 lines
8.0 KiB
Python
"""Asset Libraries API — CRUD + .blend upload + catalog refresh."""
|
|
import uuid
|
|
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, Material, MaterialAlias
|
|
from app.utils.auth import require_admin_or_pm
|
|
|
|
router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"])
|
|
|
|
ASSET_LIB_DIR = "asset-libraries"
|
|
|
|
|
|
def _asset_lib_dir() -> Path:
|
|
d = Path(settings.upload_dir) / ASSET_LIB_DIR
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
# ── Schemas ──────────────────────────────────────────────────────────────────
|
|
|
|
class AssetLibraryOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: str | None
|
|
original_filename: str | None
|
|
catalog: dict
|
|
is_active: bool
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
class AssetLibraryUpdate(BaseModel):
|
|
name: str | None = None
|
|
description: str | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
def _to_out(lib: AssetLibrary) -> dict:
|
|
return {
|
|
"id": str(lib.id),
|
|
"name": lib.name,
|
|
"description": lib.description,
|
|
"original_filename": lib.original_filename,
|
|
"catalog": lib.catalog or {"materials": [], "node_groups": []},
|
|
"is_active": lib.is_active,
|
|
"created_at": lib.created_at.isoformat(),
|
|
"updated_at": lib.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
# ── 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),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
result = await db.execute(select(AssetLibrary).order_by(AssetLibrary.name))
|
|
return [_to_out(lib) for lib in result.scalars().all()]
|
|
|
|
|
|
@router.post("", response_model=AssetLibraryOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_asset_library(
|
|
name: str = Form(...),
|
|
description: str | None = Form(None),
|
|
blend_file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = AssetLibrary(
|
|
name=name,
|
|
description=description,
|
|
original_filename=blend_file.filename,
|
|
catalog={"materials": [], "node_groups": []},
|
|
)
|
|
db.add(lib)
|
|
await db.flush() # get the id
|
|
|
|
# Save .blend file
|
|
dest = _asset_lib_dir() / f"{lib.id}.blend"
|
|
with dest.open("wb") as f:
|
|
shutil.copyfileobj(blend_file.file, f)
|
|
lib.blend_file_path = str(dest)
|
|
|
|
await db.commit()
|
|
await db.refresh(lib)
|
|
|
|
# Queue catalog refresh
|
|
try:
|
|
from app.domains.materials.tasks import refresh_asset_library_catalog
|
|
refresh_asset_library_catalog.delay(str(lib.id))
|
|
except Exception:
|
|
pass # task queuing failure is non-blocking
|
|
|
|
return _to_out(lib)
|
|
|
|
|
|
@router.get("/{lib_id}", response_model=AssetLibraryOut)
|
|
async def get_asset_library(
|
|
lib_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = await db.get(AssetLibrary, lib_id)
|
|
if not lib:
|
|
raise HTTPException(status_code=404, detail="Asset library not found")
|
|
return _to_out(lib)
|
|
|
|
|
|
@router.patch("/{lib_id}", response_model=AssetLibraryOut)
|
|
async def update_asset_library(
|
|
lib_id: uuid.UUID,
|
|
body: AssetLibraryUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = await db.get(AssetLibrary, lib_id)
|
|
if not lib:
|
|
raise HTTPException(status_code=404, detail="Asset library not found")
|
|
for field, value in body.model_dump(exclude_none=True).items():
|
|
setattr(lib, field, value)
|
|
await db.commit()
|
|
await db.refresh(lib)
|
|
return _to_out(lib)
|
|
|
|
|
|
@router.post("/{lib_id}/upload-blend", response_model=AssetLibraryOut)
|
|
async def upload_blend(
|
|
lib_id: uuid.UUID,
|
|
blend_file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = await db.get(AssetLibrary, lib_id)
|
|
if not lib:
|
|
raise HTTPException(status_code=404, detail="Asset library not found")
|
|
|
|
dest = _asset_lib_dir() / f"{lib.id}.blend"
|
|
with dest.open("wb") as f:
|
|
shutil.copyfileobj(blend_file.file, f)
|
|
lib.blend_file_path = str(dest)
|
|
lib.original_filename = blend_file.filename
|
|
|
|
await db.commit()
|
|
await db.refresh(lib)
|
|
|
|
try:
|
|
from app.domains.materials.tasks import refresh_asset_library_catalog
|
|
refresh_asset_library_catalog.delay(str(lib.id))
|
|
except Exception:
|
|
pass
|
|
|
|
return _to_out(lib)
|
|
|
|
|
|
@router.post("/{lib_id}/refresh-catalog", response_model=AssetLibraryOut)
|
|
async def refresh_catalog(
|
|
lib_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = await db.get(AssetLibrary, lib_id)
|
|
if not lib:
|
|
raise HTTPException(status_code=404, detail="Asset library not found")
|
|
if not lib.blend_file_path:
|
|
raise HTTPException(status_code=400, detail="No .blend file uploaded yet")
|
|
|
|
from app.domains.materials.tasks import refresh_asset_library_catalog
|
|
refresh_asset_library_catalog.delay(str(lib.id))
|
|
return _to_out(lib)
|
|
|
|
|
|
@router.delete("/{lib_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_asset_library(
|
|
lib_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
_user=Depends(require_admin_or_pm),
|
|
):
|
|
lib = await db.get(AssetLibrary, lib_id)
|
|
if not lib:
|
|
raise HTTPException(status_code=404, detail="Asset library not found")
|
|
|
|
# Remove .blend file from disk
|
|
if lib.blend_file_path:
|
|
p = Path(lib.blend_file_path)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
await db.delete(lib)
|
|
await db.commit()
|