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:
@@ -17,24 +17,39 @@ logger = logging.getLogger(__name__)
|
||||
MIN_BLENDER_VERSION = (5, 0, 1)
|
||||
|
||||
|
||||
def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None:
|
||||
"""Try MinIO cache first, then fall back to local STEP→STL conversion."""
|
||||
# MinIO cache check (non-fatal — cache miss just means we convert normally)
|
||||
try:
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
||||
step_hash = compute_step_hash(str(step_path))
|
||||
cached_bytes = check_stl_cache(step_hash, quality)
|
||||
if cached_bytes:
|
||||
stl_path.write_bytes(cached_bytes)
|
||||
logger.info("STL restored from MinIO cache: %s (%d KB)", stl_path.name, len(cached_bytes) // 1024)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("MinIO cache check failed (non-fatal): %s", exc)
|
||||
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
|
||||
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
|
||||
|
||||
# Local conversion
|
||||
from app.services.step_processor import convert_step_to_stl
|
||||
logger.info("STL cache miss — converting: %s", step_path.name)
|
||||
convert_step_to_stl(step_path, stl_path, quality)
|
||||
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
|
||||
"high" → finer mesh (~0.05 mm deflection, slower)
|
||||
"""
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
|
||||
linear_deflection = 0.3 if quality == "low" else 0.05
|
||||
angular_deflection = 0.3 if quality == "low" else 0.1
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
cmd = [
|
||||
_sys.executable, str(script_path),
|
||||
"--step_path", str(step_path),
|
||||
"--output_path", str(glb_path),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
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)
|
||||
if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||
f"STDERR: {result.stderr[-1000:]}"
|
||||
)
|
||||
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
@@ -51,127 +66,6 @@ def is_blender_available() -> bool:
|
||||
return bool(find_blender())
|
||||
|
||||
|
||||
def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
|
||||
"""Convert a STEP file to STL using cadquery.
|
||||
|
||||
Raises ImportError if cadquery is not installed (not available in backend
|
||||
container — only in render-worker container).
|
||||
"""
|
||||
import cadquery as cq # only available in render-worker
|
||||
|
||||
if quality == "high":
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
|
||||
else:
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3)
|
||||
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
raise RuntimeError("cadquery produced empty STL")
|
||||
|
||||
|
||||
def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list:
|
||||
"""Export one STL per named STEP leaf shape using OCP XCAF.
|
||||
|
||||
Returns the manifest list (may be empty on failure — non-fatal).
|
||||
"""
|
||||
tol = 0.01 if quality == "high" else 0.3
|
||||
angular_tol = 0.05 if quality == "high" else 0.3
|
||||
|
||||
try:
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
import cadquery as cq
|
||||
except ImportError as e:
|
||||
logger.warning("per-part export skipped (import error): %s", e)
|
||||
return []
|
||||
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
|
||||
app.InitDocument(doc)
|
||||
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(step_path))
|
||||
if status != IFSelect_RetDone:
|
||||
logger.warning("XCAF reader failed with status %s", status)
|
||||
return []
|
||||
|
||||
if not reader.Transfer(doc):
|
||||
logger.warning("XCAF transfer failed")
|
||||
return []
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
name_id = TDataStd_Name.GetID_s()
|
||||
|
||||
leaves = []
|
||||
|
||||
def _get_label_name(label):
|
||||
name_attr = TDataStd_Name()
|
||||
if label.FindAttribute(name_id, name_attr):
|
||||
return name_attr.Get().ToExtString()
|
||||
return ""
|
||||
|
||||
def _collect_leaves(label):
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(label):
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
comp_label = components.Value(i)
|
||||
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
|
||||
ref_label = TDF_Label_cls()
|
||||
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
|
||||
comp_name = _get_label_name(comp_label)
|
||||
ref_name = _get_label_name(ref_label)
|
||||
name = ref_name or comp_name
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
|
||||
_collect_leaves(ref_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
else:
|
||||
_collect_leaves(comp_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
|
||||
name = _get_label_name(label)
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
|
||||
top_labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(top_labels)
|
||||
for i in range(1, top_labels.Length() + 1):
|
||||
_collect_leaves(top_labels.Value(i))
|
||||
|
||||
if not leaves:
|
||||
logger.warning("no leaf shapes found via XCAF")
|
||||
return []
|
||||
|
||||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = []
|
||||
|
||||
for idx, (name, shape) in enumerate(leaves):
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
filename = f"{idx:02d}_{safe_name}.stl"
|
||||
filepath = str(parts_dir / filename)
|
||||
try:
|
||||
cq_shape = cq.Shape(shape)
|
||||
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
|
||||
manifest.append({"index": idx, "name": name, "file": filename})
|
||||
except Exception as e:
|
||||
logger.warning("failed to export part '%s': %s", name, e)
|
||||
|
||||
manifest_path = parts_dir / "manifest.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump({"parts": manifest}, f, indent=2)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def render_still(
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
@@ -202,7 +96,7 @@ def render_still(
|
||||
denoising_use_gpu: str = "",
|
||||
mesh_attributes: dict | None = None,
|
||||
) -> dict:
|
||||
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
||||
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
|
||||
|
||||
Returns a dict with timing, sizes, engine_used, and log_lines.
|
||||
Raises RuntimeError on failure.
|
||||
@@ -215,7 +109,6 @@ def render_still(
|
||||
|
||||
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
|
||||
if not script_path.exists():
|
||||
# Fallback: look next to this file (development mode)
|
||||
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
|
||||
if alt.exists():
|
||||
script_path = alt
|
||||
@@ -224,24 +117,16 @@ def render_still(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion (cadquery)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Blender render
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -263,7 +148,7 @@ def render_still(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(output_path),
|
||||
str(width), str(height),
|
||||
eng, str(samples), str(smooth_angle),
|
||||
@@ -332,22 +217,13 @@ def render_still(
|
||||
|
||||
render_duration_s = round(time.monotonic() - t_render, 2)
|
||||
|
||||
parts_count = 0
|
||||
manifest_file = parts_dir / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text())
|
||||
parts_count = len(data.get("parts", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": glb_size_bytes,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"parts_count": parts_count,
|
||||
"parts_count": 0,
|
||||
"engine_used": engine_used,
|
||||
"log_lines": log_lines,
|
||||
}
|
||||
@@ -407,24 +283,15 @@ def render_turntable_to_file(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Render frames with Blender
|
||||
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
|
||||
@@ -439,7 +306,7 @@ def render_turntable_to_file(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(frames_dir),
|
||||
str(frame_count),
|
||||
"360", # degrees
|
||||
@@ -554,10 +421,10 @@ def render_turntable_to_file(
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"ffmpeg_duration_s": ffmpeg_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": 0,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"frame_count": len(frame_files),
|
||||
"engine_used": engine,
|
||||
|
||||
Reference in New Issue
Block a user