diff --git a/LEARNINGS.md b/LEARNINGS.md
index d8bc64e..b4e1994 100644
--- a/LEARNINGS.md
+++ b/LEARNINGS.md
@@ -436,6 +436,11 @@ SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg
---
+### 2026-03-07 | GLB Export | Trimesh kennt keine Materialien — Blender-Pipeline ist Pflicht
+**Problem:** `generate_gltf_geometry_task` nutzte trimesh für STL→GLB. Trimesh ist eine reine Geometrie-Bibliothek: keine Material-Bibliotheken, kein OCC-Kantenmarking, kein Asset-Library-Support. Das erzeugte graue, facettierte GLB-Dateien ohne Materialien.
+**Lösung:** Task auf Blender headless (`export_gltf.py`) umgestellt. Übergibt: `material_map` (via `resolve_material_map()` aus `cad_part_materials`), `sharp_edges_json` (aus `mesh_attributes.sharp_edge_midpoints`), `asset_library_blend` (via `get_material_library_path()`). Trimesh nur noch als Fallback wenn Blender nicht verfügbar.
+**Fehler:** Der Blender-Script (`export_gltf.py`) war schon fertig implementiert — aber `generate_gltf_geometry_task` hat ihn nie aufgerufen. Skript vorhanden ≠ Skript verdrahtet. Immer prüfen ob ein Script auch von der richtigen Stelle aufgerufen wird.
+
### 2026-03-07 | Frontend | ` ` kann keine Auth-Header senden — useAuthBlob Hook nötig
**Problem:** ` ` schickt keine `Authorization`-Header → 401 → `imgError=true` → graues Icon in der Media Library. Betrifft alle Browser-nativen Elemente (` `, ``, ``).
**Lösung:** `useAuthBlob(url, enabled)` Hook: `fetch(url, { headers: { Authorization: \`Bearer ${token}\` } })` → `URL.createObjectURL(blob)` → Blob-URL als `src` nutzen. Cleanup via `URL.revokeObjectURL` + `cancelled`-Flag gegen Race Conditions.
diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py
index 3448082..4f08c4c 100644
--- a/backend/app/tasks/step_tasks.py
+++ b/backend/app/tasks/step_tasks.py
@@ -395,13 +395,21 @@ def generate_stl_cache(self, cad_file_id: str, quality: str):
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_gltf_geometry_task", queue="thumbnail_rendering", max_retries=1)
def generate_gltf_geometry_task(self, cad_file_id: str):
- """Export a geometry-only GLB from the STL low-quality cache using trimesh.
+ """Export a GLB via Blender with material substitution and OCC sharp-edge data.
- Creates a MediaAsset with asset_type='gltf_geometry' and cad_file_id set.
- No Blender required — trimesh handles the STL→GLB conversion.
+ Pipeline:
+ 1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction)
+ 2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material)
+ 3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges
+ 4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable
+
+ Replaces the existing gltf_geometry MediaAsset for this CadFile on each run.
"""
+ import json as _json
+ import os as _os
+ import subprocess as _subprocess
from pathlib import Path as _Path
- from sqlalchemy import create_engine
+ from sqlalchemy import create_engine, select as _select
from sqlalchemy.orm import Session
from app.config import settings as app_settings
from app.models.cad_file import CadFile
@@ -414,64 +422,130 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id)
return
step_path_str = cad_file.stored_path
+ mesh_attrs = cad_file.mesh_attributes or {}
+ sharp_edge_midpoints = mesh_attrs.get("sharp_edge_midpoints", [])
- # Read 3D export settings
- from sqlalchemy import text as _text
- _scale = float(session.execute(_text(
- "SELECT value FROM system_settings WHERE key='gltf_scale_factor'"
- )).scalar() or "0.001")
- _smooth = (session.execute(_text(
- "SELECT value FROM system_settings WHERE key='gltf_smooth_normals'"
- )).scalar() or "true") == "true"
+ # Load product materials for this CAD file
+ from app.domains.products.models import Product
+ product = session.execute(
+ _select(Product).where(Product.cad_file_id == cad_file.id)
+ ).scalar_one_or_none()
+
+ raw_material_map: dict[str, str] = {}
+ if product and product.cad_part_materials:
+ for entry in product.cad_part_materials:
+ part_name = entry.get("part_name") or entry.get("name", "")
+ mat_name = entry.get("material_name") or entry.get("material", "")
+ if part_name and mat_name:
+ raw_material_map[part_name] = mat_name
eng.dispose()
- log_task_event(self.request.id, f"Starting generate_gltf_geometry_task: cad_file={cad_file_id}", "info")
+ # Resolve aliases: "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare"
+ from app.services.material_service import resolve_material_map
+ material_map = resolve_material_map(raw_material_map)
+
+ # Get asset library .blend path from system settings
+ from app.services.template_service import get_material_library_path
+ asset_library_blend = get_material_library_path()
+
step = _Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
- logger.error("generate_gltf_geometry_task: STL not found %s", stl_path)
raise RuntimeError(f"STL cache not found: {stl_path}")
output_path = step.parent / f"{step.stem}_geometry.glb"
- try:
- import trimesh
- import trimesh as _trimesh
- def _process_mesh(m):
- m.apply_scale(_scale)
- if _smooth:
+ log_task_event(
+ self.request.id,
+ f"Starting GLB export: {len(material_map)} materials, "
+ f"{len(sharp_edge_midpoints)} sharp-edge hints, "
+ f"library={'yes' if asset_library_blend else 'no'}",
+ "info",
+ )
+
+ # --- Blender path ---
+ blender_bin = _os.environ.get("BLENDER_BIN", "blender")
+ scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
+ script_path = scripts_dir / "export_gltf.py"
+ blender_ok = False
+
+ if _Path(blender_bin).exists() and script_path.exists():
+ cmd = [
+ blender_bin, "--background", "--python", str(script_path), "--",
+ "--stl_path", str(stl_path),
+ "--output_path", str(output_path),
+ "--material_map", _json.dumps(material_map),
+ "--sharp_edges_json", _json.dumps(sharp_edge_midpoints),
+ ]
+ if asset_library_blend and _Path(asset_library_blend).exists():
+ cmd += ["--asset_library_blend", asset_library_blend]
+
+ try:
+ result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180)
+ if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
+ blender_ok = True
+ logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)",
+ output_path.stat().st_size // 1024)
+ else:
+ logger.warning(
+ "Blender GLB export failed (exit %d) — falling back to trimesh.\n"
+ "STDOUT: %s\nSTDERR: %s",
+ result.returncode, result.stdout[-1500:], result.stderr[-500:],
+ )
+ except Exception as exc:
+ logger.warning("Blender GLB export error (%s) — falling back to trimesh", exc)
+ else:
+ logger.warning(
+ "Blender not available at '%s' or script missing at '%s' — using trimesh fallback",
+ blender_bin, script_path,
+ )
+
+ # --- Trimesh fallback (geometry only, no materials) ---
+ if not blender_ok:
+ try:
+ import trimesh
+ import trimesh as _trimesh
+
+ def _process_mesh(m):
+ m.apply_scale(0.001)
try:
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
except Exception:
- pass # non-critical
+ pass
- mesh = trimesh.load(str(stl_path))
+ mesh = trimesh.load(str(stl_path))
+ if hasattr(mesh, 'geometry'):
+ for sub in mesh.geometry.values():
+ _process_mesh(sub)
+ else:
+ _process_mesh(mesh)
+ mesh.export(str(output_path))
+ log_task_event(self.request.id, "Trimesh fallback export completed (no materials)", "done")
+ except Exception as exc:
+ log_task_event(self.request.id, f"Failed: {exc}", "error")
+ logger.error("generate_gltf_geometry_task trimesh fallback failed: %s", exc)
+ raise self.retry(exc=exc, countdown=15)
+ else:
+ log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done")
- if hasattr(mesh, 'geometry'):
- # trimesh.Scene with multiple sub-meshes
- for sub in mesh.geometry.values():
- _process_mesh(sub)
- else:
- _process_mesh(mesh)
-
- mesh.export(str(output_path))
- log_task_event(self.request.id, f"Completed successfully: {output_path.name}", "done")
- logger.info("generate_gltf_geometry_task: exported %s", output_path.name)
- except Exception as exc:
- log_task_event(self.request.id, f"Failed: {exc}", "error")
- logger.error("generate_gltf_geometry_task failed for %s: %s", cad_file_id, exc)
- raise self.retry(exc=exc, countdown=15)
-
- # Create MediaAsset record
+ # --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
import asyncio
async def _store():
from app.database import AsyncSessionLocal
from app.domains.media.models import MediaAsset, MediaAssetType
from app.config import settings as _cfg
+ import uuid
async with AsyncSessionLocal() as db:
- import uuid
+ # Delete previous gltf_geometry assets for this cad_file to avoid stale records
+ from sqlalchemy import delete as _delete
+ await db.execute(
+ _delete(MediaAsset).where(
+ MediaAsset.cad_file_id == uuid.UUID(cad_file_id),
+ MediaAsset.asset_type == MediaAssetType.gltf_geometry,
+ )
+ )
_key = str(output_path)
_prefix = str(_cfg.upload_dir).rstrip("/") + "/"
if _key.startswith(_prefix):
@@ -487,7 +561,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
await db.commit()
return str(asset.id)
- asset_id = asyncio.get_event_loop().run_until_complete(_store())
+ asset_id = asyncio.run(_store())
logger.info("generate_gltf_geometry_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
return {"glb_path": str(output_path), "asset_id": asset_id}