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:
2026-03-07 14:10:45 +01:00
parent bfd58e3419
commit c1e9a86996
2 changed files with 119 additions and 40 deletions
+114 -40
View File
@@ -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}