feat: surface-evaluated normals, GMSH tessellation, draw call batching
USD exporter: - Compute normals from B-Rep surface via BRepLProp_SLProps at each vertex UV parameter — eliminates faceting on curved surfaces (same as Stepper) - Add GMSH Frontal-Delaunay tessellation engine (opt-in via --tessellation_engine gmsh) with per-solid strategy matching export_step_to_gltf.py - Use vertex normal interpolation instead of faceVarying (6x smaller normals) - Default engine remains OCC (GMSH has coordinate-space bug with instanced parts) Frontend: - Fix faceted shading in InlineCadViewer: only call computeVertexNormals() when geometry lacks normals, preserving smooth GLB normals from pipeline - Add useGeometryMerge hook for draw call batching (merge by material) - Fix unused import in cadUtils, optional props in ThreeDViewer Backend: - Move dataclass import to top of step_processor.py (PEP 8) - Unified single-read STEP metadata extraction with fallback Render worker: - Fix USD import seam/sharp restoration: read primvars via pxr directly (Blender's USD importer doesn't expose custom Int2Array primvars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ This module is invoked from the Celery worker (step_tasks.py).
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -112,12 +113,20 @@ def extract_cad_metadata(cad_file_id: str, tenant_id: str | None = None) -> None
|
||||
if not step_path.exists():
|
||||
raise FileNotFoundError(f"STEP file not found: {step_path}")
|
||||
|
||||
objects = _extract_step_objects(step_path)
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
|
||||
edge_data = extract_mesh_edge_data(str(step_path))
|
||||
if edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
|
||||
# Try unified single-read first, fall back to separate reads
|
||||
metadata = extract_step_metadata(str(step_path))
|
||||
if metadata.objects:
|
||||
objects = metadata.objects
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
if metadata.edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **metadata.edge_data}
|
||||
else:
|
||||
logger.info(f"[STEP] fallback: separate reads for {cad_file_id}")
|
||||
objects = _extract_step_objects(step_path)
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
edge_data = extract_mesh_edge_data(str(step_path))
|
||||
if edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
|
||||
|
||||
gltf_path = _convert_to_gltf(step_path, cad_file_id, settings.upload_dir)
|
||||
if gltf_path:
|
||||
@@ -164,14 +173,20 @@ def process_cad_file(cad_file_id: str) -> None:
|
||||
if not step_path.exists():
|
||||
raise FileNotFoundError(f"STEP file not found: {step_path}")
|
||||
|
||||
# Step 1: Extract object names
|
||||
objects = _extract_step_objects(step_path)
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
|
||||
# Step 1b: Extract sharp-edge topology data and merge into mesh_attributes
|
||||
edge_data = extract_mesh_edge_data(str(step_path))
|
||||
if edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
|
||||
# Step 1: Extract object names + edge data (unified single-read)
|
||||
metadata = extract_step_metadata(str(step_path))
|
||||
if metadata.objects:
|
||||
objects = metadata.objects
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
if metadata.edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **metadata.edge_data}
|
||||
else:
|
||||
logger.info(f"[STEP] fallback: separate reads for {cad_file_id}")
|
||||
objects = _extract_step_objects(step_path)
|
||||
cad_file.parsed_objects = {"objects": objects}
|
||||
edge_data = extract_mesh_edge_data(str(step_path))
|
||||
if edge_data:
|
||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
|
||||
|
||||
# Step 2: Generate thumbnail — pass empty part_colors so the Three.js
|
||||
# renderer extracts named parts and auto-assigns palette colours.
|
||||
@@ -388,6 +403,235 @@ def extract_mesh_edge_data(step_path: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepMetadata:
|
||||
"""Result of unified STEP file read — part names + edge data in one pass."""
|
||||
objects: list[str] = field(default_factory=list)
|
||||
edge_data: dict = field(default_factory=dict)
|
||||
dimensions_mm: dict | None = None
|
||||
bbox_center_mm: dict | None = None
|
||||
|
||||
|
||||
def extract_step_metadata(step_path: str) -> StepMetadata:
|
||||
"""Read a STEP file once via XCAF and extract both part names and edge topology.
|
||||
|
||||
Replaces the two-pass pattern of _extract_step_objects() + extract_mesh_edge_data()
|
||||
with a single STEPCAFControl_Reader read. The XCAF reader gives us both the labeled
|
||||
hierarchy (part names) and the TopoDS_Shape (for tessellation and edge analysis).
|
||||
|
||||
Falls back gracefully: returns StepMetadata with empty fields on ImportError.
|
||||
"""
|
||||
try:
|
||||
# Try OCC.Core first (pythonocc, available in worker container)
|
||||
_using_ocp = False
|
||||
try:
|
||||
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
|
||||
from OCC.Core.TDocStd import TDocStd_Document
|
||||
from OCC.Core.TDataStd import TDataStd_Name
|
||||
from OCC.Core.TCollection import TCollection_ExtendedString
|
||||
from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve, BRepAdaptor_Curve2d
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
|
||||
from OCC.Core.TopExp import topexp as _TopExp
|
||||
from OCC.Core.TopoDS import TopoDS as _TopoDS
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib as _brepbndlib_mod
|
||||
|
||||
def _map_shapes(shape, edge_type, face_type, out_map):
|
||||
_TopExp.MapShapesAndAncestors(shape, edge_type, face_type, out_map)
|
||||
def _to_edge(s):
|
||||
return _TopoDS.Edge(s)
|
||||
def _to_face(s):
|
||||
return _TopoDS.Face(s)
|
||||
def _brepbndlib_add(shape, bbox):
|
||||
_brepbndlib_mod.Add(shape, bbox)
|
||||
except ImportError:
|
||||
# Fall back to OCP (cadquery's fork)
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader # type: ignore[no-redef]
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool # type: ignore[no-redef]
|
||||
from OCP.TDocStd import TDocStd_Document # type: ignore[no-redef]
|
||||
from OCP.TDataStd import TDataStd_Name # type: ignore[no-redef]
|
||||
from OCP.TCollection import TCollection_ExtendedString # type: ignore[no-redef]
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_FORWARD # type: ignore[no-redef]
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve, BRepAdaptor_Curve2d # type: ignore[no-redef]
|
||||
from OCP.BRepLProp import BRepLProp_SLProps # type: ignore[no-redef]
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh # type: ignore[no-redef]
|
||||
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape # type: ignore[no-redef]
|
||||
from OCP.TopExp import TopExp as _TopExp # type: ignore[no-redef]
|
||||
from OCP.TopoDS import TopoDS as _TopoDS # type: ignore[no-redef]
|
||||
from OCP.Bnd import Bnd_Box # type: ignore[no-redef]
|
||||
from OCP.BRepBndLib import BRepBndLib as _brepbndlib_mod # type: ignore[no-redef]
|
||||
_using_ocp = True
|
||||
|
||||
def _map_shapes(shape, edge_type, face_type, out_map):
|
||||
_TopExp.MapShapesAndAncestors_s(shape, edge_type, face_type, out_map)
|
||||
def _to_edge(s):
|
||||
return _TopoDS.Edge_s(s)
|
||||
def _to_face(s):
|
||||
return _TopoDS.Face_s(s)
|
||||
def _brepbndlib_add(shape, bbox):
|
||||
_brepbndlib_mod.Add_s(shape, bbox)
|
||||
|
||||
import math
|
||||
|
||||
# ── Step 1: Read STEP via XCAF (single read) ──────────────────────
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetColorMode(True)
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(step_path))
|
||||
if not reader.Transfer(doc):
|
||||
logger.warning("extract_step_metadata: XCAF transfer failed for %s", step_path)
|
||||
return StepMetadata()
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main()) if not _using_ocp \
|
||||
else XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
|
||||
labels = []
|
||||
shape_tool.GetFreeShapes(labels)
|
||||
|
||||
# ── Step 2: Extract part names from XCAF labels ───────────────────
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
name_attr = TDataStd_Name()
|
||||
find_id = TDataStd_Name.GetID() if not _using_ocp else TDataStd_Name.GetID_s()
|
||||
if label.FindAttribute(find_id, name_attr):
|
||||
names.append(name_attr.Get().ToExtString())
|
||||
|
||||
# ── Step 3: Get root shape and tessellate ─────────────────────────
|
||||
# Collect all free shapes — usually just one root compound
|
||||
root_shapes = []
|
||||
for label in labels:
|
||||
s = shape_tool.GetShape(label) if not _using_ocp else shape_tool.GetShape_s(label)
|
||||
if not s.IsNull():
|
||||
root_shapes.append(s)
|
||||
|
||||
if not root_shapes:
|
||||
return StepMetadata(objects=names)
|
||||
|
||||
# Tessellate and extract edges from each root shape
|
||||
SHARP_THRESHOLD_DEG = 20.0
|
||||
dihedral_angles: list[float] = []
|
||||
sharp_pairs: list = []
|
||||
all_shapes_for_bbox = []
|
||||
|
||||
for shape in root_shapes:
|
||||
BRepMesh_IncrementalMesh(shape, 0.5, False, 0.5)
|
||||
all_shapes_for_bbox.append(shape)
|
||||
|
||||
# Build edge → adjacent faces map
|
||||
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
|
||||
_map_shapes(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)
|
||||
|
||||
for i in range(1, edge_face_map.Extent() + 1):
|
||||
edge_shape = edge_face_map.FindKey(i)
|
||||
faces = edge_face_map.FindFromIndex(i)
|
||||
if faces.Size() < 2:
|
||||
continue
|
||||
face_shapes = list(faces)
|
||||
if len(face_shapes) < 2:
|
||||
continue
|
||||
try:
|
||||
edge = _to_edge(edge_shape)
|
||||
face1 = _to_face(face_shapes[0])
|
||||
face2 = _to_face(face_shapes[1])
|
||||
|
||||
curve3d = BRepAdaptor_Curve(edge)
|
||||
pt_start = curve3d.Value(curve3d.FirstParameter())
|
||||
pt_end = curve3d.Value(curve3d.LastParameter())
|
||||
|
||||
c2d_1 = BRepAdaptor_Curve2d(edge, face1)
|
||||
uv1 = c2d_1.Value((c2d_1.FirstParameter() + c2d_1.LastParameter()) / 2)
|
||||
surf1 = BRepAdaptor_Surface(face1)
|
||||
props1 = BRepLProp_SLProps(surf1, uv1.X(), uv1.Y(), 1, 1e-6)
|
||||
if not props1.IsNormalDefined():
|
||||
continue
|
||||
n1 = props1.Normal()
|
||||
if face1.Orientation() != TopAbs_FORWARD:
|
||||
n1.Reverse()
|
||||
|
||||
c2d_2 = BRepAdaptor_Curve2d(edge, face2)
|
||||
uv2 = c2d_2.Value((c2d_2.FirstParameter() + c2d_2.LastParameter()) / 2)
|
||||
surf2 = BRepAdaptor_Surface(face2)
|
||||
props2 = BRepLProp_SLProps(surf2, uv2.X(), uv2.Y(), 1, 1e-6)
|
||||
if not props2.IsNormalDefined():
|
||||
continue
|
||||
n2 = props2.Normal()
|
||||
if face2.Orientation() != TopAbs_FORWARD:
|
||||
n2.Reverse()
|
||||
|
||||
cos_angle = max(-1.0, min(1.0, n1.Dot(n2)))
|
||||
angle_deg = math.degrees(math.acos(cos_angle))
|
||||
if angle_deg > 90:
|
||||
angle_deg = 180.0 - angle_deg
|
||||
dihedral_angles.append(angle_deg)
|
||||
|
||||
if angle_deg > SHARP_THRESHOLD_DEG:
|
||||
sharp_pairs.append([
|
||||
[round(pt_start.X(), 3), round(pt_start.Y(), 3), round(pt_start.Z(), 3)],
|
||||
[round(pt_end.X(), 3), round(pt_end.Y(), 3), round(pt_end.Z(), 3)],
|
||||
])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# ── Step 4: Bounding box ──────────────────────────────────────────
|
||||
dimensions_mm = None
|
||||
bbox_center_mm = None
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
for shape in all_shapes_for_bbox:
|
||||
_brepbndlib_add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
dimensions_mm = {
|
||||
"x": round(xmax - xmin, 2),
|
||||
"y": round(ymax - ymin, 2),
|
||||
"z": round(zmax - zmin, 2),
|
||||
}
|
||||
bbox_center_mm = {
|
||||
"x": round((xmin + xmax) / 2, 2),
|
||||
"y": round((ymin + ymax) / 2, 2),
|
||||
"z": round((zmin + zmax) / 2, 2),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Step 5: Build edge_data dict ──────────────────────────────────
|
||||
edge_data: dict = {}
|
||||
if dimensions_mm:
|
||||
edge_data["dimensions_mm"] = dimensions_mm
|
||||
edge_data["bbox_center_mm"] = bbox_center_mm
|
||||
|
||||
if dihedral_angles:
|
||||
import statistics
|
||||
max_angle = max(dihedral_angles)
|
||||
hard_edges = [a for a in dihedral_angles if a > SHARP_THRESHOLD_DEG]
|
||||
if hard_edges:
|
||||
suggested = max(15.0, min(60.0, statistics.median(hard_edges) * 0.8))
|
||||
else:
|
||||
suggested = 30.0
|
||||
edge_data["suggested_smooth_angle"] = round(suggested, 1)
|
||||
edge_data["has_mechanical_edges"] = max_angle > 45
|
||||
edge_data["sharp_edge_pairs"] = sharp_pairs
|
||||
|
||||
logger.info(f"[STEP] unified read: {len(names)} objects, {len(sharp_pairs)} sharp pairs")
|
||||
return StepMetadata(
|
||||
objects=names,
|
||||
edge_data=edge_data,
|
||||
dimensions_mm=dimensions_mm,
|
||||
bbox_center_mm=bbox_center_mm,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("OCC not available for extract_step_metadata")
|
||||
return StepMetadata()
|
||||
except Exception as exc:
|
||||
logger.warning("extract_step_metadata failed: %s", exc)
|
||||
return StepMetadata()
|
||||
|
||||
|
||||
def _extract_step_objects(step_path: Path) -> list[str]:
|
||||
"""Extract part names from STEP file using pythonocc."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user