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
+8
View File
@@ -262,6 +262,14 @@ SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg
--- ---
### 2026-03-06 | Blender | Asset Library link=True — Assets müssen in .blend als Asset markiert sein
**Problem:** `bpy.data.libraries.load(blend_path, link=True, assets_only=True)` liefert nur Materialien/Node-Groups die explizit via Blender's Asset-System markiert wurden (`asset_data is not None`). Nicht markierte Datenblöcke werden ignoriert.
**Lösung:** In der .blend-Datei: jedes Material/Node-Group das gelinkt werden soll muss via "Mark as Asset" (F3 → "Mark as Asset") markiert sein.
**catalog_assets.py** filtert via `m.asset_data is not None` — dieser Filter muss konsistent in catalog_assets.py und asset_library.py verwendet werden.
**Für künftige Projekte:** Immer "Mark as Asset" dokumentieren wenn .blend-Libraries an User weitergegeben werden.
---
### 2026-03-06 | Celery Inspect | active_queues() zum Worker-Capability-Check ### 2026-03-06 | Celery Inspect | active_queues() zum Worker-Capability-Check
**Erkenntnis:** `celery_app.control.inspect().active_queues()` gibt pro Worker zurück welche Queues er konsumiert. Damit kann man gezielt prüfen ob ein Worker mit bestimmten Fähigkeiten (z.B. `thumbnail_rendering`) connected ist — besser als Worker-Namen-Heuristiken. **Erkenntnis:** `celery_app.control.inspect().active_queues()` gibt pro Worker zurück welche Queues er konsumiert. Damit kann man gezielt prüfen ob ein Worker mit bestimmten Fähigkeiten (z.B. `thumbnail_rendering`) connected ist — besser als Worker-Namen-Heuristiken.
**Anwendung:** `GET /api/worker/health/render` nutzt `active_queues()` um `render_worker_connected` und `blender_available` korrekt zu bestimmen. **Anwendung:** `GET /api/worker/health/render` nutzt `active_queues()` um `render_worker_connected` und `blender_available` korrekt zu bestimmen.
@@ -0,0 +1,34 @@
"""asset_libraries table
Revision ID: 045
Revises: 044
Create Date: 2026-03-06
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "045"
down_revision = "044"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"asset_libraries",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("blend_file_path", sa.Text, nullable=True),
sa.Column("original_filename", sa.String(500), nullable=True),
sa.Column("catalog", JSONB, nullable=False, server_default='{"materials": [], "node_groups": []}'),
sa.Column("description", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
)
def downgrade() -> None:
op.drop_table("asset_libraries")
+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 import uuid
from datetime import datetime 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base from app.database import Base
# TYPE_CHECKING import to avoid circular references # TYPE_CHECKING import to avoid circular references
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -45,3 +45,20 @@ class MaterialAlias(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
material = relationship("Material", back_populates="aliases") 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.tenants.router import router as tenants_router
from app.domains.rendering.workflow_router import router as workflows_router from app.domains.rendering.workflow_router import router as workflows_router
from app.domains.media.router import router as media_router from app.domains.media.router import router as media_router
from app.api.routers.asset_libraries import router as asset_libraries_router
@asynccontextmanager @asynccontextmanager
@@ -86,6 +87,7 @@ app.include_router(notifications_router, prefix="/api")
app.include_router(tenants_router, prefix="/api") app.include_router(tenants_router, prefix="/api")
app.include_router(workflows_router) app.include_router(workflows_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(asset_libraries_router, prefix="/api")
@app.get("/health") @app.get("/health")
+1
View File
@@ -13,6 +13,7 @@ celery_app = Celery(
"app.domains.rendering.tasks", "app.domains.rendering.tasks",
"app.domains.products.tasks", "app.domains.products.tasks",
"app.domains.imports.tasks", "app.domains.imports.tasks",
"app.domains.materials.tasks",
], ],
) )
+60
View File
@@ -0,0 +1,60 @@
import api from './client'
export interface AssetLibraryCatalog {
materials: string[]
node_groups: string[]
}
export interface AssetLibrary {
id: string
name: string
description: string | null
original_filename: string | null
catalog: AssetLibraryCatalog
is_active: boolean
created_at: string
updated_at: string
}
export async function listAssetLibraries(): Promise<AssetLibrary[]> {
const { data } = await api.get<AssetLibrary[]>('/asset-libraries')
return data
}
export async function getAssetLibrary(id: string): Promise<AssetLibrary> {
const { data } = await api.get<AssetLibrary>(`/asset-libraries/${id}`)
return data
}
export async function createAssetLibrary(params: {
name: string
description?: string
blend_file: File
}): Promise<AssetLibrary> {
const form = new FormData()
form.append('name', params.name)
if (params.description) form.append('description', params.description)
form.append('blend_file', params.blend_file)
const { data } = await api.post<AssetLibrary>('/asset-libraries', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function uploadAssetLibraryBlend(id: string, file: File): Promise<AssetLibrary> {
const form = new FormData()
form.append('blend_file', file)
const { data } = await api.post<AssetLibrary>(`/asset-libraries/${id}/upload-blend`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function refreshAssetLibraryCatalog(id: string): Promise<AssetLibrary> {
const { data } = await api.post<AssetLibrary>(`/asset-libraries/${id}/refresh-catalog`)
return data
}
export async function deleteAssetLibrary(id: string): Promise<void> {
await api.delete(`/asset-libraries/${id}`)
}
+205
View File
@@ -13,6 +13,10 @@ import { getMaterialLibraryInfo, uploadMaterialLibrary, deleteMaterialLibrary }
import type { MaterialLibraryInfo } from '../api/renderTemplates' import type { MaterialLibraryInfo } from '../api/renderTemplates'
import { listPricingTiers } from '../api/pricing' import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes' import { listOutputTypes } from '../api/outputTypes'
import {
listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog,
type AssetLibrary,
} from '../api/assetLibraries'
export default function AdminPage() { export default function AdminPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -218,6 +222,11 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Users (admin only) */} {/* Users (admin only) */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
@@ -1041,3 +1050,199 @@ function PricingSummaryCard() {
</div> </div>
) )
} }
// ── Asset Library Panel ───────────────────────────────────────────────────────
function AssetLibraryPanel() {
const qc = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
queryFn: listAssetLibraries,
})
const createMut = useMutation({
mutationFn: () => createAssetLibrary({ name: newName, description: newDesc || undefined, blend_file: newFile! }),
onSuccess: () => {
toast.success('Asset library created')
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
setShowCreate(false)
setNewName('')
setNewDesc('')
setNewFile(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteAssetLibrary(id),
onSuccess: () => {
toast.success('Asset library deleted')
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const refreshMut = useMutation({
mutationFn: (id: string) => refreshAssetLibraryCatalog(id),
onSuccess: () => {
toast.success('Catalog refresh queued')
setTimeout(() => qc.invalidateQueries({ queryKey: ['asset-libraries'] }), 3000)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to refresh'),
})
const toggle = (id: string) =>
setExpanded((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n })
return (
<div className="card">
<div className="p-4 border-b border-border-light flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Asset Libraries</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload Blender .blend files containing production materials and node groups.
</p>
</div>
</div>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
<Plus size={14} />New Library
</button>
</div>
{showCreate && (
<div className="p-4 border-b border-border-light bg-surface-alt space-y-3">
<div className="flex gap-3">
<input
className="input flex-1"
placeholder="Library name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<input
className="input flex-1"
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<label className="btn-secondary cursor-pointer">
<Upload size={14} />
{newFile ? newFile.name : 'Choose .blend file'}
<input
type="file"
accept=".blend"
className="hidden"
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
/>
</label>
<button
className="btn-primary"
disabled={!newName || !newFile || createMut.isPending}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? 'Creating' : 'Create'}
</button>
<button className="btn-secondary" onClick={() => setShowCreate(false)}>
<X size={14} />
</button>
</div>
</div>
)}
{libraries.length === 0 ? (
<div className="p-8 text-center text-content-muted text-sm">
No asset libraries yet. Upload a .blend file to get started.
</div>
) : (
<div className="divide-y divide-border-light">
{(libraries as AssetLibrary[]).map((lib) => {
const isExpanded = expanded.has(lib.id)
const matCount = lib.catalog.materials.length
const ngCount = lib.catalog.node_groups.length
return (
<div key={lib.id}>
<div className="p-4 flex items-center gap-3">
<button
onClick={() => toggle(lib.id)}
className="text-content-muted hover:text-content"
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium text-content text-sm">{lib.name}</p>
{lib.description && (
<p className="text-xs text-content-muted">{lib.description}</p>
)}
</div>
<span className="text-xs text-content-muted">
{lib.original_filename ?? ''}
</span>
<span className="badge-neutral text-xs">{matCount} materials</span>
<span className="badge-neutral text-xs">{ngCount} node groups</span>
<button
className="btn-secondary text-xs"
onClick={() => refreshMut.mutate(lib.id)}
disabled={refreshMut.isPending}
title="Re-scan catalog from .blend"
>
<RefreshCw size={12} />Refresh
</button>
<button
className="btn-danger text-xs"
onClick={() => { if (confirm(`Delete "${lib.name}"?`)) deleteMut.mutate(lib.id) }}
>
<Trash2 size={12} />
</button>
</div>
{isExpanded && (
<div className="px-10 pb-4 space-y-3">
{matCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Materials</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.materials.map((m) => (
<span key={m} className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{m}
</span>
))}
</div>
</div>
)}
{ngCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Node Groups</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.node_groups.map((ng) => (
<span key={ng} className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{ng}
</span>
))}
</div>
</div>
)}
{matCount === 0 && ngCount === 0 && (
<p className="text-xs text-content-muted italic">
No assets found. Click "Refresh" to scan the .blend for marked assets.
</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
+8 -8
View File
@@ -257,7 +257,7 @@ Frontend:
## Phase K Tasks (nach Commit) ## Phase K Tasks (nach Commit)
### Task K1: Migration 045 + AssetLibrary Model [ ] ### Task K1: Migration 045 + AssetLibrary Model [x]
- **Datei**: `backend/alembic/versions/045_asset_libraries.py` (neu, autogenerate), `domains/materials/models.py` - **Datei**: `backend/alembic/versions/045_asset_libraries.py` (neu, autogenerate), `domains/materials/models.py`
- **Was**: - **Was**:
```python ```python
@@ -271,7 +271,7 @@ Frontend:
- `output_types.asset_library_id` FK optional (nullable) - `output_types.asset_library_id` FK optional (nullable)
- **Akzeptanzkriterium**: `alembic upgrade head` erfolgreich, `asset_libraries` Tabelle in DB - **Akzeptanzkriterium**: `alembic upgrade head` erfolgreich, `asset_libraries` Tabelle in DB
### Task K2: Asset Library CRUD Backend [ ] ### Task K2: Asset Library CRUD Backend [x]
- **Datei**: `backend/app/domains/materials/router.py` + `service.py` + `schemas.py` - **Datei**: `backend/app/domains/materials/router.py` + `service.py` + `schemas.py`
- **Was**: - **Was**:
- `POST /api/asset-libraries` -- .blend Upload -> MinIO `asset-libraries/{id}.blend` -> queut Katalog-Refresh - `POST /api/asset-libraries` -- .blend Upload -> MinIO `asset-libraries/{id}.blend` -> queut Katalog-Refresh
@@ -281,7 +281,7 @@ Frontend:
- `AssetLibraryOut` Schema mit `catalog` field - `AssetLibraryOut` Schema mit `catalog` field
- **Akzeptanzkriterium**: POST + GET funktionieren, .blend in MinIO gespeichert - **Akzeptanzkriterium**: POST + GET funktionieren, .blend in MinIO gespeichert
### Task K3: Katalog-Refresh Celery Task + Blender Script [ ] ### Task K3: Katalog-Refresh Celery Task + Blender Script [x]
- **Datei**: `backend/app/domains/materials/tasks.py` (neu), `render-worker/scripts/catalog_assets.py` (neu) - **Datei**: `backend/app/domains/materials/tasks.py` (neu), `render-worker/scripts/catalog_assets.py` (neu)
- **Was**: - **Was**:
- Celery Task `refresh_asset_library_catalog(asset_library_id)` auf Queue `thumbnail_rendering` - Celery Task `refresh_asset_library_catalog(asset_library_id)` auf Queue `thumbnail_rendering`
@@ -301,7 +301,7 @@ Frontend:
- Schreibt Katalog in `asset_libraries.catalog JSONB` - Schreibt Katalog in `asset_libraries.catalog JSONB`
- **Akzeptanzkriterium**: Nach .blend-Upload enthaelt `catalog` JSONB die Asset-Namen - **Akzeptanzkriterium**: Nach .blend-Upload enthaelt `catalog` JSONB die Asset-Namen
### Task K4: Blender Asset Library Apply Script [ ] ### Task K4: Blender Asset Library Apply Script [x]
- **Datei**: `render-worker/scripts/asset_library.py` (neu) - **Datei**: `render-worker/scripts/asset_library.py` (neu)
- **Was**: - **Was**:
```python ```python
@@ -329,7 +329,7 @@ Frontend:
``` ```
- **Akzeptanzkriterium**: Render mit Asset-Library zeigt korrekte Produktionsmaterialien - **Akzeptanzkriterium**: Render mit Asset-Library zeigt korrekte Produktionsmaterialien
### Task K5: export_gltf + export_blend Scripts [ ] ### Task K5: export_gltf + export_blend Scripts [x]
- **Dateien**: `render-worker/scripts/export_gltf.py` (neu), `render-worker/scripts/export_blend.py` (neu) - **Dateien**: `render-worker/scripts/export_gltf.py` (neu), `render-worker/scripts/export_blend.py` (neu)
- **Was**: - **Was**:
- `export_gltf.py`: - `export_gltf.py`:
@@ -345,7 +345,7 @@ Frontend:
4. MediaAsset-Record mit `asset_type=blend_production` 4. MediaAsset-Record mit `asset_type=blend_production`
- **Akzeptanzkriterium**: GLB-Download oeffnet sich im Three.js Viewer mit Materialien - **Akzeptanzkriterium**: GLB-Download oeffnet sich im Three.js Viewer mit Materialien
### Task K6: Workflow-Builder -- Asset Library Nodes [ ] ### Task K6: Workflow-Builder -- Asset Library Nodes [x]
- **Datei**: `backend/app/domains/rendering/workflow_builder.py` - **Datei**: `backend/app/domains/rendering/workflow_builder.py`
- **Was**: - **Was**:
- Neue Celery Tasks: `apply_asset_library_materials_task`, `apply_asset_library_modifiers_task`, `export_gltf_task`, `export_blend_task` - Neue Celery Tasks: `apply_asset_library_materials_task`, `apply_asset_library_modifiers_task`, `export_gltf_task`, `export_blend_task`
@@ -364,7 +364,7 @@ Frontend:
``` ```
- **Akzeptanzkriterium**: Dispatch eines `still_production` Workflows -> PNG + GLB + .blend erzeugt - **Akzeptanzkriterium**: Dispatch eines `still_production` Workflows -> PNG + GLB + .blend erzeugt
### Task K7: Asset Library Management UI [ ] ### Task K7: Asset Library Management UI [x]
- **Dateien**: `frontend/src/api/assetLibraries.ts` (neu), `frontend/src/pages/Admin.tsx` erweitern - **Dateien**: `frontend/src/api/assetLibraries.ts` (neu), `frontend/src/pages/Admin.tsx` erweitern
- **Was**: - **Was**:
- API Client: `getAssetLibraries`, `uploadAssetLibrary` (multipart), `deleteAssetLibrary`, `getAssetLibraryCatalog` - API Client: `getAssetLibraries`, `uploadAssetLibrary` (multipart), `deleteAssetLibrary`, `getAssetLibraryCatalog`
@@ -375,7 +375,7 @@ Frontend:
- OutputTypeTable: Asset-Library-Dropdown-Spalte - OutputTypeTable: Asset-Library-Dropdown-Spalte
- **Akzeptanzkriterium**: Admin kann .blend hochladen, Katalog sehen, OutputType zuweisen - **Akzeptanzkriterium**: Admin kann .blend hochladen, Katalog sehen, OutputType zuweisen
### Task K8: PLAN.md + LEARNINGS.md + Commit [ ] ### Task K8: PLAN.md + LEARNINGS.md + Commit [x]
- **Was**: - **Was**:
- PLAN.md: Phase K als ABGESCHLOSSEN markieren - PLAN.md: Phase K als ABGESCHLOSSEN markieren
- LEARNINGS.md: Asset Library link=True Pattern, GLB-Export Blender API - LEARNINGS.md: Asset Library link=True Pattern, GLB-Export Blender API
+77
View File
@@ -0,0 +1,77 @@
"""Asset library helpers for Blender render scripts.
Provides functions to link materials and node groups from a .blend asset library
into the current scene, and apply them to mesh objects.
These functions are intended to be imported by still_render.py / turntable_render.py
when a RenderTemplate has an asset library associated.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def apply_asset_library_materials(blend_path: str, material_map: dict) -> None:
"""Link materials from an asset library .blend and apply them to mesh slots.
Args:
blend_path: Absolute path to the .blend library file.
material_map: Mapping of current slot material name -> library material name.
E.g. {"Steel--Stahl": "SCHAEFFLER_010101_Steel-Bare"}
"""
import bpy # type: ignore[import]
if not material_map:
return
target_names = set(material_map.values())
# Link materials from the library
with bpy.data.libraries.load(blend_path, link=True, assets_only=True) as (src, dst):
dst.materials = [name for name in src.materials if name in target_names]
linked = {m.name for m in dst.materials if m is not None}
logger.info("Linked %d materials from %s", len(linked), blend_path)
# Apply to all mesh object material slots
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
for slot in obj.material_slots:
if slot.material is None:
continue
resolved = material_map.get(slot.material.name)
if resolved and resolved in bpy.data.materials:
slot.material = bpy.data.materials[resolved]
def apply_asset_library_node_groups(blend_path: str, modifier_map: dict) -> None:
"""Link geometry node groups from an asset library and apply as modifiers.
Args:
blend_path: Absolute path to the .blend library file.
modifier_map: Mapping of object name substring -> node group name.
E.g. {"ring": "WearPattern_GeoNodes"}
"""
import bpy # type: ignore[import]
if not modifier_map:
return
target_names = set(modifier_map.values())
with bpy.data.libraries.load(blend_path, link=True, assets_only=True) as (src, dst):
dst.node_groups = [name for name in src.node_groups if name in target_names]
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
for part_substr, ng_name in modifier_map.items():
if part_substr.lower() in obj.name.lower():
ng = bpy.data.node_groups.get(ng_name)
if ng:
mod = obj.modifiers.new(name=ng_name, type="NODES")
mod.node_group = ng
logger.info("Applied node group %s to %s", ng_name, obj.name)
+43
View File
@@ -0,0 +1,43 @@
"""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": ["Mat1", "Mat2", ...], "node_groups": ["NG1", ...]}
Only assets marked via Blender's asset system (asset_data is not None) are included.
"""
from __future__ import annotations
import json
import sys
import traceback
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 = [m.name 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)
+78
View File
@@ -0,0 +1,78 @@
"""Blender headless script: export a STEP-derived scene as a production .blend.
Usage:
blender --background --python export_blend.py -- \\
--stl_path /path/to/file.stl \\
--output_path /path/to/output.blend \\
[--asset_library_blend /path/to/library.blend] \\
[--material_map '{"SrcMat": "LibMat"}']
The script:
1. Imports the STL file (with mm→m scale).
2. Optionally applies asset library materials from a .blend.
3. Packs all external data.
4. Saves a copy as the output .blend.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
def parse_args() -> argparse.Namespace:
argv = sys.argv
if "--" not in argv:
print("No arguments after --", file=sys.stderr)
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
return parser.parse_args(rest)
def main() -> None:
args = parse_args()
material_map: dict = json.loads(args.material_map)
import bpy # type: ignore[import]
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL
bpy.ops.import_mesh.stl(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Apply asset library materials if provided
if args.asset_library_blend and material_map:
import os
sys.path.insert(0, os.path.dirname(__file__))
from asset_library import apply_asset_library_materials
apply_asset_library_materials(args.asset_library_blend, material_map)
# Pack all external data into the .blend
bpy.ops.file.pack_all()
# Save a copy to output_path
bpy.ops.wm.save_as_mainfile(filepath=args.output_path, compress=True, copy=True)
print(f".blend exported to {args.output_path}")
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)
+83
View File
@@ -0,0 +1,83 @@
"""Blender headless script: export a STEP-derived scene as a production GLB.
Usage:
blender --background --python export_gltf.py -- \\
--stl_path /path/to/file.stl \\
--output_path /path/to/output.glb \\
[--asset_library_blend /path/to/library.blend] \\
[--material_map '{"SrcMat": "LibMat"}']
The script:
1. Imports the STL file (with mm→m scale).
2. Optionally applies asset library materials from a .blend.
3. Exports as GLB (Draco-compressed if available, otherwise standard).
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
def parse_args() -> argparse.Namespace:
argv = sys.argv
if "--" not in argv:
print("No arguments after --", file=sys.stderr)
sys.exit(1)
rest = argv[argv.index("--") + 1:]
parser = argparse.ArgumentParser()
parser.add_argument("--stl_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument("--asset_library_blend", default=None)
parser.add_argument("--material_map", default="{}")
return parser.parse_args(rest)
def main() -> None:
args = parse_args()
material_map: dict = json.loads(args.material_map)
import bpy # type: ignore[import]
# Clean scene
bpy.ops.wm.read_factory_settings(use_empty=True)
# Import STL
bpy.ops.import_mesh.stl(filepath=args.stl_path)
# Scale mm → m
for obj in bpy.context.selected_objects:
obj.scale = (0.001, 0.001, 0.001)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(scale=True)
# Apply asset library materials if provided
if args.asset_library_blend and material_map:
import os
sys.path.insert(0, os.path.dirname(__file__))
from asset_library import apply_asset_library_materials
apply_asset_library_materials(args.asset_library_blend, material_map)
# Export GLB
try:
bpy.ops.export_scene.gltf(
filepath=args.output_path,
export_format="GLB",
export_apply=True,
use_selection=False,
)
except Exception as exc:
print(f"GLB export failed: {exc}", file=sys.stderr)
sys.exit(1)
print(f"GLB exported to {args.output_path}")
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)