diff --git a/LEARNINGS.md b/LEARNINGS.md index 39faecc..bc1c8ab 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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 **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. diff --git a/backend/alembic/versions/045_asset_libraries.py b/backend/alembic/versions/045_asset_libraries.py new file mode 100644 index 0000000..da9f68a --- /dev/null +++ b/backend/alembic/versions/045_asset_libraries.py @@ -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") diff --git a/backend/app/api/routers/asset_libraries.py b/backend/app/api/routers/asset_libraries.py new file mode 100644 index 0000000..09f1db9 --- /dev/null +++ b/backend/app/api/routers/asset_libraries.py @@ -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() diff --git a/backend/app/domains/materials/models.py b/backend/app/domains/materials/models.py index 7ac6db7..6310fb3 100644 --- a/backend/app/domains/materials/models.py +++ b/backend/app/domains/materials/models.py @@ -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) diff --git a/backend/app/domains/materials/tasks.py b/backend/app/domains/materials/tasks.py new file mode 100644 index 0000000..d2865dc --- /dev/null +++ b/backend/app/domains/materials/tasks.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 8b5a471..3d31542 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py index f372898..0f1b35f 100644 --- a/backend/app/tasks/celery_app.py +++ b/backend/app/tasks/celery_app.py @@ -13,6 +13,7 @@ celery_app = Celery( "app.domains.rendering.tasks", "app.domains.products.tasks", "app.domains.imports.tasks", + "app.domains.materials.tasks", ], ) diff --git a/frontend/src/api/assetLibraries.ts b/frontend/src/api/assetLibraries.ts new file mode 100644 index 0000000..c8eed73 --- /dev/null +++ b/frontend/src/api/assetLibraries.ts @@ -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 { + const { data } = await api.get('/asset-libraries') + return data +} + +export async function getAssetLibrary(id: string): Promise { + const { data } = await api.get(`/asset-libraries/${id}`) + return data +} + +export async function createAssetLibrary(params: { + name: string + description?: string + blend_file: File +}): Promise { + 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('/asset-libraries', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function uploadAssetLibraryBlend(id: string, file: File): Promise { + const form = new FormData() + form.append('blend_file', file) + const { data } = await api.post(`/asset-libraries/${id}/upload-blend`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function refreshAssetLibraryCatalog(id: string): Promise { + const { data } = await api.post(`/asset-libraries/${id}/refresh-catalog`) + return data +} + +export async function deleteAssetLibrary(id: string): Promise { + await api.delete(`/asset-libraries/${id}`) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index fc3558a..31aaa5e 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -13,6 +13,10 @@ import { getMaterialLibraryInfo, uploadMaterialLibrary, deleteMaterialLibrary } import type { MaterialLibraryInfo } from '../api/renderTemplates' import { listPricingTiers } from '../api/pricing' import { listOutputTypes } from '../api/outputTypes' +import { + listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog, + type AssetLibrary, +} from '../api/assetLibraries' export default function AdminPage() { const qc = useQueryClient() @@ -218,6 +222,11 @@ export default function AdminPage() { + {/* ------------------------------------------------------------------ */} + {/* Asset Libraries */} + {/* ------------------------------------------------------------------ */} + + {/* ------------------------------------------------------------------ */} {/* Users (admin only) */} {/* ------------------------------------------------------------------ */} @@ -1041,3 +1050,199 @@ function PricingSummaryCard() { ) } + + +// ── 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(null) + const [expanded, setExpanded] = useState>(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 ( +
+
+
+ +
+

Asset Libraries

+

+ Upload Blender .blend files containing production materials and node groups. +

+
+
+ +
+ + {showCreate && ( +
+
+ setNewName(e.target.value)} + /> + setNewDesc(e.target.value)} + /> +
+
+ + + +
+
+ )} + + {libraries.length === 0 ? ( +
+ No asset libraries yet. Upload a .blend file to get started. +
+ ) : ( +
+ {(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 ( +
+
+ +
+

{lib.name}

+ {lib.description && ( +

{lib.description}

+ )} +
+ + {lib.original_filename ?? '—'} + + {matCount} materials + {ngCount} node groups + + +
+ + {isExpanded && ( +
+ {matCount > 0 && ( +
+

Materials

+
+ {lib.catalog.materials.map((m) => ( + + {m} + + ))} +
+
+ )} + {ngCount > 0 && ( +
+

Node Groups

+
+ {lib.catalog.node_groups.map((ng) => ( + + {ng} + + ))} +
+
+ )} + {matCount === 0 && ngCount === 0 && ( +

+ No assets found. Click "Refresh" to scan the .blend for marked assets. +

+ )} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} diff --git a/plan.md b/plan.md index ffa455e..413dfbd 100644 --- a/plan.md +++ b/plan.md @@ -257,7 +257,7 @@ Frontend: ## 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` - **Was**: ```python @@ -271,7 +271,7 @@ Frontend: - `output_types.asset_library_id` FK optional (nullable) - **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` - **Was**: - `POST /api/asset-libraries` -- .blend Upload -> MinIO `asset-libraries/{id}.blend` -> queut Katalog-Refresh @@ -281,7 +281,7 @@ Frontend: - `AssetLibraryOut` Schema mit `catalog` field - **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) - **Was**: - 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` - **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) - **Was**: ```python @@ -329,7 +329,7 @@ Frontend: ``` - **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) - **Was**: - `export_gltf.py`: @@ -345,7 +345,7 @@ Frontend: 4. MediaAsset-Record mit `asset_type=blend_production` - **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` - **Was**: - 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 -### 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 - **Was**: - API Client: `getAssetLibraries`, `uploadAssetLibrary` (multipart), `deleteAssetLibrary`, `getAssetLibraryCatalog` @@ -375,7 +375,7 @@ Frontend: - OutputTypeTable: Asset-Library-Dropdown-Spalte - **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**: - PLAN.md: Phase K als ABGESCHLOSSEN markieren - LEARNINGS.md: Asset Library link=True Pattern, GLB-Export Blender API diff --git a/render-worker/scripts/asset_library.py b/render-worker/scripts/asset_library.py new file mode 100644 index 0000000..895c8a9 --- /dev/null +++ b/render-worker/scripts/asset_library.py @@ -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) diff --git a/render-worker/scripts/catalog_assets.py b/render-worker/scripts/catalog_assets.py new file mode 100644 index 0000000..867c880 --- /dev/null +++ b/render-worker/scripts/catalog_assets.py @@ -0,0 +1,43 @@ +"""Blender headless script: extract asset catalog from a .blend library file. + +Usage: + blender --background --python catalog_assets.py -- + +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) diff --git a/render-worker/scripts/export_blend.py b/render-worker/scripts/export_blend.py new file mode 100644 index 0000000..2257686 --- /dev/null +++ b/render-worker/scripts/export_blend.py @@ -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) diff --git a/render-worker/scripts/export_gltf.py b/render-worker/scripts/export_gltf.py new file mode 100644 index 0000000..6717123 --- /dev/null +++ b/render-worker/scripts/export_gltf.py @@ -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)