fix(gltf): route generate_gltf_geometry_task through Blender for materials + sharp edges
Replace trimesh-only GLB export with Blender headless pipeline using export_gltf.py. The viewer GLB and downloadable GLB now receive: - Material substitution via the Schaeffler asset library (.blend) - OCC-derived sharp edge data (sharp_edge_midpoints from mesh_attributes) marked as Blender sharp edges before export Material map is resolved via resolve_material_map() to convert aliases (e.g. "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare") before passing to Blender. Old gltf_geometry MediaAsset is replaced on each run to avoid stale records accumulating. Trimesh kept as fallback if Blender binary unavailable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 | `<img src>` kann keine Auth-Header senden — useAuthBlob Hook nötig
|
||||
**Problem:** `<img src="/api/media/{id}/download">` schickt keine `Authorization`-Header → 401 → `imgError=true` → graues Icon in der Media Library. Betrifft alle Browser-nativen Elemente (`<img>`, `<video>`, `<audio>`).
|
||||
**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.
|
||||
|
||||
+114
-40
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user