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 />