feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
+426 -67
View File
@@ -1,14 +1,16 @@
import { Suspense, useEffect, useRef, useState } from 'react'
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas } from '@react-three/fiber'
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, Cpu } from 'lucide-react'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu, AlertCircle, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
type ViewMode = 'solid' | 'wireframe'
type GlbSource = 'geometry' | 'production'
@@ -22,26 +24,96 @@ const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
{ id: 'city', label: 'City' },
]
function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
// ---------------------------------------------------------------------------
// CameraAutoFit — auto-fits camera to model bounding box on first load
// ---------------------------------------------------------------------------
function CameraAutoFit({
sceneRef,
controlsRef,
fitTrigger,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
}) {
const { camera, size } = useThree()
useEffect(() => {
if (fitTrigger === 0 || !sceneRef.current) return
const box = new THREE.Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const pc = camera as THREE.PerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
const dist = (maxDim / 2) / Math.tan(Math.min(fovRad, fovH) / 2) * 1.6
camera.position.set(center.x + maxDim * 0.05, center.y + maxDim * 0.2, center.z + dist)
camera.near = maxDim * 0.001
camera.far = maxDim * 100
camera.updateProjectionMatrix()
camera.lookAt(center)
if (controlsRef.current) {
controlsRef.current.target.copy(center)
controlsRef.current.minDistance = maxDim * 0.05
controlsRef.current.maxDistance = maxDim * 20
controlsRef.current.update()
}
}, [fitTrigger]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
// ---------------------------------------------------------------------------
// GlbModelWithFit — loads GLB, stores scene ref, signals ready, pointer events
// ---------------------------------------------------------------------------
function GlbModelWithFit({
url,
wireframe,
sceneRef,
onReady,
onPointerOver,
onPointerOut,
onClick,
}: {
url: string
wireframe: boolean
sceneRef: React.MutableRefObject<THREE.Object3D | null>
onReady: () => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}) {
const { scene } = useGLTF(url)
const cloned = useRef<THREE.Group | null>(null)
if (!cloned.current) {
cloned.current = scene.clone(true)
cloned.current.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) {
// Non-indexed geometry: each triangle has unique vertices,
// so computeVertexNormals() would give per-face normals (flat shading).
// mergeVertices() creates an indexed geometry with shared vertices first,
// so the subsequent normal computation averages across adjacent faces → smooth.
geo = mergeVertices(geo)
if (obj instanceof THREE.Mesh) {
if (obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo)
geo.computeVertexNormals()
obj.geometry = geo
}
// Clone materials so emissive / color changes don't affect the shared GLTF cache
if (obj.material) {
obj.material = Array.isArray(obj.material)
? obj.material.map((m: THREE.Material) => m.clone())
: obj.material.clone()
}
// For indexed geometry (Blender GLB): normals are already baked smooth by Blender.
// Recomputing here still works correctly because shared vertices average properly.
geo.computeVertexNormals()
obj.geometry = geo
}
})
}
@@ -58,10 +130,22 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
})
}, [wireframe])
return <primitive object={cloned.current} />
useEffect(() => {
sceneRef.current = cloned.current
onReady()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return (
<primitive
object={cloned.current}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
onClick={onClick}
/>
)
}
const HEIGHT = 420
const HEIGHT = 560
function ToolbarBtn({
active, onClick, children, title,
@@ -70,7 +154,7 @@ function ToolbarBtn({
<button
title={title}
onClick={onClick}
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors ${
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors rounded ${
active ? 'bg-white/20 text-white' : 'text-white/50 hover:text-white/80 hover:bg-white/10'
}`}
>
@@ -82,19 +166,38 @@ function ToolbarBtn({
export default function InlineCadViewer({
cadFileId,
thumbnailUrl,
initialPartMaterials,
}: {
cadFileId: string
thumbnailUrl?: string | null
initialPartMaterials?: PartMaterialMap
}) {
const token = useAuthStore((s) => s.token)
const qc = useQueryClient()
// GLB source / display state
const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null)
const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const [modelReady, setModelReady] = useState(false)
const [fitTrigger, setFitTrigger] = useState(0)
// Material assignment state
const [pinnedPart, setPinnedPart] = useState<string | null>(null)
const [showUnassigned, setShowUnassigned] = useState(false)
const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
// Media asset queries
const { data: gltfAssets } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_geometry'] }),
@@ -108,14 +211,33 @@ export default function InlineCadViewer({
staleTime: 0,
})
// Part-material assignments — from CadFile (manual assignments in viewer)
const { data: savedPartMaterials = {} } = useQuery({
queryKey: ['part-materials', cadFileId],
queryFn: () => getPartMaterials(cadFileId),
staleTime: 30_000,
retry: false,
})
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials],
)
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
)
useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
}, [generating, gltfAssets])
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasProduction = (productionAssets?.length ?? 0) > 0
// Auto-switch to production if it's the only available source
useEffect(() => {
if (!hasGeometry && hasProduction) setGlbSource('production')
}, [hasGeometry, hasProduction])
@@ -125,9 +247,11 @@ export default function InlineCadViewer({
? productionAssets?.[0]?.download_url
: gltfAssets?.[0]?.download_url
// Fetch active GLB as blob URL (needs auth header)
useEffect(() => {
if (!activeDownloadUrl || !token) return
setGlbBlobUrl(null)
setModelReady(false)
setLoadingGlb(true)
let blobUrl = ''
fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } })
@@ -138,11 +262,119 @@ export default function InlineCadViewer({
})
.catch(() => toast.error('Failed to load 3D model'))
.finally(() => setLoadingGlb(false))
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [activeDownloadUrl, token])
// Apply saved material colors after model loads or when assignments change
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const entry = resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
})
})
}, [modelReady, partMaterials])
// Unassigned glow — only when at least one assignment exists
useEffect(() => {
if (!modelReady || !sceneRef.current) 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
if (showUnassigned && hasAnyAssignment) {
const assigned = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
mat.emissive.set(assigned ? 0x000000 : 0xff4400)
mat.emissiveIntensity = assigned ? 0 : 0.8
} else {
mat.emissive.set(0x000000)
mat.emissiveIntensity = 0
}
})
})
}, [modelReady, showUnassigned, partMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
if (!pinnedPart) setIsolateMode('none')
}, [pinnedPart])
// Reset hideAssigned when all assignments are cleared
useEffect(() => {
if (Object.keys(partMaterials).length === 0) setHideAssigned(false)
}, [partMaterials])
// Combined visibility effect — handles hideAssigned + isolateMode together to avoid conflicts
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 isSelected = normalizedName === pinnedPart
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials)
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 }
})
// 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
}
// 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
} 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 }
})
}
}
})
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// 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))
})
const keys = Object.keys(partMaterials)
const matched = keys.filter(k => names.has(k))
const unmatched = keys.filter(k => !names.has(k))
console.debug('[CAD] Match status:', {
totalGlbMeshes: names.size,
totalStoredKeys: keys.length,
matched: matched.length,
unmatched: unmatched.length,
unmatchedKeys: unmatched,
glbNames: [...names].sort(),
})
}, [modelReady, partMaterials])
const generateMut = useMutation({
mutationFn: () => generateGltfGeometry(cadFileId),
onSuccess: () => {
@@ -153,63 +385,188 @@ export default function InlineCadViewer({
onError: () => toast.error('Failed to queue GLB generation'),
})
if (glbBlobUrl) {
return (
<div className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950 relative" style={{ height: HEIGHT }}>
<Canvas camera={{ position: [0, 0, 2], fov: 45 }}>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModel key={glbBlobUrl} url={glbBlobUrl} wireframe={viewMode === 'wireframe'} />
</Suspense>
<OrbitControls makeDefault />
</Canvas>
// Hover highlight
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
// Restore previous hovered mesh (correctly preserve unassigned glow)
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
const prev = hoveredMeshRef.current
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
const hasAny = Object.keys(partMaterials).length > 0
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAny && !resolvePartMaterial(normalizeMeshName((prev.userData?.name as string) || prev.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
}
hoveredMeshRef.current = mesh
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, partMaterials])
{/* Toolbar */}
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
{/* Geometry / Production toggle — only when both exist */}
const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) {
const mesh = hoveredMeshRef.current
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
}
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials])
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const meshObj = e.object as THREE.Mesh
const name = normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '')
if (name) setPinnedPart(name)
}, [])
// ── Render: model loaded ──────────────────────────────────────────────────
if (glbBlobUrl) {
const pm = partMaterials as PartMaterialMap
return (
<div
className="w-full rounded-lg border border-border-default bg-gray-950 flex flex-col overflow-hidden"
style={{ height: HEIGHT }}
onClick={() => setPinnedPart(null)}
>
{/* ── Toolbar row — real block element above the canvas ── */}
<div
className="shrink-0 flex items-center gap-0.5 px-2 py-1 bg-black/70 border-b border-white/10 flex-wrap"
onClick={(e) => e.stopPropagation()}
>
{/* Geo / PBR toggle */}
{hasGeometry && hasProduction && (
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC, no materials)">
<Box size={12} /> Geo
<>
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC)">
<Box size={11} /> Geo
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender + PBR materials)">
<Cpu size={12} /> PBR
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender PBR)">
<Cpu size={11} /> PBR
</ToolbarBtn>
</div>
<div className="w-px h-4 bg-white/10 mx-0.5" />
</>
)}
{/* View mode */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={12} /> Solid
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={12} /> Wire
</ToolbarBtn>
</div>
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={11} /> Solid
</ToolbarBtn>
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
<Grid3X3 size={11} /> Wire
</ToolbarBtn>
{/* Lighting presets */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<span className="px-2 py-1 text-[11px] text-white/30 flex items-center">
<Sun size={11} />
</span>
<div className="w-px bg-white/10" />
{LIGHT_PRESETS.map((p, i) => (
<div key={p.id} className="flex">
{i > 0 && <div className="w-px bg-white/10" />}
<ToolbarBtn active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
<div className="w-px h-4 bg-white/10 mx-0.5" />
{/* Lighting */}
<Sun size={11} className="text-white/30 mx-1" />
{LIGHT_PRESETS.map((p) => (
<ToolbarBtn key={p.id} active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
{p.label}
</ToolbarBtn>
))}
{/* Show unassigned + hide assigned toggles */}
{modelReady && (
<>
<div className="w-px h-4 bg-white/10 mx-0.5" />
<ToolbarBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
>
<AlertCircle size={11} />
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
</ToolbarBtn>
{assignedCount > 0 && (
<ToolbarBtn
active={hideAssigned}
onClick={() => setHideAssigned(v => !v)}
title="Hide parts that already have a material assigned"
>
<EyeOff size={11} />
<span className="text-[10px]">Hide assigned</span>
</ToolbarBtn>
</div>
))}
)}
</>
)}
</div>
{/* ── Canvas area ── */}
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
<Canvas
gl={{ powerPreference: 'high-performance', antialias: true }}
dpr={[1, 1.5]}
camera={{ position: [0, 0, 2], fov: 45 }}
>
<Suspense fallback={null}>
<Environment preset={lightPreset} background={false} />
<GlbModelWithFit
key={glbBlobUrl}
url={glbBlobUrl}
wireframe={viewMode === 'wireframe'}
sceneRef={sceneRef}
onReady={() => {
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))
})
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
setModelReady(true)
setFitTrigger(t => t + 1)
}}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
/>
</Suspense>
<OrbitControls ref={controlsRef} makeDefault />
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
</Canvas>
{/* Material assignment panel */}
{pinnedPart && (
<MaterialPanel
partName={pinnedPart}
cadFileId={cadFileId}
currentEntry={resolvePartMaterial(pinnedPart, pm)}
partMaterials={pm}
onClose={() => setPinnedPart(null)}
isolateMode={isolateMode}
onIsolateModeChange={setIsolateMode}
/>
)}
{/* Hint */}
<div className="absolute bottom-1.5 right-2 text-gray-600 text-[10px] pointer-events-none select-none">
click part to assign material
</div>
</div>
</div>
)
}
// ── Render: loading ───────────────────────────────────────────────────────
if (loadingGlb) {
return (
<div
@@ -224,6 +581,8 @@ export default function InlineCadViewer({
)
}
// ── Render: no GLB yet ────────────────────────────────────────────────────
return (
<div
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3"