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:
@@ -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()
|
||||
Reference in New Issue
Block a user