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 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,12 +113,20 @@ 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}")
|
||||||
|
|
||||||
objects = _extract_step_objects(step_path)
|
# Try unified single-read first, fall back to separate reads
|
||||||
cad_file.parsed_objects = {"objects": objects}
|
metadata = extract_step_metadata(str(step_path))
|
||||||
|
if metadata.objects:
|
||||||
edge_data = extract_mesh_edge_data(str(step_path))
|
objects = metadata.objects
|
||||||
if edge_data:
|
cad_file.parsed_objects = {"objects": objects}
|
||||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data}
|
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)
|
gltf_path = _convert_to_gltf(step_path, cad_file_id, settings.upload_dir)
|
||||||
if gltf_path:
|
if gltf_path:
|
||||||
@@ -164,14 +173,20 @@ 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)
|
||||||
objects = _extract_step_objects(step_path)
|
metadata = extract_step_metadata(str(step_path))
|
||||||
cad_file.parsed_objects = {"objects": objects}
|
if metadata.objects:
|
||||||
|
objects = metadata.objects
|
||||||
# Step 1b: Extract sharp-edge topology data and merge into mesh_attributes
|
cad_file.parsed_objects = {"objects": objects}
|
||||||
edge_data = extract_mesh_edge_data(str(step_path))
|
if metadata.edge_data:
|
||||||
if edge_data:
|
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **metadata.edge_data}
|
||||||
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **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
|
# Step 2: Generate thumbnail — pass empty part_colors so the Three.js
|
||||||
# renderer extracts named parts and auto-assigns palette colours.
|
# renderer extracts named parts and auto-assigns palette colours.
|
||||||
@@ -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 +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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
for i in range(1, free_labels.Length() + 1):
|
engine = getattr(args, "tessellation_engine", "gmsh")
|
||||||
shape = shape_tool.GetShape_s(free_labels.Value(i))
|
if engine == "gmsh":
|
||||||
if not shape.IsNull():
|
# 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(
|
BRepMesh_IncrementalMesh(
|
||||||
shape, args.linear_deflection, False, args.angular_deflection, True
|
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):
|
||||||
|
shape = shape_tool.GetShape_s(free_labels.Value(i))
|
||||||
|
if not shape.IsNull():
|
||||||
|
BRepMesh_IncrementalMesh(
|
||||||
|
shape, args.linear_deflection, False, args.angular_deflection, True)
|
||||||
print("Tessellation complete.")
|
print("Tessellation complete.")
|
||||||
|
|
||||||
# ── Apply colors ──────────────────────────────────────────────────────
|
# ── Apply colors ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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: ⚠️
|
||||||
|
|||||||
Reference in New Issue
Block a user