feat(K): Blender Asset Library + production exports (GLB + .blend)

- feat(migration): 045_asset_libraries — new asset_libraries table (blend_file_path, catalog JSONB)
- feat(model): AssetLibrary SQLAlchemy model in domains/materials/models.py
- feat(api): POST/GET/PATCH/DELETE /api/asset-libraries + /upload-blend + /refresh-catalog endpoints
- feat(celery): refresh_asset_library_catalog task on thumbnail_rendering queue — runs Blender headless
- feat(blender): catalog_assets.py — extracts asset-marked materials + node_groups from .blend
- feat(blender): asset_library.py — apply_asset_library_materials + apply_asset_library_node_groups helpers
- feat(blender): export_gltf.py — STEP→STL→GLB production export with optional asset library
- feat(blender): export_blend.py — STEP→STL→.blend production export with pack_all()
- feat(frontend): api/assetLibraries.ts — full CRUD API client
- feat(frontend): AssetLibraryPanel in Admin.tsx — upload, refresh, expand catalog view
- docs: Blender asset_data marking requirement learning in LEARNINGS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 20:56:26 +01:00
parent 7a1329958d
commit a18d4c23ec
14 changed files with 922 additions and 10 deletions
+199
View File
@@ -0,0 +1,199 @@
"""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 FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.database import get_db
from app.config import settings
from app.domains.materials.models import AssetLibrary
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("", 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()
+19 -2
View File
@@ -1,8 +1,8 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
# TYPE_CHECKING import to avoid circular references
from typing import TYPE_CHECKING
@@ -45,3 +45,20 @@ class MaterialAlias(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
material = relationship("Material", back_populates="aliases")
class AssetLibrary(Base):
__tablename__ = "asset_libraries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
blend_file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
original_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
catalog: Mapped[dict] = mapped_column(JSONB, nullable=False, default=lambda: {"materials": [], "node_groups": []})
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+105
View File
@@ -0,0 +1,105 @@
"""Celery tasks for asset library management."""
from __future__ import annotations
import json
import logging
import subprocess
import uuid
from pathlib import Path
from celery import shared_task
logger = logging.getLogger(__name__)
CATALOG_SCRIPT = Path(__file__).parent.parent.parent.parent.parent / "render-worker" / "scripts" / "catalog_assets.py"
@shared_task(
name="app.domains.materials.tasks.refresh_asset_library_catalog",
queue="thumbnail_rendering",
bind=True,
max_retries=2,
default_retry_delay=30,
)
def refresh_asset_library_catalog(self, asset_library_id: str) -> None:
"""Run Blender headless to extract catalog from a .blend asset library.
Updates the `catalog` JSONB column of the AssetLibrary record.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.domains.materials.models import AssetLibrary
from app.config import settings
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://")
try:
engine = create_engine(sync_url)
with Session(engine) as db:
lib = db.get(AssetLibrary, uuid.UUID(asset_library_id))
if not lib:
logger.warning("AssetLibrary %s not found", asset_library_id)
return
blend_path = lib.blend_file_path
engine.dispose()
if not blend_path or not Path(blend_path).exists():
logger.warning("AssetLibrary %s: blend file not found at %s", asset_library_id, blend_path)
return
# Determine Blender binary
import os
blender_bin = os.environ.get("BLENDER_BIN", "blender")
result = subprocess.run(
[
blender_bin,
"--background",
"--python", str(CATALOG_SCRIPT),
"--", blend_path,
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error("catalog_assets.py failed (exit %d):\n%s", result.returncode, result.stderr)
return
# Parse catalog JSON from stdout (last line that starts with '{')
catalog = None
for line in reversed(result.stdout.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
catalog = json.loads(line)
break
except json.JSONDecodeError:
continue
if catalog is None:
logger.error("catalog_assets.py: no JSON found in output:\n%s", result.stdout)
return
# Persist catalog
engine2 = create_engine(sync_url)
with Session(engine2) as db:
lib = db.get(AssetLibrary, uuid.UUID(asset_library_id))
if lib:
lib.catalog = catalog
db.commit()
logger.info(
"AssetLibrary %s catalog updated: %d materials, %d node_groups",
asset_library_id,
len(catalog.get("materials", [])),
len(catalog.get("node_groups", [])),
)
engine2.dispose()
except subprocess.TimeoutExpired:
logger.error("catalog_assets.py timed out for library %s", asset_library_id)
raise self.retry(countdown=60)
except Exception as exc:
logger.exception("refresh_asset_library_catalog failed: %s", exc)
raise self.retry(exc=exc)
+2
View File
@@ -22,6 +22,7 @@ from app.domains.billing.router import pricing_router, invoice_router
from app.domains.tenants.router import router as tenants_router
from app.domains.rendering.workflow_router import router as workflows_router
from app.domains.media.router import router as media_router
from app.api.routers.asset_libraries import router as asset_libraries_router
@asynccontextmanager
@@ -86,6 +87,7 @@ app.include_router(notifications_router, prefix="/api")
app.include_router(tenants_router, prefix="/api")
app.include_router(workflows_router)
app.include_router(media_router)
app.include_router(asset_libraries_router, prefix="/api")
@app.get("/health")
+1
View File
@@ -13,6 +13,7 @@ celery_app = Celery(
"app.domains.rendering.tasks",
"app.domains.products.tasks",
"app.domains.imports.tasks",
"app.domains.materials.tasks",
],
)