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
@@ -4,7 +4,7 @@ import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
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 { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
@@ -12,6 +12,7 @@ import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
type ViewMode = 'solid' | 'wireframe'
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
@@ -105,7 +106,10 @@ function GlbModelWithFit({
if (obj.geometry) {
let geo = obj.geometry.clone()
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
}
// 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 [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [perfMode, setPerfMode] = useState(false)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
@@ -381,6 +386,15 @@ export default function InlineCadViewer({
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
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
@@ -469,6 +483,13 @@ export default function InlineCadViewer({
</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 */}
{modelReady && (
<>
@@ -542,9 +563,9 @@ export default function InlineCadViewer({
setModelReady(true)
setFitTrigger(t => t + 1)
}}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
onPointerOver={perfMode ? undefined : handlePointerOver}
onPointerOut={perfMode ? undefined : handlePointerOut}
onClick={perfMode ? undefined : handleClick}
/>
</Suspense>
<OrbitControls ref={controlsRef} makeDefault />
+25 -7
View File
@@ -25,7 +25,7 @@ import * as THREE from 'three'
import { toast } from 'sonner'
import {
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff,
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff, Layers,
} from 'lucide-react'
import api from '../../api/client'
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 { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
// ---------------------------------------------------------------------------
// Types
@@ -181,9 +182,9 @@ interface ModelWithReadyProps {
wireframe: boolean
onReady: () => void
sceneRef: React.MutableRefObject<THREE.Object3D | null>
onPointerOver: (e: any) => void
onPointerOut: () => void
onClick: (e: any) => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}
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
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
// Performance mode — merge geometries by material to reduce draw calls
const [perfMode, setPerfMode] = useState(false)
// Refs
const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null)
@@ -689,6 +693,15 @@ export default function ThreeDViewer({
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
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
@@ -770,6 +783,11 @@ export default function ThreeDViewer({
{/* Wireframe */}
<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 */}
<div className="flex rounded-md overflow-hidden border border-gray-700">
{(['Persp', 'Ortho'] as const).map(label => {
@@ -1048,9 +1066,9 @@ export default function ThreeDViewer({
wireframe={wireframe}
onReady={handleModelReady}
sceneRef={sceneRef}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
onPointerOver={perfMode ? undefined : handlePointerOver}
onPointerOut={perfMode ? undefined : handlePointerOut}
onClick={perfMode ? undefined : handleClick}
/>
</Suspense>
</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)
}
}
// ---------------------------------------------------------------------------
// 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
}
}