refactor: replace STL intermediary with OCC-native STEP→GLB pipeline

- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform
  (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method)
- Blender scripts (blender_render.py, still_render.py, turntable_render.py,
  export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m
- step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache,
  replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB
  after thumbnail render
- render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step,
  remove convert_step_to_stl and export_per_part_stls
- domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks
  to use GLB instead of STL
- cad.py: remove STL download/generate endpoints, add generate-gltf-production
- admin.py: generate-missing-stls → generate-missing-geometry-glbs
- Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:49:18 +01:00
parent 3eba7b2d37
commit 95cfe0aa93
20 changed files with 809 additions and 1301 deletions
+215 -172
View File
@@ -1,6 +1,5 @@
"""Celery tasks for STEP file processing and thumbnail generation."""
import logging
import struct
from pathlib import Path
from app.tasks.celery_app import celery_app
from app.core.task_logs import log_task_event
@@ -8,44 +7,37 @@ from app.core.task_logs import log_task_event
logger = logging.getLogger(__name__)
def _bbox_from_stl(stl_path: str) -> dict | None:
"""Extract bounding box from a cached binary STL file.
def _bbox_from_glb(glb_path: str) -> dict | None:
"""Extract bounding box from a GLB file (meters → converted to mm).
Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure.
Reading vertex extremes from an existing STL is ~10-100× faster than re-parsing STEP.
OCC GLB output is in meters; multiply by 1000 to get mm.
"""
try:
import numpy as np
p = Path(stl_path)
if not p.exists() or p.stat().st_size < 84:
import trimesh
p = Path(glb_path)
if not p.exists():
return None
with p.open("rb") as f:
f.seek(80) # skip 80-byte header
n = struct.unpack("<I", f.read(4))[0]
if n == 0:
return None
raw = f.read(n * 50) # 50 bytes per triangle
# Binary STL per-triangle layout: normal(12B) + v1(12B) + v2(12B) + v3(12B) + attr(2B) = 50B
# Extract vertex bytes (columns 12..48 of each 50-byte row)
arr = np.frombuffer(raw, dtype=np.uint8).reshape(n, 50)
verts = np.frombuffer(arr[:, 12:48].tobytes(), dtype=np.float32).reshape(-1, 3)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
scene = trimesh.load(str(p), force="scene")
bounds = getattr(scene, "bounds", None)
if bounds is None:
return None
mins, maxs = bounds
dims = maxs - mins
return {
"dimensions_mm": {
"x": round(float(dims[0]), 2),
"y": round(float(dims[1]), 2),
"z": round(float(dims[2]), 2),
"x": round(float(dims[0]) * 1000, 2),
"y": round(float(dims[1]) * 1000, 2),
"z": round(float(dims[2]) * 1000, 2),
},
"bbox_center_mm": {
"x": round(float((mins[0] + maxs[0]) / 2), 2),
"y": round(float((mins[1] + maxs[1]) / 2), 2),
"z": round(float((mins[2] + maxs[2]) / 2), 2),
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
},
}
except Exception as exc:
logger.debug(f"_bbox_from_stl failed for {stl_path}: {exc}")
logger.debug(f"_bbox_from_glb failed for {glb_path}: {exc}")
return None
@@ -229,9 +221,9 @@ def render_step_thumbnail(self, cad_file_id: str):
logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}")
raise self.retry(exc=exc, countdown=30, max_retries=2)
# Extract bounding box from the STL that was just cached by the renderer.
# STL binary parsing is near-instant (numpy min/max) vs re-parsing the STEP file.
# Falls back to cadquery STEP re-parse if STL is not found.
# Extract bounding box from the thumbnail GLB generated by the renderer.
# GLB bbox via trimesh is fast and avoids re-parsing the STEP file.
# Falls back to cadquery STEP re-parse if GLB is not found.
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@@ -247,8 +239,8 @@ def render_step_thumbnail(self, cad_file_id: str):
if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"):
_step = Path(_step_path)
_stl = _step.parent / f"{_step.stem}_low.stl"
bbox_data = _bbox_from_stl(str(_stl)) or _bbox_from_step_cadquery(_step_path)
_glb = _step.parent / f"{_step.stem}_thumbnail.glb"
bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path)
if bbox_data:
_eng2 = create_engine(_sync_url2)
with Session(_eng2) as _sess2:
@@ -295,6 +287,13 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
# Auto-generate geometry GLB so the 3D viewer is ready without manual trigger
try:
generate_gltf_geometry_task.delay(cad_file_id)
logger.info("render_step_thumbnail: queued generate_gltf_geometry_task for %s", cad_file_id)
except Exception:
logger.debug("Could not queue generate_gltf_geometry_task (non-fatal)")
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
def reextract_cad_metadata(cad_file_id: str):
@@ -321,8 +320,8 @@ def reextract_cad_metadata(cad_file_id: str):
try:
p = Path(step_path)
stl_path = p.parent / f"{p.stem}_low.stl"
patch = _bbox_from_stl(str(stl_path)) or _bbox_from_step_cadquery(step_path)
glb_path = p.parent / f"{p.stem}_thumbnail.glb"
patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path)
if patch:
with Session(eng) as session:
cad_file = session.get(CadFile, cad_file_id)
@@ -342,72 +341,22 @@ def reextract_cad_metadata(cad_file_id: str):
eng.dispose()
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_stl_cache", queue="thumbnail_rendering")
def generate_stl_cache(self, cad_file_id: str, quality: str):
"""Generate and cache STL for a CAD file without triggering a full render."""
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.config import settings as app_settings
from app.models.cad_file import CadFile
logger.info(f"Generating {quality}-quality STL for CAD file: {cad_file_id}")
sync_url = app_settings.database_url.replace("+asyncpg", "")
eng = create_engine(sync_url)
with Session(eng) as session:
cad_file = session.get(CadFile, cad_file_id)
if not cad_file or not cad_file.stored_path:
logger.error(f"CAD file not found or no stored_path: {cad_file_id}")
return
step_path = cad_file.stored_path
eng.dispose()
try:
from app.services.render_blender import convert_step_to_stl, export_per_part_stls
from app.domains.products.cache_service import compute_step_hash, check_stl_cache, store_stl_cache
from pathlib import Path as _Path
step = _Path(step_path)
stl_out = step.parent / f"{step.stem}_{quality}.stl"
parts_dir = step.parent / f"{step.stem}_{quality}_parts"
if not stl_out.exists() or stl_out.stat().st_size == 0:
# Check MinIO cache before running cadquery conversion
step_hash = compute_step_hash(step_path)
cached_bytes = check_stl_cache(step_hash, quality)
if cached_bytes:
stl_out.write_bytes(cached_bytes)
logger.info(f"STL cache hit for {cad_file_id} ({quality}), skipped conversion")
else:
convert_step_to_stl(step, stl_out, quality)
# Store result in MinIO for future workers
if stl_out.exists() and stl_out.stat().st_size > 0:
store_stl_cache(step_hash, quality, str(stl_out))
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step, parts_dir, quality)
except Exception as pe:
logger.warning(f"Per-part STL export non-fatal: {pe}")
logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)")
except Exception as exc:
logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}")
raise self.retry(exc=exc, countdown=30, max_retries=2)
@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 GLB via Blender with material substitution and OCC sharp-edge data.
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
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
1. Reads STEP file directly (no STL needed)
2. Builds color_map from product.cad_part_materials (hex colors)
3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors
4. Stores result as gltf_geometry MediaAsset (replaces any existing one)
Replaces the existing gltf_geometry MediaAsset for this CadFile on each run.
Output is in meters, Y-up (glTF convention).
"""
import json as _json
import os as _os
import subprocess as _subprocess
import sys as _sys
from pathlib import Path as _Path
from sqlalchemy import create_engine, select as _select
from sqlalchemy.orm import Session
@@ -422,115 +371,68 @@ 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", [])
# Load product materials for this CAD file
# Build hex color_map from product.cad_part_materials
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] = {}
color_map: dict[str, str] = {}
product_id = str(product.id) if product else None
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
hex_color = entry.get("hex_color") or entry.get("color", "")
if part_name and hex_color:
color_map[part_name] = hex_color
eng.dispose()
# 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")
raise RuntimeError(f"STL cache not found: {stl_path}")
if not step.exists():
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
raise RuntimeError(f"STEP file not found: {step}")
output_path = step.parent / f"{step.stem}_geometry.glb"
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'}",
f"Starting OCC GLB export: {len(color_map)} part colors",
"info",
)
# --- Blender path ---
blender_bin = _os.environ.get("BLENDER_BIN", "blender")
# Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_gltf.py"
blender_ok = False
script_path = scripts_dir / "export_step_to_gltf.py"
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]
python_bin = _sys.executable
cmd = [
python_bin, str(script_path),
"--step_path", str(step),
"--output_path", str(output_path),
"--color_map", _json.dumps(color_map),
]
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,
)
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
logger.info("[occ-gltf] %s", line)
for line in result.stderr.splitlines():
logger.warning("[occ-gltf stderr] %s", line)
# --- Trimesh fallback (geometry only, no materials) ---
if not blender_ok:
try:
import trimesh
import trimesh as _trimesh
if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError(
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"Failed: {exc}", "error")
logger.error("generate_gltf_geometry_task OCC export failed: %s", exc)
raise self.retry(exc=exc, countdown=15)
def _process_mesh(m):
m.apply_scale(0.001)
try:
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
except Exception:
pass
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")
log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done")
# --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
# Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers.
import uuid as _uuid
from sqlalchemy import create_engine as _ce, delete as _del
from sqlalchemy.orm import Session as _Session
@@ -551,6 +453,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
_key = _key[len(_prefix):]
asset = MediaAsset(
cad_file_id=_uuid.UUID(cad_file_id),
product_id=_uuid.UUID(product_id) if product_id else None,
asset_type=MediaAssetType.gltf_geometry,
storage_key=_key,
mime_type="model/gltf-binary",
@@ -565,6 +468,146 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
return {"glb_path": str(output_path), "asset_id": asset_id}
@celery_app.task(
bind=True,
name="app.tasks.step_tasks.generate_gltf_production_task",
queue="thumbnail_rendering",
max_retries=2,
)
def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict:
"""Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py.
1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not).
2. Resolves SCHAEFFLER material map for the CadFile's product.
3. Runs Blender headless with export_gltf.py → production GLB.
4. Stores result as gltf_production MediaAsset.
"""
import json as _json
import os as _os
import subprocess as _subprocess
import uuid as _uuid
from pathlib import Path as _Path
from sqlalchemy import create_engine as _ce, delete as _del, select as _sel
from sqlalchemy.orm import Session as _Session
from app.config import settings as app_settings
from app.domains.media.models import MediaAsset, MediaAssetType
from app.services.render_blender import find_blender, is_blender_available
log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info")
_sync_url = app_settings.database_url.replace("+asyncpg", "")
_eng = _ce(_sync_url)
# --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset ---
with _Session(_eng) as _sess:
_row = _sess.execute(
_sel(MediaAsset).where(
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
).scalar_one_or_none()
geom_glb_key = _row.storage_key if _row else None
if not geom_glb_key:
# Trigger geometry generation first and retry this task
log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info")
generate_gltf_geometry_task.delay(cad_file_id, product_id)
raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2)
geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key
if not geom_glb_path.exists():
raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}")
# --- 2. Resolve material map (SCHAEFFLER library names) ---
from app.services.material_service import resolve_material_map
with _Session(_eng) as _sess:
from app.models.cad_file import CadFile as _CF
_cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none()
raw_mat_map: dict = {}
if _cad and _cad.cad_part_materials:
raw_mat_map = _cad.cad_part_materials
mat_map = resolve_material_map(raw_mat_map)
# --- 3. Resolve asset library .blend path from system settings ---
from app.models.system_setting import SystemSetting
with _Session(_eng) as _sess:
_setting = _sess.execute(
_sel(SystemSetting).where(SystemSetting.key == "asset_library_blend")
).scalar_one_or_none()
asset_library_blend = _setting.value if _setting and _setting.value else ""
_eng.dispose()
# Output path next to geometry GLB
output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb")
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
if not is_blender_available():
raise RuntimeError("Blender is not available — cannot generate production GLB")
if not export_script.exists():
raise RuntimeError(f"export_gltf.py not found at {export_script}")
blender_bin = find_blender()
cmd = [
blender_bin, "--background",
"--python", str(export_script),
"--",
"--glb_path", str(geom_glb_path),
"--output_path", str(output_path),
"--material_map", _json.dumps(mat_map),
]
if asset_library_blend:
cmd += ["--asset_library_blend", asset_library_blend]
log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info")
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
raise RuntimeError(
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
raise self.retry(exc=exc, countdown=30)
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
# --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) ---
_eng2 = _ce(_sync_url)
with _Session(_eng2) as _sess:
_sess.execute(
_del(MediaAsset).where(
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_production,
)
)
_key = str(output_path)
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
if _key.startswith(_prefix):
_key = _key[len(_prefix):]
asset = MediaAsset(
cad_file_id=_uuid.UUID(cad_file_id),
product_id=_uuid.UUID(product_id) if product_id else None,
asset_type=MediaAssetType.gltf_production,
storage_key=_key,
mime_type="model/gltf-binary",
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
)
_sess.add(asset)
_sess.commit()
asset_id = str(asset.id)
_eng2.dispose()
logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
return {"glb_path": str(output_path), "asset_id": asset_id}
@celery_app.task(bind=True, name="app.tasks.step_tasks.regenerate_thumbnail", queue="thumbnail_rendering")
def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
"""Regenerate thumbnail with per-part colours."""