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
+63 -108
View File
@@ -193,9 +193,8 @@ def render_turntable_task(
import os
import shutil
import subprocess
from app.services.render_blender import (
find_blender, convert_step_to_stl, export_per_part_stls
)
import sys
from app.services.render_blender import find_blender
blender_bin = find_blender()
if not blender_bin:
@@ -208,27 +207,25 @@ def render_turntable_task(
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
turntable_script = scripts_dir / "turntable_render.py"
# STL conversion — try MinIO cache first, then convert locally
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
if not stl_path.exists() or stl_path.stat().st_size == 0:
try:
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
step_hash = compute_step_hash(str(step))
cached = check_stl_cache(step_hash, stl_quality)
if cached:
stl_path.write_bytes(cached)
logger.info("STL restored from MinIO cache: %s", stl_path.name)
else:
convert_step_to_stl(step, stl_path, stl_quality)
except Exception as exc:
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
convert_step_to_stl(step, stl_path, stl_quality)
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
if not (parts_dir / "manifest.json").exists():
try:
export_per_part_stls(step, parts_dir, stl_quality)
except Exception as exc:
logger.warning("per-part export non-fatal: %s", exc)
# GLB generation via OCC (replaces STL intermediary)
linear_deflection = 0.3 if stl_quality == "low" else 0.05
angular_deflection = 0.3 if stl_quality == "low" else 0.1
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
if not glb_path.exists() or glb_path.stat().st_size == 0:
occ_script = scripts_dir / "export_step_to_gltf.py"
occ_cmd = [
sys.executable, str(occ_script),
"--step_path", str(step),
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
]
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
if occ_result.returncode != 0:
raise RuntimeError(
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
)
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
# Build turntable render arguments
frames_dir = out_dir / "frames"
@@ -238,7 +235,7 @@ def render_turntable_task(
blender_bin, "--background",
"--python", str(turntable_script),
"--",
str(stl_path),
str(glb_path),
str(frames_dir),
output_name,
str(width), str(height),
@@ -463,93 +460,40 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
max_retries=1,
)
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
"""Export a GLB from the STL cache via Blender subprocess (with trimesh fallback).
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or
'gltf_production' (when an asset library is applied).
Requires the STL low-quality cache to exist.
Publishes a MediaAsset with asset_type='gltf_geometry'.
"""
import json
import os
import subprocess
import sys
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(
f"STL cache not found: {stl_path}. Run thumbnail generation first."
)
output_path = step.parent / f"{step.stem}_geometry.glb"
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
occ_script = scripts_dir / "export_step_to_gltf.py"
from app.services.render_blender import find_blender, is_blender_available
if not occ_script.exists():
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
asset_type = "gltf_geometry"
# Load sharp edge hints from mesh_attributes for UV seam marking
sharp_edges_json = "[]"
if cad_file_id:
try:
import asyncio as _asyncio
async def _load_mesh_attrs() -> list:
from app.database import AsyncSessionLocal
from app.models.cad_file import CadFile as _CF
from sqlalchemy import select as _sel
async with AsyncSessionLocal() as _db:
_res = await _db.execute(_sel(_CF).where(_CF.id == cad_file_id))
_cad = _res.scalar_one_or_none()
if _cad and _cad.mesh_attributes:
return _cad.mesh_attributes.get("sharp_edge_midpoints") or []
return []
_midpoints = _asyncio.get_event_loop().run_until_complete(_load_mesh_attrs())
if _midpoints:
sharp_edges_json = json.dumps(_midpoints)
except Exception as _exc:
logger.warning("Could not load sharp_edge_midpoints for %s: %s", cad_file_id, _exc)
if is_blender_available() and export_script.exists():
blender_bin = find_blender()
cmd = [
blender_bin, "--background",
"--python", str(export_script),
"--",
"--stl_path", str(stl_path),
"--output_path", str(output_path),
"--asset_library_blend", "",
"--material_map", json.dumps({}),
"--sharp_edges_json", sharp_edges_json,
]
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:]}"
)
publish_asset.delay(order_line_id, asset_type, str(output_path))
logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name)
return {"glb_path": str(output_path), "method": "blender"}
except Exception as exc:
logger.warning(
"Blender GLB export failed for %s, falling back to trimesh: %s",
order_line_id, exc,
)
# Trimesh fallback
try:
import trimesh
mesh = trimesh.load(str(stl_path))
mesh.export(str(output_path))
publish_asset.delay(order_line_id, asset_type, str(output_path))
logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name)
return {"glb_path": str(output_path), "method": "trimesh"}
cmd = [
sys.executable, str(occ_script),
"--step_path", str(step),
"--output_path", str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(
f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name)
return {"glb_path": str(output_path), "method": "occ"}
except Exception as exc:
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
raise self.retry(exc=exc, countdown=15)
@@ -576,9 +520,20 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(f"STL cache not found: {stl_path}")
# Use geometry GLB as input (generate if missing)
glb_path = step.parent / f"{step.stem}_geometry.glb"
if not glb_path.exists():
import subprocess as _sp
import sys as _sys
scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
occ_cmd = [
_sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"),
"--step_path", str(step),
"--output_path", str(glb_path),
]
occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120)
if occ_res.returncode != 0:
raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}")
output_path = step.parent / f"{step.stem}_production.blend"
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
@@ -617,7 +572,7 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
blender_bin, "--background",
"--python", str(export_script),
"--",
"--stl_path", str(stl_path),
"--glb_path", str(glb_path),
"--output_path", str(output_path),
"--asset_library_blend", asset_lib_path,
"--material_map", json.dumps(mat_map),
@@ -670,7 +625,7 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
if not product or not product.cad_file_id:
return None, None, None
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none()
stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None
glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None
# Resolve asset library blend path
try:
@@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
blend_path = None
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
return stl_path, blend_path, mat_map
return glb_path, blend_path, mat_map
result = _inner()
if result is None or result[0] is None:
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
return {"status": "skipped"}
stl_path, blend_path, mat_map = result
if not stl_path or not Path(stl_path).exists():
logger.warning("STL not found for %s", order_line_id)
return {"status": "skipped", "reason": "stl_not_found"}
glb_path, blend_path, mat_map = result
if not glb_path or not Path(glb_path).exists():
logger.warning("Geometry GLB not found for %s", order_line_id)
return {"status": "skipped", "reason": "glb_not_found"}
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script = scripts_dir / "asset_library.py"
cmd = [
blender_bin, "--background", "--python", str(script), "--",
"--stl_path", stl_path,
"--glb_path", glb_path,
"--asset_library_blend", blend_path or "",
"--material_map", json.dumps(mat_map),
]