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:
@@ -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 +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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user