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:
2026-03-13 15:14:23 +01:00
parent 6c5873d51f
commit 253f11a945
8 changed files with 977 additions and 166 deletions
+248 -4
View File
@@ -8,6 +8,7 @@ This module is invoked from the Celery worker (step_tasks.py).
""" """
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -112,9 +113,17 @@ def extract_cad_metadata(cad_file_id: str, tenant_id: str | None = None) -> None
if not step_path.exists(): if not step_path.exists():
raise FileNotFoundError(f"STEP file not found: {step_path}") raise FileNotFoundError(f"STEP file not found: {step_path}")
# 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) objects = _extract_step_objects(step_path)
cad_file.parsed_objects = {"objects": objects} cad_file.parsed_objects = {"objects": objects}
edge_data = extract_mesh_edge_data(str(step_path)) edge_data = extract_mesh_edge_data(str(step_path))
if edge_data: if edge_data:
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data} cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
@@ -164,11 +173,17 @@ def process_cad_file(cad_file_id: str) -> None:
if not step_path.exists(): if not step_path.exists():
raise FileNotFoundError(f"STEP file not found: {step_path}") raise FileNotFoundError(f"STEP file not found: {step_path}")
# Step 1: Extract object names # 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) objects = _extract_step_objects(step_path)
cad_file.parsed_objects = {"objects": objects} 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)) edge_data = extract_mesh_edge_data(str(step_path))
if edge_data: if edge_data:
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data} cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
@@ -388,6 +403,235 @@ def extract_mesh_edge_data(step_path: str) -> dict:
return {} 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]: def _extract_step_objects(step_path: Path) -> list[str]:
"""Extract part names from STEP file using pythonocc.""" """Extract part names from STEP file using pythonocc."""
try: try:
@@ -4,7 +4,7 @@ import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei' import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff } from 'lucide-react' import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff, Zap } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media' import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad' import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
@@ -12,6 +12,7 @@ import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel' import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils' import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
type ViewMode = 'solid' | 'wireframe' type ViewMode = 'solid' | 'wireframe'
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city' type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
@@ -105,7 +106,10 @@ function GlbModelWithFit({
if (obj.geometry) { if (obj.geometry) {
let geo = obj.geometry.clone() let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo) if (!geo.index) geo = mergeVertices(geo)
geo.computeVertexNormals() // Only compute normals if the geometry doesn't already have them.
// GLBs from our pipeline include smooth normals — overwriting them
// with computeVertexNormals() produces flat/faceted shading.
if (!geo.attributes.normal) geo.computeVertexNormals()
obj.geometry = geo obj.geometry = geo
} }
// Clone materials so emissive / color changes don't affect the shared GLTF cache // Clone materials so emissive / color changes don't affect the shared GLTF cache
@@ -189,6 +193,7 @@ export default function InlineCadViewer({
const [showUnassigned, setShowUnassigned] = useState(false) const [showUnassigned, setShowUnassigned] = useState(false)
const [hideAssigned, setHideAssigned] = useState(false) const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none') const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [perfMode, setPerfMode] = useState(false)
const [totalMeshCount, setTotalMeshCount] = useState(0) const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set()) const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({}) const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
@@ -381,6 +386,15 @@ export default function InlineCadViewer({
onError: () => toast.error('Failed to queue GLB generation'), onError: () => toast.error('Failed to queue GLB generation'),
}) })
// Performance mode: merge geometries by material to reduce draw calls
useGeometryMerge({
meshRegistryRef,
partMaterials,
pbrMap,
enabled: perfMode,
sceneRef,
})
// Hover highlight // Hover highlight
const handlePointerOver = useCallback((e: any) => { const handlePointerOver = useCallback((e: any) => {
e.stopPropagation() e.stopPropagation()
@@ -469,6 +483,13 @@ export default function InlineCadViewer({
</ToolbarBtn> </ToolbarBtn>
))} ))}
<div className="w-px h-4 bg-white/10 mx-0.5" />
{/* Performance mode */}
<ToolbarBtn active={perfMode} onClick={() => setPerfMode(v => !v)} title="Performance mode — merges geometries, disables per-part hover">
<Zap size={11} /> Perf
</ToolbarBtn>
{/* Show unassigned + hide assigned toggles */} {/* Show unassigned + hide assigned toggles */}
{modelReady && ( {modelReady && (
<> <>
@@ -542,9 +563,9 @@ export default function InlineCadViewer({
setModelReady(true) setModelReady(true)
setFitTrigger(t => t + 1) setFitTrigger(t => t + 1)
}} }}
onPointerOver={handlePointerOver} onPointerOver={perfMode ? undefined : handlePointerOver}
onPointerOut={handlePointerOut} onPointerOut={perfMode ? undefined : handlePointerOut}
onClick={handleClick} onClick={perfMode ? undefined : handleClick}
/> />
</Suspense> </Suspense>
<OrbitControls ref={controlsRef} makeDefault /> <OrbitControls ref={controlsRef} makeDefault />
+25 -7
View File
@@ -25,7 +25,7 @@ import * as THREE from 'three'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown, X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff, Maximize2, Grid3X3, Sun, AlertCircle, EyeOff, Layers,
} from 'lucide-react' } from 'lucide-react'
import api from '../../api/client' import api from '../../api/client'
import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad' import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad'
@@ -34,6 +34,7 @@ import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel' import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils' import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -181,9 +182,9 @@ interface ModelWithReadyProps {
wireframe: boolean wireframe: boolean
onReady: () => void onReady: () => void
sceneRef: React.MutableRefObject<THREE.Object3D | null> sceneRef: React.MutableRefObject<THREE.Object3D | null>
onPointerOver: (e: any) => void onPointerOver?: (e: any) => void
onPointerOut: () => void onPointerOut?: () => void
onClick: (e: any) => void onClick?: (e: any) => void
} }
function ModelWithReady({ url, wireframe, onReady, sceneRef, onPointerOver, onPointerOut, onClick }: ModelWithReadyProps) { function ModelWithReady({ url, wireframe, onReady, sceneRef, onPointerOver, onPointerOut, onClick }: ModelWithReadyProps) {
@@ -403,6 +404,9 @@ export default function ThreeDViewer({
// Isolation mode — ghost/hide other parts while a part is pinned // Isolation mode — ghost/hide other parts while a part is pinned
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none') const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
// Performance mode — merge geometries by material to reduce draw calls
const [perfMode, setPerfMode] = useState(false)
// Refs // Refs
const sceneRef = useRef<THREE.Object3D | null>(null) const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null) const controlsRef = useRef<any>(null)
@@ -689,6 +693,15 @@ export default function ThreeDViewer({
document.body.appendChild(a); a.click(); document.body.removeChild(a) document.body.appendChild(a); a.click(); document.body.removeChild(a)
} }
// Performance mode: merge geometries by material to reduce draw calls
useGeometryMerge({
meshRegistryRef,
partMaterials: effectiveMaterials,
pbrMap,
enabled: perfMode,
sceneRef,
})
// Task 5 — hover: highlight mesh with emissive, restore on out // Task 5 — hover: highlight mesh with emissive, restore on out
const handlePointerOver = useCallback((e: any) => { const handlePointerOver = useCallback((e: any) => {
e.stopPropagation() e.stopPropagation()
@@ -770,6 +783,11 @@ export default function ThreeDViewer({
{/* Wireframe */} {/* Wireframe */}
<TBtn active={wireframe} onClick={() => setWireframe(v => !v)} title="Wireframe (W)">Wire</TBtn> <TBtn active={wireframe} onClick={() => setWireframe(v => !v)} title="Wireframe (W)">Wire</TBtn>
{/* Performance mode */}
<TBtn active={perfMode} onClick={() => setPerfMode(v => !v)} title="Performance mode — merges geometries, disables per-part hover">
<Layers size={11} />
</TBtn>
{/* Projection: Perspective / Ortho */} {/* Projection: Perspective / Ortho */}
<div className="flex rounded-md overflow-hidden border border-gray-700"> <div className="flex rounded-md overflow-hidden border border-gray-700">
{(['Persp', 'Ortho'] as const).map(label => { {(['Persp', 'Ortho'] as const).map(label => {
@@ -1048,9 +1066,9 @@ export default function ThreeDViewer({
wireframe={wireframe} wireframe={wireframe}
onReady={handleModelReady} onReady={handleModelReady}
sceneRef={sceneRef} sceneRef={sceneRef}
onPointerOver={handlePointerOver} onPointerOver={perfMode ? undefined : handlePointerOver}
onPointerOut={handlePointerOut} onPointerOut={perfMode ? undefined : handlePointerOut}
onClick={handleClick} onClick={perfMode ? undefined : handleClick}
/> />
</Suspense> </Suspense>
</GltfErrorBoundary> </GltfErrorBoundary>
+31
View File
@@ -196,3 +196,34 @@ export function forEachMeshMaterial(mesh: any, fn: (mat: any) => void): void {
if (m && 'color' in m) fn(m) if (m && 'color' in m) fn(m)
} }
} }
// ---------------------------------------------------------------------------
// Geometry merge helpers — for draw call batching in Performance mode
// ---------------------------------------------------------------------------
/**
* Group registry entries by their resolved material key.
*
* Returns a Map where each key is a material identifier (library name, hex
* color, or '__unassigned__') and each value is the list of registry entries
* sharing that material. Groups with only 1 mesh are not worth merging and
* are excluded.
*/
export function groupRegistryByMaterial(
registry: MeshRegistryEntry[],
partMaterials: PartMaterialMap,
): Map<string, MeshRegistryEntry[]> {
const groups = new Map<string, MeshRegistryEntry[]>()
for (const entry of registry) {
const mat = resolvePartMaterial(entry.partKey, partMaterials)
const key = mat ? `${mat.type}:${mat.value}` : '__unassigned__'
const arr = groups.get(key)
if (arr) arr.push(entry)
else groups.set(key, [entry])
}
// Only keep groups with 2+ meshes (merging 1 mesh is pointless)
for (const [key, arr] of groups) {
if (arr.length < 2) groups.delete(key)
}
return groups
}
@@ -0,0 +1,169 @@
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import type { MeshRegistryEntry } from './cadUtils'
import type { PartMaterialMap } from '../../api/cad'
import { groupRegistryByMaterial, resolvePartMaterial } from './cadUtils'
import type { MaterialPBRMap } from '../../api/assetLibraries'
import { applyPBRToMaterial, previewColorForEntry } from './cadUtils'
interface UseGeometryMergeOpts {
meshRegistryRef: React.RefObject<MeshRegistryEntry[]>
partMaterials: PartMaterialMap
pbrMap: MaterialPBRMap
enabled: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sceneRef: React.RefObject<any> // THREE.Scene
}
interface MergedState {
mergedMeshes: THREE.Mesh[]
hiddenOriginals: THREE.Object3D[]
}
/**
* Hook that merges meshes sharing the same material into single geometries
* to reduce draw calls. When enabled, original meshes are hidden and merged
* replacements are added to the scene. When disabled, originals are restored.
*/
export function useGeometryMerge({
meshRegistryRef,
partMaterials,
pbrMap,
enabled,
sceneRef,
}: UseGeometryMergeOpts): { drawCallReduction: number } {
const stateRef = useRef<MergedState | null>(null)
const reductionRef = useRef(0)
useEffect(() => {
const scene = sceneRef.current
const registry = meshRegistryRef.current
if (!scene || !registry || registry.length === 0) return
if (!enabled) {
// Restore originals
if (stateRef.current) {
_restore(stateRef.current, scene)
stateRef.current = null
reductionRef.current = 0
}
return
}
// Already merged — skip
if (stateRef.current) return
const groups = groupRegistryByMaterial(registry, partMaterials)
if (groups.size === 0) return
const mergedMeshes: THREE.Mesh[] = []
const hiddenOriginals: THREE.Object3D[] = []
let meshesReplaced = 0
for (const [materialKey, entries] of groups) {
// Collect geometries with world transforms baked in
const geometries: THREE.BufferGeometry[] = []
for (const entry of entries) {
const mesh = entry.mesh as THREE.Mesh
if (!mesh.geometry) continue
// Ensure world matrix is up to date
mesh.updateWorldMatrix(true, false)
const cloned = mesh.geometry.clone()
cloned.applyMatrix4(mesh.matrixWorld)
// Skip geometries with incompatible attributes (e.g. missing normals)
if (geometries.length > 0) {
const refAttrs = Object.keys(geometries[0].attributes).sort().join(',')
const curAttrs = Object.keys(cloned.attributes).sort().join(',')
if (curAttrs !== refAttrs) {
cloned.dispose()
continue
}
}
geometries.push(cloned)
}
if (geometries.length < 2) {
for (const g of geometries) g.dispose()
continue
}
try {
const merged = mergeGeometries(geometries, false)
if (!merged) {
for (const g of geometries) g.dispose()
continue
}
// Create merged mesh with material from the first entry
const sourceMesh = entries[0].mesh as THREE.Mesh
const mat = (Array.isArray(sourceMesh.material)
? sourceMesh.material[0]
: sourceMesh.material) as THREE.MeshStandardMaterial
const mergedMat = mat.clone()
// Apply PBR properties to the merged material
const partEntry = resolvePartMaterial(entries[0].partKey, partMaterials)
if (partEntry) {
if (partEntry.type === 'library' && pbrMap[partEntry.value]) {
applyPBRToMaterial(mergedMat, pbrMap[partEntry.value])
} else {
mergedMat.color.set(previewColorForEntry(partEntry, pbrMap))
}
}
const mergedMesh = new THREE.Mesh(merged, mergedMat)
mergedMesh.name = `__merged_${materialKey}`
mergedMesh.userData._isMerged = true
scene.add(mergedMesh)
mergedMeshes.push(mergedMesh)
// Hide originals
for (const entry of entries) {
const mesh = entry.mesh as THREE.Object3D
mesh.visible = false
mesh.raycast = () => {} // disable raycasting
hiddenOriginals.push(mesh)
}
meshesReplaced += entries.length
// Clean up cloned geometries (merged copy owns the data now)
for (const g of geometries) g.dispose()
} catch {
for (const g of geometries) g.dispose()
}
}
stateRef.current = { mergedMeshes, hiddenOriginals }
reductionRef.current = meshesReplaced - mergedMeshes.length
// Cleanup on unmount or deps change
return () => {
if (stateRef.current) {
_restore(stateRef.current, scene)
stateRef.current = null
reductionRef.current = 0
}
}
}, [enabled, partMaterials, pbrMap, meshRegistryRef, sceneRef])
return { drawCallReduction: reductionRef.current }
}
function _restore(state: MergedState, scene: THREE.Scene): void {
// Remove merged meshes
for (const mesh of state.mergedMeshes) {
scene.remove(mesh)
mesh.geometry.dispose()
if (Array.isArray(mesh.material)) {
for (const m of mesh.material) m.dispose()
} else {
mesh.material.dispose()
}
}
// Restore originals
for (const obj of state.hiddenOriginals) {
obj.visible = true
obj.raycast = THREE.Mesh.prototype.raycast
}
}
+370 -8
View File
@@ -47,6 +47,9 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--sharp_threshold", type=float, default=20.0) p.add_argument("--sharp_threshold", type=float, default=20.0)
p.add_argument("--cad_file_id", default="") p.add_argument("--cad_file_id", default="")
p.add_argument("--material_map", default="{}") p.add_argument("--material_map", default="{}")
p.add_argument("--tessellation_engine", choices=["occ", "gmsh"], default="occ",
help="Tessellation backend: 'occ' = BRepMesh (default, fast), "
"'gmsh' = Frontal-Delaunay (uniform mesh, no fan artifacts but slower)")
return p.parse_args() return p.parse_args()
@@ -455,7 +458,7 @@ def _author_xcaf_to_usd(
hex_color = PALETTE_HEX[counters['n_parts'] % len(PALETTE_HEX)] hex_color = PALETTE_HEX[counters['n_parts'] % len(PALETTE_HEX)]
# Extract mesh from definition shape (face_loc only, no instance placement) # Extract mesh from definition shape (face_loc only, no instance placement)
vertices, triangles = _extract_mesh(bare_def) vertices, triangles, per_vertex_normals = _extract_mesh(bare_def)
if not vertices or not triangles: if not vertices or not triangles:
counters['n_empty'] += 1 counters['n_empty'] += 1
return return
@@ -509,6 +512,17 @@ def _author_xcaf_to_usd(
r, g, b = _hex_to_rgb01(hex_color) r, g, b = _hex_to_rgb01(hex_color)
mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)])) mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)]))
# ── Per-vertex normals ─────────────────────────────────────
# Blender's USD importer reads normals and creates custom split
# normals. Using 'vertex' interpolation (1 normal per vertex) is
# ~6× smaller than 'faceVarying' (1 per triangle corner) and
# produces identical smooth shading for our tessellated meshes.
if per_vertex_normals and len(per_vertex_normals) == len(vertices):
mesh.CreateNormalsAttr(Vt.Vec3fArray([
Gf.Vec3f(nx, ny, nz) for (nx, ny, nz) in per_vertex_normals
]))
mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex)
# ── Material metadata on mesh prim (customData) ─────────── # ── Material metadata on mesh prim (customData) ───────────
mesh_prim = mesh.GetPrim() mesh_prim = mesh.GetPrim()
mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key) mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key)
@@ -575,11 +589,19 @@ def _author_xcaf_to_usd(
# ── Mesh geometry extraction ────────────────────────────────────────────────── # ── Mesh geometry extraction ──────────────────────────────────────────────────
def _extract_mesh(shape) -> tuple[list, list]: def _extract_mesh(shape) -> tuple[list, list, list]:
"""Return (vertices, triangles) from a tessellated OCC shape. """Return (vertices, triangles, normals) from a tessellated OCC shape.
Vertices are in OCC space (mm, Z-up). Vertices are in OCC space (mm, Z-up).
Triangles are 0-based index triples. Triangles are 0-based index triples.
Normals are per-vertex (same count/order as vertices).
Normal sources (in priority order):
1. B-Rep surface normals — evaluated from the mathematical surface at each
vertex UV parameter via BRepLProp_SLProps. Gives perfectly smooth normals
regardless of triangle topology (same approach as Stepper/CAD-Exchanger).
2. Angle-weighted vertex normals from triangle cross products — fallback when
UV nodes are unavailable (e.g. after GMSH tessellation).
Transform strategy: strip the shape's own Location before exploring faces Transform strategy: strip the shape's own Location before exploring faces
so that face_loc from BRep_Tool.Triangulation_s is always relative to the so that face_loc from BRep_Tool.Triangulation_s is always relative to the
@@ -593,10 +615,14 @@ def _extract_mesh(shape) -> tuple[list, list]:
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS
from OCP.BRep import BRep_Tool from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location from OCP.TopLoc import TopLoc_Location
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepLProp import BRepLProp_SLProps
vertices: list = [] vertices: list = []
normals: list = []
triangles: list = [] triangles: list = []
v_offset = 0 v_offset = 0
faces_without_normals = False
shape_trsf = shape.Location().Transformation() shape_trsf = shape.Location().Transformation()
shape_has_loc = not shape.Location().IsIdentity() shape_has_loc = not shape.Location().IsIdentity()
@@ -614,16 +640,48 @@ def _extract_mesh(shape) -> tuple[list, list]:
reversed_face = (face.Orientation() == TopAbs_REVERSED) reversed_face = (face.Orientation() == TopAbs_REVERSED)
face_has_loc = not face_loc.IsIdentity() face_has_loc = not face_loc.IsIdentity()
# Evaluate surface normals from B-Rep geometry (analytically exact).
# This gives smooth shading across face boundaries because the normals
# come from the mathematical surface, not from triangle approximation.
has_uv = tri.HasUVNodes()
surf = None
if has_uv:
try:
surf = BRepAdaptor_Surface(face)
except Exception:
surf = None
for i in range(1, tri.NbNodes() + 1): for i in range(1, tri.NbNodes() + 1):
node = tri.Node(i) node = tri.Node(i)
# Step 1: face_loc — definition-space transform (face within shape)
if face_has_loc: if face_has_loc:
node = node.Transformed(face_loc.Transformation()) node = node.Transformed(face_loc.Transformation())
# Step 2: shape_loc — instance placement (shape within assembly)
if shape_has_loc: if shape_has_loc:
node = node.Transformed(shape_trsf) node = node.Transformed(shape_trsf)
vertices.append((node.X(), node.Y(), node.Z())) vertices.append((node.X(), node.Y(), node.Z()))
nrm_set = False
if surf is not None:
try:
uv = tri.UVNode(i)
props = BRepLProp_SLProps(surf, uv.X(), uv.Y(), 1, 1e-6)
if props.IsNormalDefined():
nrm = props.Normal()
if face_has_loc:
nrm = nrm.Transformed(face_loc.Transformation())
if shape_has_loc:
nrm = nrm.Transformed(shape_trsf)
if reversed_face:
normals.append((-nrm.X(), -nrm.Y(), -nrm.Z()))
else:
normals.append((nrm.X(), nrm.Y(), nrm.Z()))
nrm_set = True
except Exception:
pass
if not nrm_set:
faces_without_normals = True
normals.append(None) # type: ignore[arg-type]
for i in range(1, tri.NbTriangles() + 1): for i in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tri.Triangle(i).Get() n1, n2, n3 = tri.Triangle(i).Get()
v0 = n1 - 1 + v_offset v0 = n1 - 1 + v_offset
@@ -635,7 +693,248 @@ def _extract_mesh(shape) -> tuple[list, list]:
exp.Next() exp.Next()
return vertices, triangles # Fallback: compute normals from triangle geometry for faces without UV data
if faces_without_normals and vertices and triangles:
_compute_vertex_normals(vertices, triangles, normals)
return vertices, triangles, normals
def _compute_vertex_normals(vertices: list, triangles: list, normals: list) -> None:
"""Fill in None entries in normals with angle-weighted vertex normals.
For each triangle, computes the face normal (cross product) and adds it
to each vertex's accumulator weighted by the interior angle at that vertex.
This produces smooth normals identical to what Blender's "Shade Smooth" does.
"""
# Accumulate weighted normals for vertices that need them
accum = {} # vertex_index → [nx, ny, nz]
for tri in triangles:
v0, v1, v2 = tri
# Only process if any vertex in this triangle needs normals
needs = any(normals[vi] is None for vi in (v0, v1, v2))
if not needs:
continue
p0 = vertices[v0]
p1 = vertices[v1]
p2 = vertices[v2]
# Edge vectors
e01 = (p1[0]-p0[0], p1[1]-p0[1], p1[2]-p0[2])
e02 = (p2[0]-p0[0], p2[1]-p0[1], p2[2]-p0[2])
e12 = (p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2])
# Face normal (unnormalized cross product)
fn = (
e01[1]*e02[2] - e01[2]*e02[1],
e01[2]*e02[0] - e01[0]*e02[2],
e01[0]*e02[1] - e01[1]*e02[0],
)
fn_len = (fn[0]**2 + fn[1]**2 + fn[2]**2) ** 0.5
if fn_len < 1e-12:
continue
# Angle at each vertex (for weighting)
def _dot(a, b):
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
def _len(a):
return (a[0]**2 + a[1]**2 + a[2]**2) ** 0.5
def _neg(a):
return (-a[0], -a[1], -a[2])
edges_at_vert = [
(v0, e01, e02),
(v1, _neg(e01), e12),
(v2, _neg(e02), _neg(e12)),
]
for vi, ea, eb in edges_at_vert:
if normals[vi] is not None:
continue
la, lb = _len(ea), _len(eb)
if la < 1e-12 or lb < 1e-12:
continue
cos_a = max(-1.0, min(1.0, _dot(ea, eb) / (la * lb)))
angle = math.acos(cos_a)
if vi not in accum:
accum[vi] = [0.0, 0.0, 0.0]
accum[vi][0] += fn[0] * angle
accum[vi][1] += fn[1] * angle
accum[vi][2] += fn[2] * angle
# Normalize and write back
for vi, acc in accum.items():
length = (acc[0]**2 + acc[1]**2 + acc[2]**2) ** 0.5
if length > 1e-12:
normals[vi] = (acc[0]/length, acc[1]/length, acc[2]/length)
else:
normals[vi] = (0.0, 0.0, 1.0)
# Fill any remaining None entries (shouldn't happen, but safety)
for i in range(len(normals)):
if normals[i] is None:
normals[i] = (0.0, 0.0, 1.0)
# ── GMSH Frontal-Delaunay tessellation ───────────────────────────────────────
def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None:
"""Tessellate an OCC TopoDS_Shape using GMSH Frontal-Delaunay mesher.
Writes the resulting Poly_Triangulation back to each TopoDS_Face via
BRep_Builder.UpdateFace(), so _extract_mesh() picks up the uniform
topology from BRep_Tool.Triangulation_s.
GMSH surface tags correspond 1:1 to faces in TopExp_Explorer(FACE) order
after importShapes() from a .brep file.
Falls back to BRepMesh for any face that GMSH cannot mesh.
"""
import tempfile
from OCP.BRep import BRep_Builder
from OCP.BRepTools import BRepTools
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.Poly import Poly_Triangulation, Poly_Array1OfTriangle, Poly_Triangle
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS as _TopoDS
from OCP.gp import gp_Pnt
import gmsh
with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp:
brep_path = tmp.name
n_faces_gmsh = 0
n_faces_fallback = 0
n_triangles_total = 0
try:
BRepTools.Write_s(shape, brep_path)
import os as _os
n_threads = min(_os.cpu_count() or 1, 16)
gmsh.initialize()
gmsh.option.setNumber("General.Terminal", 0)
gmsh.option.setNumber("General.NumThreads", n_threads)
gmsh.option.setNumber("Mesh.MaxNumThreads1D", n_threads)
gmsh.option.setNumber("Mesh.MaxNumThreads2D", n_threads)
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D
gmsh.option.setNumber("Mesh.RecombineAll", 0)
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 50.0)
min_circle_pts = min(12, max(6, int(math.ceil(
2.0 * math.pi / max(angular_deflection, 0.01)))))
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3)
gmsh.option.setNumber("General.Verbosity", 1)
gmsh.model.add("shape")
gmsh.model.occ.importShapes(brep_path)
gmsh.model.occ.synchronize()
gmsh.model.mesh.generate(2)
surface_tags = [tag for (_, tag) in gmsh.model.getEntities(2)]
surface_mesh: dict[int, tuple] = {}
for stag in surface_tags:
try:
node_tags, coords, _ = gmsh.model.mesh.getNodes(
dim=2, tag=stag, includeBoundary=True)
if len(node_tags) == 0:
continue
node_map = {}
pts_xyz = []
for i, ntag in enumerate(node_tags):
x, y, z = coords[3*i], coords[3*i+1], coords[3*i+2]
node_map[ntag] = i + 1
pts_xyz.append((x, y, z))
elem_types, elem_tags, elem_node_tags = gmsh.model.mesh.getElements(
dim=2, tag=stag)
tris = []
for etype, etags, entags in zip(elem_types, elem_tags, elem_node_tags):
if etype != 2:
continue
n_elems = len(etags)
for k in range(n_elems):
a = node_map.get(entags[3*k])
b = node_map.get(entags[3*k+1])
c = node_map.get(entags[3*k+2])
if a and b and c:
tris.append((a, b, c))
if pts_xyz and tris:
surface_mesh[stag] = (pts_xyz, tris)
except Exception:
continue
except Exception as _gmsh_err:
print(f"WARNING: GMSH failed ({_gmsh_err}), falling back to BRepMesh",
file=sys.stderr)
BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True)
return
finally:
try:
gmsh.finalize()
except Exception:
pass
try:
Path(brep_path).unlink(missing_ok=True)
except Exception:
pass
builder = BRep_Builder()
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_index = 0
while explorer.More():
face = _TopoDS.Face_s(explorer.Current())
if face_index < len(surface_tags):
stag = surface_tags[face_index]
mesh_data = surface_mesh.get(stag)
if mesh_data:
pts_xyz, tris = mesh_data
n_nodes = len(pts_xyz)
n_tris = len(tris)
try:
arr_pts = TColgp_Array1OfPnt(1, n_nodes)
for idx, (x, y, z) in enumerate(pts_xyz, 1):
arr_pts.SetValue(idx, gp_Pnt(x, y, z))
arr_tris = Poly_Array1OfTriangle(1, n_tris)
for idx, (a, b, c) in enumerate(tris, 1):
arr_tris.SetValue(idx, Poly_Triangle(a, b, c))
tri = Poly_Triangulation(arr_pts, arr_tris)
tri.ComputeNormals() # smooth normals from triangle averaging
builder.UpdateFace(face, tri)
n_faces_gmsh += 1
n_triangles_total += n_tris
except Exception:
BRepMesh_IncrementalMesh(
face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
else:
BRepMesh_IncrementalMesh(
face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
else:
BRepMesh_IncrementalMesh(
face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
face_index += 1
explorer.Next()
print(
f"GMSH tessellation: {n_faces_gmsh} faces meshed, "
f"{n_faces_fallback} BRepMesh fallback, "
f"{n_triangles_total} triangles total"
f" (threads={n_threads})"
)
# ── Index-space sharp edge mapping ──────────────────────────────────────────── # ── Index-space sharp edge mapping ────────────────────────────────────────────
@@ -780,12 +1079,75 @@ def main() -> None:
) )
# ── Tessellate ──────────────────────────────────────────────────────────── # ── Tessellate ────────────────────────────────────────────────────────────
engine = getattr(args, "tessellation_engine", "gmsh")
if engine == "gmsh":
# GMSH: tessellate per-solid (same strategy as export_step_to_gltf.py).
# 1. BRepMesh baseline on full root shape — catches free faces/shells.
# 2. GMSH override per unique SOLID — uniform Frontal-Delaunay topology.
# Skips REVERSED (mirrored) solids to avoid inverted-Jacobian issues.
# Deduplication via IsSame() (TShape pointer), not id() (unreliable in OCP).
from OCP.TopExp import TopExp_Explorer as _Explorer
from OCP.TopAbs import (TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL,
TopAbs_REVERSED as _REVERSED)
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
for i in range(1, free_labels.Length() + 1):
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
if root_shape.IsNull():
continue
# Step 1: BRepMesh baseline
BRepMesh_IncrementalMesh(
root_shape, args.linear_deflection, False, args.angular_deflection, True)
# Step 2: GMSH override for unique SOLID shapes
_seen_shapes: list = []
solids = []
exp = _Explorer(root_shape, _SOLID)
while exp.More():
solids.append(exp.Current())
exp.Next()
if not solids:
exp = _Explorer(root_shape, _SHELL)
while exp.More():
solids.append(exp.Current())
exp.Next()
from OCP.TopoDS import TopoDS_Compound as _Compound
from OCP.BRep import BRep_Builder as _BBuilder
eligible = []
for solid in solids:
if solid.Orientation() == _REVERSED:
continue
if any(solid.IsSame(s) for s in _seen_shapes):
continue
eligible.append(solid.Located(_TopLoc_Location()))
_seen_shapes.append(solid)
if eligible:
try:
if len(eligible) == 1:
_tessellate_with_gmsh(
eligible[0], args.linear_deflection, args.angular_deflection)
else:
compound = _Compound()
bb = _BBuilder()
bb.MakeCompound(compound)
for s in eligible:
bb.Add(compound, s)
_tessellate_with_gmsh(
compound, args.linear_deflection, args.angular_deflection)
except Exception as exc:
print(f"WARNING: GMSH fallback to BRepMesh: {exc}", file=sys.stderr)
print(f"GMSH: {len(eligible)} eligible solids out of {len(solids)} total")
else:
for i in range(1, free_labels.Length() + 1): for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i)) shape = shape_tool.GetShape_s(free_labels.Value(i))
if not shape.IsNull(): if not shape.IsNull():
BRepMesh_IncrementalMesh( BRepMesh_IncrementalMesh(
shape, args.linear_deflection, False, args.angular_deflection, True shape, args.linear_deflection, False, args.angular_deflection, True)
)
print("Tessellation complete.") print("Tessellation complete.")
# ── Apply colors ────────────────────────────────────────────────────── # ── Apply colors ──────────────────────────────────────────────────────
+64 -40
View File
@@ -2,8 +2,9 @@
Runs inside Blender's Python environment (bpy available). Runs inside Blender's Python environment (bpy available).
Imports a USD stage and restores seam + sharp edges from Imports a USD stage and restores seam + sharp edges from
schaeffler:*EdgeVertexPairs primvars (mapped as Blender mesh attributes schaeffler:*EdgeVertexPairs primvars. Blender's built-in USD importer does
by Blender's built-in USD importer). NOT map arbitrary custom primvars (constant Int2Array) to mesh attributes,
so we read them directly via the pxr module and apply via bmesh.
USD stage convention: mm Y-up, metersPerUnit=0.001. USD stage convention: mm Y-up, metersPerUnit=0.001.
Blender's USD importer respects metersPerUnit and scales objects to metres. Blender's USD importer respects metersPerUnit and scales objects to metres.
@@ -21,7 +22,7 @@ def import_usd_file(usd_path: str) -> list | tuple:
Returns a tuple of (parts, material_lookup) where: Returns a tuple of (parts, material_lookup) where:
- parts: list of imported mesh objects, centred at world origin - parts: list of imported mesh objects, centred at world origin
- material_lookup: dict mapping blender_object_name → canonical_material_name - material_lookup: dict mapping blender_object_name → canonical_material_name
(populated from schaeffler:canonicalMaterialName primvars, empty dict if absent) (populated from schaeffler:canonicalMaterialName customData, empty dict if absent)
USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres. USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres.
""" """
@@ -38,22 +39,12 @@ def import_usd_file(usd_path: str) -> list | tuple:
_rename_usd_objects(parts) _rename_usd_objects(parts)
# Restore seam + sharp edges from primvars mapped to Blender mesh attributes. # Read primvars + customData directly via pxr (Blender's USD importer does
# Blender's USD importer converts Int2Array primvars to INT32_2D attributes. # NOT expose custom primvars or customData as Python-accessible properties).
# Attribute names: "schaeffler:sharpEdgeVertexPairs" / "schaeffler:seamEdgeVertexPairs"
restored = 0
for part in parts:
restored += _restore_seam_sharp(part)
if restored:
print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True)
# Extract material lookup via pxr direct read of the USD file.
# Blender's USD importer does NOT expose STRING primvars or customData as
# Python-accessible properties — but the pxr module (available in render-worker)
# can read them perfectly from the same file.
material_lookup: dict[str, str] = {} material_lookup: dict[str, str] = {}
edge_data: dict[str, dict] = {} # part_key → {sharp: [...], seam: [...]}
try: try:
from pxr import Usd, UsdGeom # type: ignore[import] from pxr import Usd, UsdGeom, Vt # type: ignore[import]
stage = Usd.Stage.Open(usd_path) stage = Usd.Stage.Open(usd_path)
for prim in stage.Traverse(): for prim in stage.Traverse():
if prim.GetTypeName() != "Mesh": if prim.GetTypeName() != "Mesh":
@@ -61,16 +52,32 @@ def import_usd_file(usd_path: str) -> list | tuple:
part_key = prim.GetCustomDataByKey("schaeffler:partKey") or "" part_key = prim.GetCustomDataByKey("schaeffler:partKey") or ""
mat_name = prim.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "" mat_name = prim.GetCustomDataByKey("schaeffler:canonicalMaterialName") or ""
if not part_key or not mat_name: if not part_key or not mat_name:
# Also check parent Xform prim (metadata may be on container)
parent = prim.GetParent() parent = prim.GetParent()
if parent: if parent:
part_key = part_key or (parent.GetCustomDataByKey("schaeffler:partKey") or "") part_key = part_key or (parent.GetCustomDataByKey("schaeffler:partKey") or "")
mat_name = mat_name or (parent.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "") mat_name = mat_name or (parent.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "")
if part_key and mat_name: if part_key and mat_name:
# Blender object name = mesh prim leaf name (part_key)
material_lookup[part_key] = mat_name material_lookup[part_key] = mat_name
# Read seam/sharp primvars from USD mesh prim
pvs_api = UsdGeom.PrimvarsAPI(prim)
sharp_pv = pvs_api.GetPrimvar("schaeffler:sharpEdgeVertexPairs")
seam_pv = pvs_api.GetPrimvar("schaeffler:seamEdgeVertexPairs")
sharp_list = []
seam_list = []
if sharp_pv and sharp_pv.HasValue():
raw = sharp_pv.Get()
if raw is not None:
sharp_list = [(int(v[0]), int(v[1])) for v in raw]
if seam_pv and seam_pv.HasValue():
raw = seam_pv.Get()
if raw is not None:
seam_list = [(int(v[0]), int(v[1])) for v in raw]
if sharp_list or seam_list:
# Use part_key as lookup key (matches Blender object name)
edge_data[part_key] = {"sharp": sharp_list, "seam": seam_list}
except Exception as exc: except Exception as exc:
print(f"[import_usd] WARNING: pxr material lookup failed: {exc}", flush=True) print(f"[import_usd] WARNING: pxr read failed: {exc}", flush=True)
if material_lookup: if material_lookup:
print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts", print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts",
@@ -79,6 +86,29 @@ def import_usd_file(usd_path: str) -> list | tuple:
print("[import_usd] no schaeffler:canonicalMaterialName metadata found (legacy USD)", print("[import_usd] no schaeffler:canonicalMaterialName metadata found (legacy USD)",
flush=True) flush=True)
if edge_data:
print(f"[import_usd] pxr edge data: {len(edge_data)} parts with seam/sharp primvars",
flush=True)
# Apply seam + sharp edges to Blender meshes using pxr-read data
restored = 0
for part in parts:
# Match Blender object name to part_key. Blender may add .001 suffixes
# for duplicate names, so try exact match first, then strip suffix.
obj_name = part.name
data = edge_data.get(obj_name)
if data is None:
# Try stripping Blender's .NNN duplicate suffix
base = obj_name.rsplit('.', 1)[0] if '.' in obj_name else obj_name
data = edge_data.get(base)
if data:
restored += _restore_seam_sharp(part, data["sharp"], data["seam"])
if restored:
print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True)
else:
print("[import_usd] no seam/sharp edges restored (no primvar data or no matches)",
flush=True)
# Centre combined bbox at world origin (same as import_glb convention) # Centre combined bbox at world origin (same as import_glb convention)
all_corners = [] all_corners = []
for p in parts: for p in parts:
@@ -114,21 +144,19 @@ def _rename_usd_objects(parts: list) -> None:
flush=True) flush=True)
def _restore_seam_sharp(obj) -> int: def _restore_seam_sharp(obj, sharp_pairs: list, seam_pairs: list) -> int:
"""Apply seam+sharp edges from USD primvars mapped as Blender mesh attributes. """Apply seam+sharp edges from pxr-read primvar data via bmesh.
Blender's USD importer maps primvars:schaeffler:sharpEdgeVertexPairs (Int2Array) sharp_pairs / seam_pairs: list of (v0, v1) vertex index pairs read
to a mesh attribute named "schaeffler:sharpEdgeVertexPairs" with type INT32_2D. from the USD file via pxr.UsdGeom.PrimvarsAPI.
Each attribute element has a .value property returning a 2-tuple (v0, v1).
Returns 1 if any edge data was applied, 0 otherwise. Returns 1 if any edge data was applied, 0 otherwise.
""" """
mesh = obj.data if not sharp_pairs and not seam_pairs:
sharp_attr = mesh.attributes.get("schaeffler:sharpEdgeVertexPairs")
seam_attr = mesh.attributes.get("schaeffler:seamEdgeVertexPairs")
if not sharp_attr and not seam_attr:
return 0 return 0
mesh = obj.data
# Ensure single-user data block before bmesh edit # Ensure single-user data block before bmesh edit
if mesh.users > 1: if mesh.users > 1:
obj.data = mesh.copy() obj.data = mesh.copy()
@@ -140,22 +168,18 @@ def _restore_seam_sharp(obj) -> int:
n_verts = len(bm.verts) n_verts = len(bm.verts)
def _apply_pairs(attr, mark_fn): def _apply_pairs(pairs, mark_fn):
applied = 0 applied = 0
for elem in attr.data: for v0, v1 in pairs:
v = elem.value # 2-tuple for INT32_2D if 0 <= v0 < n_verts and 0 <= v1 < n_verts:
if len(v) >= 2 and 0 <= v[0] < n_verts and 0 <= v[1] < n_verts: edge = bm.edges.get([bm.verts[v0], bm.verts[v1]])
edge = bm.edges.get([bm.verts[v[0]], bm.verts[v[1]]])
if edge: if edge:
mark_fn(edge) mark_fn(edge)
applied += 1 applied += 1
return applied return applied
n_sharp = n_seam = 0 n_sharp = _apply_pairs(sharp_pairs, lambda e: setattr(e, 'smooth', False))
if sharp_attr: n_seam = _apply_pairs(seam_pairs, lambda e: setattr(e, 'seam', True))
n_sharp = _apply_pairs(sharp_attr, lambda e: setattr(e, 'smooth', False))
if seam_attr:
n_seam = _apply_pairs(seam_attr, lambda e: setattr(e, 'seam', True))
bm.to_mesh(mesh) bm.to_mesh(mesh)
bm.free() bm.free()
@@ -163,4 +187,4 @@ def _restore_seam_sharp(obj) -> int:
if n_sharp or n_seam: if n_sharp or n_seam:
print(f"[import_usd] {obj.name}: {n_sharp} sharp edges, {n_seam} seam edges", print(f"[import_usd] {obj.name}: {n_sharp} sharp edges, {n_seam} seam edges",
flush=True) flush=True)
return 1 return 1 if (n_sharp or n_seam) else 0
+31 -89
View File
@@ -1,117 +1,59 @@
# Review Report: PBR Material Extraction for 3D Viewer # Review Report: Draw Call Batching + Merge Dual STEP Parse
Date: 2026-03-13 Date: 2026-03-13
## Result: ⚠️ Minor issues ## Result: ⚠️ Minor issues
## Changes Reviewed ## Problems Found
### PBR Material Extraction Pipeline ### [backend/app/services/step_processor.py:405] `dataclass` import at module middle
**Severity**: Low
**Recommendation**: Move `from dataclasses import dataclass, field` to the top of the file with other stdlib imports. Mid-file imports work but violate PEP 8.
#### Task 1: catalog_assets.py — PBR extraction from Principled BSDF ### [frontend/src/components/cad/useGeometryMerge.ts:6] Unused import `forEachMeshMaterial`
- Extracts `base_color` (linear→sRGB via correct IEC 61966-2-1 formula), `metallic`, `roughness`, `transmission`, `ior` **Severity**: Low
- Fallback to `mat.diffuse_color` when no Principled BSDF node found **Recommendation**: Remove `forEachMeshMaterial` from the import — it's imported but never called.
- Handles both Blender 4.0+ (`Transmission Weight`) and older (`Transmission`) input names
- Verified: 35 materials extracted with correct PBR values
#### Task 2: Catalog refresh ### [frontend/src/components/cad/useGeometryMerge.ts:148] `partMaterials` and `pbrMap` in useEffect deps
- Rebuilt render-worker, triggered refresh via API **Severity**: Medium
- DB updated: `SELECT catalog FROM asset_libraries` shows PBR objects **Recommendation**: `partMaterials` and `pbrMap` are objects that get new references on re-render. The `stateRef.current` early return (line 55) mitigates unnecessary re-merge, BUT the cleanup function (line 141) runs on every deps change, setting `stateRef.current = null` and triggering a full re-merge next render. Consider comparing by stable serialization or using refs. **Not blocking** because `partMaterials` only changes on user save, and the re-merge is fast for typical assemblies.
#### Task 3: GET /api/asset-libraries/pbr-map endpoint ### [backend/app/services/step_processor.py:487] `ReadFile` status unchecked
- Public (no auth) — correct for viewer data **Severity**: Low
- Placed before `/{lib_id}` to avoid UUID collision **Recommendation**: `status` from `reader.ReadFile()` is captured but never validated. Not a regression — same pattern exists at lines 270 and 650 in existing code.
- 1h cache header (`Cache-Control: public, max-age=3600`)
- Handles old string-format catalogs gracefully (skips them)
- Returns empty dict when no active library
#### Task 4: Frontend API types
- `MaterialPBR`, `MaterialPBRMap` types correctly model backend response
- `AssetLibraryCatalog.materials` updated to union type for backwards compat
- `Admin.tsx` and `AssetLibrary.tsx` updated to handle both string and object formats
#### Task 5: cadUtils PBR helpers
- `applyPBRToMaterial()`: sets color, metalness, roughness, approximate transmission via opacity
- `pbrColorHex()`: converts base_color to hex string for UI
- `previewColorForEntry()`: moved from MaterialPanel, now uses dynamic PBR lookup with optional `pbrMap` param
#### Tasks 6-7: ThreeDViewer + InlineCadViewer
- Both fetch PBR map via `useQuery` with 5min staleTime
- Material application clones materials before modifying (prevents shared-instance bugs)
- `_pbrApplied` flag prevents redundant cloning on re-renders
- `pbrMap` added to all dependency arrays
#### Task 8: MaterialPanel
- Hardcoded `SCHAEFFLER_COLORS` (17 entries) removed
- Dynamic PBR lookup covers all 35 materials
- Shows M:/R: values (metallic/roughness) in preview swatch
- Accepts optional `pbrMap` prop (avoids duplicate fetch when parent already has it)
- Falls back to own `useQuery` fetch when prop not provided (enabled: !pbrMapProp)
## Checklist Results ## Checklist Results
### Backend / Python ### Backend / Python
- [x] New endpoint is `async def` (FastAPI handler) - [x] No new endpoints — no role check needed
- [x] No auth on pbr-map (intentional — public non-sensitive data for all viewers) - [x] No SQL injections
- [x] No SQL injection (ORM `select()` only) - [x] Async consistency: `extract_step_metadata` is sync (called from sync Celery tasks)
- [x] No `print()` in production code - [x] Uses `logger` (not `print()`)
- [x] No hardcoded paths - [x] No hardcoded paths
- [x] Fallback preserves existing behavior
- [N/A] No new models or migrations - [N/A] No new models or migrations
- [x] Endpoint registered (same router, already in main.py)
### Frontend / TypeScript ### Frontend / TypeScript
- [x] `npx tsc --noEmit` passes with 0 errors - [x] `tsc --noEmit` passes with 0 errors
- [x] New API types in `frontend/src/api/assetLibraries.ts` - [x] No new API interfaces needed (pure client-side optimization)
- [x] No `as any` for API responses (one intentional `any` for THREE.MeshStandardMaterial in cadUtils to avoid THREE import) - [x] No Tailwind opacity syntax violations
- [x] No `bg-surface/50` Tailwind opacity syntax - [x] Icons from `lucide-react` only
- [x] `useQuery` with staleTime for PBR data
- [x] `SCHAEFFLER_COLORS` export removed — no orphaned references
- [x] All `useEffect`/`useCallback` dependency arrays include `pbrMap`
- [x] Material cloning prevents shared-instance mutation bugs
### Render Pipeline
- [x] `catalog_assets.py` runs on render-worker (Blender headless)
- [x] Existing `refresh_asset_library_catalog` task unchanged — picks up new script
- [x] No references to removed services
### Security ### Security
- [x] No credentials in code - [x] No credentials in code
- [x] No hardcoded tokens - [x] No hardcoded tokens or secrets
- [x] English variable names and comments - [x] English variable names and comments
## Verification
| Gate | Result |
|------|--------|
| `npx tsc --noEmit` | 0 errors ✅ |
| `curl /api/asset-libraries/pbr-map` | 35 materials with PBR ✅ |
| `grep "SCHAEFFLER_COLORS" frontend/src/` | 0 references (only in comment) ✅ |
| `grep "previewColorForEntry" frontend/src/` | 7 matches (all pass pbrMap) ✅ |
| `grep "applyPBRToMaterial" frontend/src/` | 4 matches (definition + 3 usages) ✅ |
## Problems Found
### [cadUtils.ts:137] `applyPBRToMaterial` uses `any` type for mat parameter
**Severity**: Low
**Description**: The `mat` parameter is typed as `any` to avoid importing THREE in the utility module. This is documented with a comment and eslint-disable. The actual callers always pass `THREE.MeshStandardMaterial`.
**Recommendation**: Acceptable tradeoff. Alternative would be importing THREE types, but cadUtils is intentionally dependency-light.
### [InlineCadViewer] No partKey propagation from parent Group in onReady
**Severity**: Medium
**Description**: The InlineCadViewer's `onReady` callback doesn't propagate partKey from parent Group to child Mesh (unlike ThreeDViewer which does this at line ~540). This means `mesh.userData.partKey` may be undefined for child meshes of multi-primitive nodes, and the PBR application falls back to `resolvePartKey(normalizeMeshName(...))` which may not match if partKeyMap is empty.
**Impact**: PBR materials may not apply correctly in InlineCadViewer for products where the partKey was set on the parent Group but not propagated to children. This could explain the user's report of "no material changes visible."
**Recommendation**: Add the same parent-Group partKey propagation logic to InlineCadViewer's onReady callback, matching ThreeDViewer's pattern.
## Positives ## Positives
1. **Correct color space handling**: linear→sRGB conversion uses the IEC 61966-2-1 formula (not simplified gamma), ensuring accurate color reproduction. 1. **Excellent fallback strategy**: Both callers try unified read first, fall back to separate reads on failure. Zero-risk deployment.
2. **Backwards compatible**: Old catalogs (string arrays) are gracefully skipped. Empty partKeyMap returns identity fallback. 2. **Geometry attribute compatibility check** (useGeometryMerge.ts:75-81): Correctly compares attribute sets before merging — prevents crash on mismatched attributes.
3. **Smart caching**: PBR data rarely changes — 5min staleTime + 1h server cache is appropriate. 3. **Clean restore logic**: `_restore()` properly disposes merged geometry and materials, restores visibility and raycast. No memory leaks on toggle-off.
4. **Material cloning**: `_pbrApplied` flag prevents double-cloning on re-renders. 4. **OCC.Core vs OCP dual-import handling**: `_using_ocp` flag with lambda wrappers for `_s` suffix dispatch matches existing codebase pattern.
5. **35 materials covered**: All Schaeffler standard materials now have PBR data (vs 17 hardcoded colors before). 5. **World transform baking** (useGeometryMerge.ts:71-73): Correctly applies `matrixWorld` to cloned geometry before merging.
6. **Principled BSDF fallback**: Materials without the node still get viewport display color. 6. **Cloned geometry disposal** (line 131): Disposes intermediate buffers after merge.
## Recommendation ## Recommendation
Approved with one medium-priority fix needed: InlineCadViewer needs partKey propagation in its onReady callback to ensure PBR materials apply to all meshes. Approved with minor cleanup. The unused import and mid-file import are trivial fixes. The useEffect deps issue is mitigated by existing guards and only affects rapid material reassignment during perfMode — acceptable for now.
Review complete. Result: ⚠️ Review complete. Result: ⚠️