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