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:
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@ import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMateri
|
||||
import { fetchSceneManifest } from '../../api/sceneManifest'
|
||||
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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -408,6 +408,10 @@ export default function ThreeDViewer({
|
||||
const controlsRef = useRef<any>(null)
|
||||
const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3])
|
||||
|
||||
// MeshRegistry: flat array of {mesh, partKey} built once on model load.
|
||||
// Replaces per-effect scene.traverse() calls with direct iteration.
|
||||
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
|
||||
|
||||
// Dimension data from parsed_objects
|
||||
const { data: parsedData } = useQuery({
|
||||
queryKey: ['cad-parsed-objects', cadFileId],
|
||||
@@ -526,8 +530,8 @@ export default function ThreeDViewer({
|
||||
if (modelReady) setFitTrigger(t => t + 1)
|
||||
}, [modelReady])
|
||||
|
||||
// Compute unique mesh keys once (used in toolbar badge + assignedCount).
|
||||
// Also extract partKeyMap from GLB extras when available.
|
||||
// Build MeshRegistry + extract partKeyMap from GLB extras (runs once on model load).
|
||||
// Single traverse replaces what was previously 2 separate traversals.
|
||||
useEffect(() => {
|
||||
if (!modelReady || !sceneRef.current) return
|
||||
|
||||
@@ -536,32 +540,33 @@ export default function ThreeDViewer({
|
||||
const map = glbExtras.partKeyMap as Record<string, string> | undefined
|
||||
if (map && Object.keys(map).length > 0) {
|
||||
setPartKeyMap(map)
|
||||
// Stamp userData.partKey on every mesh. Three.js splits multi-primitive
|
||||
// GLB nodes into Group + child Meshes — the partKey extras land on the
|
||||
// parent Group, not on individual Mesh objects. We propagate it down.
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
if (obj.userData.partKey) return // already set by GLB node extras
|
||||
// Check parent Group (Three.js multi-primitive split)
|
||||
const parentPk = obj.parent?.userData?.partKey as string | undefined
|
||||
if (parentPk) { obj.userData.partKey = parentPk; return }
|
||||
// Fallback: lookup in partKeyMap by normalized name
|
||||
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 (deduplicated across multi-primitive splits)
|
||||
// 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 userData.partKey (propagate from parent Group for multi-primitive GLB nodes)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve partKey for this mesh
|
||||
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))
|
||||
}, [modelReady])
|
||||
@@ -571,59 +576,48 @@ export default function ThreeDViewer({
|
||||
if (modelReady) setFitTrigger(t => t + 1)
|
||||
}, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Task 6 — 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
|
||||
if (!modelReady || meshRegistryRef.current.length === 0) return
|
||||
// Skip when pbrMap hasn't loaded yet — avoid setting grey fallback prematurely
|
||||
if (Object.keys(pbrMap).length === 0) return
|
||||
sceneRef.current.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
|
||||
if (!entry) return
|
||||
for (const { mesh, partKey } of meshRegistryRef.current) {
|
||||
const entry = resolvePartMaterial(partKey, effectiveMaterials)
|
||||
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, effectiveMaterials, resolvePartKey, pbrMap])
|
||||
|
||||
// Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast)
|
||||
// Apply/remove unassigned highlight — uses MeshRegistry instead of traverse
|
||||
useEffect(() => {
|
||||
if (!modelReady || !sceneRef.current) return
|
||||
if (!modelReady || meshRegistryRef.current.length === 0) return
|
||||
const hasAnyAssignment = Object.keys(effectiveMaterials).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((mat) => {
|
||||
const m = mat as THREE.MeshStandardMaterial
|
||||
if (!m || !('emissive' in m)) return
|
||||
for (const { mesh, partKey } of meshRegistryRef.current) {
|
||||
forEachMeshMaterial(mesh, (mat) => {
|
||||
if (!('emissive' in mat)) return
|
||||
if (showUnassigned && hasAnyAssignment) {
|
||||
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
const hasAssignment = !!resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
|
||||
m.emissive.set(hasAssignment ? 0x000000 : 0xff4400)
|
||||
m.emissiveIntensity = hasAssignment ? 0 : 0.8
|
||||
const hasAssignment = !!resolvePartMaterial(partKey, effectiveMaterials)
|
||||
mat.emissive.set(hasAssignment ? 0x000000 : 0xff4400)
|
||||
mat.emissiveIntensity = hasAssignment ? 0 : 0.8
|
||||
} else {
|
||||
m.emissive.set(0x000000)
|
||||
m.emissiveIntensity = 0
|
||||
mat.emissive.set(0x000000)
|
||||
mat.emissiveIntensity = 0
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey])
|
||||
|
||||
// Reset isolateMode when no part is pinned
|
||||
@@ -636,46 +630,39 @@ export default function ThreeDViewer({
|
||||
if (Object.keys(effectiveMaterials).length === 0) setHideAssigned(false)
|
||||
}, [effectiveMaterials])
|
||||
|
||||
// Combined visibility effect — handles hideAssigned + isolateMode together
|
||||
// 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 normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
const partKey = resolvePartKey(normalizedName)
|
||||
if (!modelReady || meshRegistryRef.current.length === 0) return
|
||||
for (const { mesh, partKey } of meshRegistryRef.current) {
|
||||
const isSelected = partKey === pinnedPart
|
||||
const isAssigned = !!resolvePartMaterial(partKey, effectiveMaterials)
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
// 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, effectiveMaterials, resolvePartKey])
|
||||
|
||||
// Keyboard shortcuts
|
||||
|
||||
@@ -172,3 +172,27 @@ export function previewColorForEntry(
|
||||
}
|
||||
return '#888888'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MeshRegistry — O(1) access to meshes by partKey, replaces O(n) traversals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single entry in the mesh registry, linking a Three.js mesh to its partKey. */
|
||||
export interface MeshRegistryEntry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mesh: any // THREE.Mesh — typed as any to avoid importing THREE
|
||||
partKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all materials on a mesh, calling `fn` for each MeshStandardMaterial.
|
||||
* Handles both single and array materials safely.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function forEachMeshMaterial(mesh: any, fn: (mat: any) => void): void {
|
||||
if (!mesh?.material) return
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
for (const m of mats) {
|
||||
if (m && 'color' in m) fn(m)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user