feat: performance optimizations + part-materials validation

- @timed_step decorator with wall-clock + RSS tracking (pipeline_logger)
- Blender timing laps for sharp edges and material assignment
- MeshRegistry pattern: eliminate 13 scene.traverse() calls across viewers
- Lazy material cloning (clone-on-first-write in both viewers)
- _pipeline_session context manager: 7 create_engine() → 2 in render_thumbnail
- KD-tree spatial pre-filter for sharp edge marking (bbox-based pruning)
- Batch material library append: N bpy.ops.wm.append → single bpy.data.libraries.load
- GMSH single-session batching: compound all solids into one tessellation call
- Validate part-materials save endpoints against parsed_objects (prevents bogus keys)
- ROADMAP updated with completion status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 11:53:14 +01:00
parent ec667dd56a
commit 6c5873d51f
11 changed files with 612 additions and 541 deletions
+50 -69
View File
@@ -10,7 +10,7 @@ import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
type ViewMode = 'solid' | 'wireframe'
@@ -196,6 +196,7 @@ export default function InlineCadViewer({
const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
// Media asset queries
const { data: gltfAssets } = useQuery({
@@ -265,51 +266,39 @@ export default function InlineCadViewer({
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [activeDownloadUrl, token])
// Apply saved material colors + PBR properties after model loads
// Apply saved material colors + PBR properties after model loads (uses MeshRegistry)
useEffect(() => {
if (!modelReady || !sceneRef.current) return
// Wait for PBR map to load — avoids setting grey fallback prematurely
if (!modelReady || meshRegistryRef.current.length === 0) return
if (Object.keys(pbrMap).length === 0) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const entry = resolvePartMaterial(pk, partMaterials as PartMaterialMap)
if (!entry) return
for (const { mesh, partKey } of meshRegistryRef.current) {
const entry = resolvePartMaterial(partKey, partMaterials as PartMaterialMap)
if (!entry) continue
// Clone materials on first PBR application (GLB loader shares instances)
if (!mesh.userData._pbrApplied) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map(m => m.clone())
? mesh.material.map((m: THREE.Material) => m.clone())
: mesh.material.clone()
mesh.userData._pbrApplied = true
}
const clonedMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
clonedMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('color' in mat)) return
forEachMeshMaterial(mesh, (mat) => {
if (entry.type === 'library' && pbrMap[entry.value]) {
applyPBRToMaterial(mat, pbrMap[entry.value])
} else {
mat.color.set(previewColorForEntry(entry, pbrMap))
}
})
})
}
}, [modelReady, partMaterials, resolvePartKey, pbrMap])
// Unassigned glow — only when at least one assignment exists
// Unassigned glow — uses MeshRegistry instead of traverse
useEffect(() => {
if (!modelReady || !sceneRef.current) return
if (!modelReady || meshRegistryRef.current.length === 0) return
const hasAnyAssignment = Object.keys(partMaterials).length > 0
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
for (const { mesh, partKey } of meshRegistryRef.current) {
forEachMeshMaterial(mesh, (mat) => {
if (!('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment) {
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const assigned = !!resolvePartMaterial(pk, partMaterials as PartMaterialMap)
const assigned = !!resolvePartMaterial(partKey, partMaterials as PartMaterialMap)
mat.emissive.set(assigned ? 0x000000 : 0xff4400)
mat.emissiveIntensity = assigned ? 0 : 0.8
} else {
@@ -317,7 +306,7 @@ export default function InlineCadViewer({
mat.emissiveIntensity = 0
}
})
})
}
}, [modelReady, showUnassigned, partMaterials, resolvePartKey])
// Reset isolateMode when no part is pinned
@@ -330,54 +319,45 @@ export default function InlineCadViewer({
if (Object.keys(partMaterials).length === 0) setHideAssigned(false)
}, [partMaterials])
// Combined visibility effect — handles hideAssigned + isolateMode together to avoid conflicts
// Combined visibility effect — uses MeshRegistry instead of traverse
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const isSelected = pk === pinnedPart
const isAssigned = !!resolvePartMaterial(pk, partMaterials)
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
if (!modelReady || meshRegistryRef.current.length === 0) return
for (const { mesh, partKey } of meshRegistryRef.current) {
const isSelected = partKey === pinnedPart
const isAssigned = !!resolvePartMaterial(partKey, partMaterials)
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
// hideAssigned: hide all assigned meshes (except the currently selected part)
if (hideAssigned && isAssigned && !isSelected) {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
return
mesh.raycast = () => {}
continue
}
// isolateMode: ghost or hide non-selected meshes when a part is pinned
if (!isSelected && pinnedPart && isolateMode !== 'none') {
if (isolateMode === 'hide') {
mesh.visible = false
mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets
mesh.raycast = () => {}
} else {
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true }
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true }
})
}
}
})
}
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
if (!import.meta.env.DEV || !modelReady || !sceneRef.current) return
const names = new Set<string>()
sceneRef.current.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
})
if (!import.meta.env.DEV || !modelReady || meshRegistryRef.current.length === 0) return
const names = new Set<string>(meshRegistryRef.current.map(e => e.partKey))
const keys = Object.keys(partMaterials)
const matched = keys.filter(k => names.has(k))
const unmatched = keys.filter(k => !names.has(k))
@@ -535,27 +515,28 @@ export default function InlineCadViewer({
const map = glbExtras.partKeyMap as Record<string, string> | undefined
if (map && Object.keys(map).length > 0) {
setPartKeyMap(map)
// Propagate partKey from parent Group to child Meshes
sceneRef.current?.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
if (obj.userData.partKey) return
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) { obj.userData.partKey = parentPk; return }
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
const pk = map[normalized] ?? normalized
if (pk) obj.userData.partKey = pk
})
}
// Count unique parts by partKey
// Single traverse: stamp partKey, build registry, count unique parts
const registry: MeshRegistryEntry[] = []
const names = new Set<string>()
sceneRef.current?.traverse(o => {
if ((o as THREE.Mesh).isMesh) {
const pk = o.userData?.partKey as string | undefined
if (pk) { names.add(pk); return }
const normalized = normalizeMeshName((o.userData?.name as string) || o.name)
if (normalized) names.add(map?.[normalized] ?? normalized)
sceneRef.current?.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
// Stamp partKey from parent Group or partKeyMap
if (!obj.userData.partKey) {
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) {
obj.userData.partKey = parentPk
} else if (map) {
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
obj.userData.partKey = map[normalized] ?? normalized
}
}
const pk = (obj.userData?.partKey as string) ||
normalizeMeshName((obj.userData?.name as string) || obj.name)
registry.push({ mesh: obj, partKey: pk })
if (pk) names.add(pk)
})
meshRegistryRef.current = registry
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
setModelReady(true)