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:
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user