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
@@ -0,0 +1,232 @@
"""OCC-native STEP → GLB export script.
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
tessellates with BRepMesh, optionally applies per-part hex colors, and writes
a binary GLB in meters (Y-up, glTF convention).
No Blender required. Uses the same OCP bindings that cadquery ships with.
Usage:
python3 export_step_to_gltf.py \
--step_path /path/to/file.stp \
--output_path /path/to/output.glb \
[--linear_deflection 0.1] \
[--angular_deflection 0.5] \
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
Exit 0 on success, exit 1 on failure.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--step_path", required=True)
parser.add_argument("--output_path", required=True)
parser.add_argument(
"--linear_deflection", type=float, default=0.1,
help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1",
)
parser.add_argument(
"--angular_deflection", type=float, default=0.5,
help="OCC angular deflection (radians). Default 0.5",
)
parser.add_argument(
"--color_map", default="{}",
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
)
return parser.parse_args()
def _hex_to_occ_color(hex_color: str):
"""Convert '#RRGGBB' → Quantity_Color (linear float)."""
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
h = hex_color.lstrip("#")
if len(h) < 6:
return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB)
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return Quantity_Color(r, g, b, Quantity_TOC_RGB)
def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None:
"""Apply hex colors from color_map to matching shapes by name (case-insensitive substring)."""
from OCP.TDF import TDF_LabelSequence
from OCP.TDataStd import TDataStd_Name
from OCP.XCAFDoc import XCAFDoc_ShapeTool
# XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1 # integer fallback
def _visit(label) -> None:
name_attr = TDataStd_Name()
name = ""
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
name = name_attr.Get().ToExtString()
if name:
for part_name, hex_color in color_map.items():
if part_name.lower() in name.lower() or name.lower() in part_name.lower():
color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF)
break
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
for i in range(1, components.Length() + 1):
_visit(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_visit(free_labels.Value(i))
def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
"""Assign palette colors to leaf shapes when no color_map is provided."""
from OCP.TDF import TDF_LabelSequence
from OCP.XCAFDoc import XCAFDoc_ShapeTool
try:
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
except ImportError:
COLOR_SURF = 1
leaves: list = []
def _collect(label) -> None:
components = TDF_LabelSequence()
XCAFDoc_ShapeTool.GetComponents_s(label, components)
if components.Length() == 0:
leaves.append(label)
else:
for i in range(1, components.Length() + 1):
_collect(components.Value(i))
for i in range(1, free_labels.Length() + 1):
_collect(free_labels.Value(i))
for idx, label in enumerate(leaves):
occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)])
color_tool.SetColor(label, occ_color, COLOR_SURF)
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
from OCP.STEPCAFControl import STEPCAFControl_Reader
from OCP.TDocStd import TDocStd_Document
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_DocumentTool
from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
from OCP.TDF import TDF_LabelSequence
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.IFSelect import IFSelect_RetDone
from OCP.Message import Message_ProgressRange
# --- Init XDE document ---
app = XCAFApp_Application.GetApplication_s()
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
app.InitDocument(doc)
# --- Read STEP into XDE (preserves part names + embedded colors) ---
reader = STEPCAFControl_Reader()
reader.SetNameMode(True)
reader.SetColorMode(True)
reader.SetLayerMode(True)
status = reader.ReadFile(args.step_path)
if status != IFSelect_RetDone:
print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr)
sys.exit(1)
reader.Transfer(doc)
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
# --- Tessellate all free shapes ---
free_labels = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_labels)
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i))
if not shape.IsNull():
BRepMesh_IncrementalMesh(
shape,
args.linear_deflection,
False, # isRelative
args.angular_deflection,
True, # isInParallel
)
# --- Apply colors ---
if color_map:
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
print(f"Applied color_map ({len(color_map)} entries)")
else:
_apply_palette_colors(shape_tool, color_tool, free_labels)
print("Applied palette colors (no color_map provided)")
# --- Scale shapes mm → m before GLB export ---
# RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings.
# Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform.
from OCP.gp import gp_Trsf
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
trsf = gp_Trsf()
trsf.SetScaleFactor(0.001)
for i in range(1, free_labels.Length() + 1):
label = free_labels.Value(i)
orig_shape = shape_tool.GetShape_s(label)
if not orig_shape.IsNull():
scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape()
shape_tool.SetShape(label, scaled)
print("Shapes scaled mm → m")
# --- Export GLB via RWGltf_CafWriter ---
from OCP.RWGltf import RWGltf_CafWriter
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
# Perform export
try:
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
metadata = TColStd_IndexedDataMapOfStringString()
ok = writer.Perform(doc, metadata, Message_ProgressRange())
except TypeError:
# Older API without metadata dict
ok = writer.Perform(doc, Message_ProgressRange())
out = Path(args.output_path)
if not ok or not out.exists() or out.stat().st_size == 0:
print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}",
file=sys.stderr)
sys.exit(1)
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)