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:
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import {
|
||||
listGlobalRenderPositions,
|
||||
createGlobalRenderPosition,
|
||||
updateGlobalRenderPosition,
|
||||
deleteGlobalRenderPosition,
|
||||
type GlobalRenderPosition,
|
||||
type GlobalRenderPositionCreate,
|
||||
} from '../../api/renderPositions'
|
||||
|
||||
interface EditState {
|
||||
id: string | null
|
||||
name: string
|
||||
rotation_x: number
|
||||
rotation_y: number
|
||||
rotation_z: number
|
||||
is_default: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const EMPTY_EDIT: EditState = {
|
||||
id: null,
|
||||
name: '',
|
||||
rotation_x: 0,
|
||||
rotation_y: 0,
|
||||
rotation_z: 0,
|
||||
is_default: false,
|
||||
sort_order: 0,
|
||||
}
|
||||
|
||||
export default function GlobalRenderPositionsPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState<EditState | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const { data: positions = [], isLoading } = useQuery({
|
||||
queryKey: ['global-render-positions'],
|
||||
queryFn: listGlobalRenderPositions,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Partial<GlobalRenderPositionCreate> }) =>
|
||||
updateGlobalRenderPosition(id, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setEditing(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteGlobalRenderPosition(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-render-positions'] }),
|
||||
})
|
||||
|
||||
function startEdit(pos: GlobalRenderPosition) {
|
||||
setAdding(false)
|
||||
setEditing({
|
||||
id: pos.id,
|
||||
name: pos.name,
|
||||
rotation_x: pos.rotation_x,
|
||||
rotation_y: pos.rotation_y,
|
||||
rotation_z: pos.rotation_z,
|
||||
is_default: pos.is_default,
|
||||
sort_order: pos.sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editing) return
|
||||
if (editing.id) {
|
||||
const { id, ...body } = editing
|
||||
updateMut.mutate({ id, body })
|
||||
}
|
||||
}
|
||||
|
||||
function saveNew() {
|
||||
if (!editing) return
|
||||
const { id, ...body } = editing
|
||||
createMut.mutate(body)
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
setEditing({ ...EMPTY_EDIT, sort_order: positions.length })
|
||||
setAdding(true)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(null)
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
|
||||
if (!editing) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-xs text-content-muted">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="5"
|
||||
className="input w-20 text-sm"
|
||||
value={editing[field]}
|
||||
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-sm text-content-muted">Loading…</p>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-content-muted">
|
||||
Global camera rotation presets applied to all products. Per-product positions take priority.
|
||||
</p>
|
||||
<button className="btn btn-sm btn-primary flex items-center gap-1" onClick={startAdd}>
|
||||
<Plus size={14} /> Add position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light text-left text-xs text-content-muted">
|
||||
<th className="pb-1 pr-3">Name</th>
|
||||
<th className="pb-1 pr-3 text-center">Rot X°</th>
|
||||
<th className="pb-1 pr-3 text-center">Rot Y°</th>
|
||||
<th className="pb-1 pr-3 text-center">Rot Z°</th>
|
||||
<th className="pb-1 pr-3 text-center">Default</th>
|
||||
<th className="pb-1 pr-3 text-center">Order</th>
|
||||
<th className="pb-1" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => {
|
||||
const isEditingThis = editing && editing.id === pos.id
|
||||
return (
|
||||
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
|
||||
{isEditingThis ? (
|
||||
<>
|
||||
<td className="py-1 pr-2">
|
||||
<input
|
||||
className="input w-32 text-sm"
|
||||
value={editing!.name}
|
||||
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing!.is_default}
|
||||
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
className="input w-14 text-sm"
|
||||
value={editing!.sort_order}
|
||||
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 flex items-center gap-1">
|
||||
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
{pos.is_default && <span className="text-accent text-xs font-medium">✓</span>}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
|
||||
<td className="py-1.5 flex items-center gap-1">
|
||||
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
|
||||
<button
|
||||
className="btn btn-xs text-red-500"
|
||||
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* New row */}
|
||||
{adding && editing && (
|
||||
<tr className="border-b border-border-light bg-surface-alt/20">
|
||||
<td className="py-1 pr-2">
|
||||
<input
|
||||
className="input w-32 text-sm"
|
||||
placeholder="Name"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.is_default}
|
||||
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
className="input w-14 text-sm"
|
||||
value={editing.sort_order}
|
||||
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1 flex items-center gap-1">
|
||||
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../../api/client'
|
||||
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SCHAEFFLER_COLORS: Record<string, string> = {
|
||||
'SCHAEFFLER_010101_Steel-Bare': '#8a9ca8',
|
||||
'SCHAEFFLER_010102_Steel-Polished': '#b0c4ce',
|
||||
'SCHAEFFLER_010103_Steel-Brushed': '#9aabb5',
|
||||
'SCHAEFFLER_010104_Steel-Painted': '#607080',
|
||||
'SCHAEFFLER_010201_Stainless-Bare': '#adb9bf',
|
||||
'SCHAEFFLER_010202_Stainless-Polished': '#cdd8dc',
|
||||
'SCHAEFFLER_010301_Iron-Cast': '#696969',
|
||||
'SCHAEFFLER_020101_Aluminium-Bare': '#c8c8c8',
|
||||
'SCHAEFFLER_020102_Aluminium-Anodized': '#b0b8c0',
|
||||
'SCHAEFFLER_030101_Brass': '#c9a84c',
|
||||
'SCHAEFFLER_030201_Bronze': '#a07040',
|
||||
'SCHAEFFLER_040101_Copper': '#b87333',
|
||||
'SCHAEFFLER_050101_Plastic-Black': '#202020',
|
||||
'SCHAEFFLER_050102_Plastic-White': '#f0f0f0',
|
||||
'SCHAEFFLER_050201_Rubber-Black': '#1a1a1a',
|
||||
'SCHAEFFLER_060101_Ceramic': '#e8dcc8',
|
||||
'SCHAEFFLER_070101_Glass': '#88bbcc',
|
||||
}
|
||||
|
||||
export function previewColorForEntry(entry: PartMaterialEntry): string {
|
||||
if (entry.type === 'hex') return entry.value
|
||||
return SCHAEFFLER_COLORS[entry.value] ?? '#888888'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MaterialOut — matches GET /api/materials response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MaterialOut {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
schaeffler_code: number | null
|
||||
source: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MaterialPanel — floating panel for assigning a material/color to a part
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type IsolateMode = 'none' | 'ghost' | 'hide'
|
||||
|
||||
export interface MaterialPanelProps {
|
||||
partName: string
|
||||
cadFileId: string
|
||||
currentEntry: PartMaterialEntry | undefined
|
||||
partMaterials: PartMaterialMap
|
||||
onClose: () => void
|
||||
isolateMode?: IsolateMode
|
||||
onIsolateModeChange?: (mode: IsolateMode) => void
|
||||
}
|
||||
|
||||
export default function MaterialPanel({
|
||||
partName,
|
||||
cadFileId,
|
||||
currentEntry,
|
||||
partMaterials,
|
||||
onClose,
|
||||
isolateMode = 'none',
|
||||
onIsolateModeChange,
|
||||
}: MaterialPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch all tenant materials (no filter — user sees their full library)
|
||||
const { data: allMaterials = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<MaterialOut[]>('/materials')
|
||||
return res.data
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const [assignType, setAssignType] = useState<'library' | 'hex'>(
|
||||
currentEntry?.type ?? 'library',
|
||||
)
|
||||
const [hexValue, setHexValue] = useState(
|
||||
currentEntry?.type === 'hex' ? currentEntry.value : '#888888',
|
||||
)
|
||||
const [libValue, setLibValue] = useState(
|
||||
currentEntry?.type === 'library'
|
||||
? currentEntry.value
|
||||
: (allMaterials[0]?.name ?? ''),
|
||||
)
|
||||
|
||||
// Set default library value once materials load
|
||||
useEffect(() => {
|
||||
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
|
||||
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
|
||||
toast.success(`Material assigned to "${partName}"`)
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.error('Failed to save material assignment'),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
|
||||
toast.success(`Assignment removed from "${partName}"`)
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.error('Failed to remove assignment'),
|
||||
})
|
||||
|
||||
function handleAssign() {
|
||||
const entry: PartMaterialEntry =
|
||||
assignType === 'hex'
|
||||
? { type: 'hex', value: hexValue }
|
||||
: { type: 'library', value: libValue }
|
||||
saveMut.mutate({ ...partMaterials, [partName]: entry })
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
const updated = { ...partMaterials }
|
||||
delete updated[partName]
|
||||
removeMut.mutate(updated)
|
||||
}
|
||||
|
||||
const isBusy = saveMut.isPending || removeMut.isPending
|
||||
const previewHex = assignType === 'hex'
|
||||
? hexValue
|
||||
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-30 w-72 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Palette size={13} className="text-accent shrink-0" />
|
||||
<span className="text-white text-xs font-semibold truncate" title={partName}>
|
||||
{partName}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Isolation toggles — ghost or hide all other parts */}
|
||||
{onIsolateModeChange && (
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => onIsolateModeChange(isolateMode === 'ghost' ? 'none' : 'ghost')}
|
||||
title="Ghost other parts (semi-transparent)"
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
isolateMode === 'ghost'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Layers size={11} /> Ghost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onIsolateModeChange(isolateMode === 'hide' ? 'none' : 'hide')}
|
||||
title="Hide other parts"
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
|
||||
isolateMode === 'hide'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<EyeOff size={11} /> Hide
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type tabs */}
|
||||
<div className="flex rounded-md overflow-hidden border border-gray-700 text-xs">
|
||||
{(['library', 'hex'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setAssignType(t)}
|
||||
className={`flex-1 py-1.5 font-medium transition-colors ${
|
||||
assignType === t
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t === 'library' ? 'Library Material' : 'Hex Color'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{assignType === 'library' ? (
|
||||
<div>
|
||||
<label className="block text-gray-400 text-[11px] mb-1">Material</label>
|
||||
<select
|
||||
value={libValue}
|
||||
onChange={(e) => setLibValue(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{allMaterials.map((m) => (
|
||||
<option key={m.id} value={m.name}>
|
||||
{m.name}
|
||||
{m.description ? ` — ${m.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
{allMaterials.length === 0 && (
|
||||
<option value="">No materials found</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-gray-400 text-[11px] mb-1">Hex Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={hexValue}
|
||||
onChange={(e) => setHexValue(e.target.value)}
|
||||
className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hexValue}
|
||||
onChange={(e) => setHexValue(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 font-mono focus:outline-none focus:border-accent"
|
||||
placeholder="#888888"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview swatch */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-400">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
|
||||
style={{ backgroundColor: previewHex }}
|
||||
/>
|
||||
<span>Viewport preview color</span>
|
||||
</div>
|
||||
|
||||
{/* Current assignment */}
|
||||
{currentEntry && (
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
|
||||
style={{ backgroundColor: previewColorForEntry(currentEntry) }}
|
||||
/>
|
||||
<span className="truncate">Current: {currentEntry.value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={isBusy || (assignType === 'library' && !libValue)}
|
||||
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
|
||||
Assign
|
||||
</button>
|
||||
{currentEntry && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isBusy}
|
||||
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
|
||||
>
|
||||
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
import type { PartMaterialEntry, PartMaterialMap } from '../../api/cad'
|
||||
|
||||
/**
|
||||
* Normalize a GLB mesh name by stripping suffixes added by the export pipeline:
|
||||
* - OCC RWGltf_CafWriter adds "_AF0", "_AF1", … for repeated assembly instances
|
||||
* - Blender adds ".001", ".002", … for name deduplication on re-import
|
||||
*
|
||||
* Mirrors the logic in render-worker/scripts/export_gltf.py (lines 107-114).
|
||||
*
|
||||
* Examples:
|
||||
* "Ring_AF3" → "Ring"
|
||||
* "Ring_AF0_AF1" → "Ring" (nested suffixes — loop until stable)
|
||||
* "Cage.001" → "Cage"
|
||||
* "Cage.001_AF2" → "Cage"
|
||||
* "KOMP_ASM_1_AF0_ASM" → "KOMP_ASM_1" (_AF0_ASM variant)
|
||||
* "GE360-HF_000_P_ASM_1_AF0_ASM" → "GE360-HF_000_P_ASM_1"
|
||||
* "PlainPart" → "PlainPart"
|
||||
*/
|
||||
export function normalizeMeshName(name: string): string {
|
||||
// Strip Blender dedup suffix (.001, .002, …)
|
||||
let n = name.replace(/\.\d{3}$/, '')
|
||||
// Strip OCC assembly-instance suffix — handles _AF0, _AF1, _AF0_ASM, _AF1_ASM patterns
|
||||
// The optional (_ASM)? group catches assembly-node variants like _AF0_ASM
|
||||
let prev = ''
|
||||
while (prev !== n) { prev = n; n = n.replace(/_AF\d+(_ASM)?$/i, '') }
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePartMaterial
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a material entry for a (already-normalized) GLB mesh name.
|
||||
*
|
||||
* OCC's GLB exporter strips certain path suffixes (_ASM_1, _1, _AF\d+_\d+)
|
||||
* that cadquery keeps when parsing the STEP topology. This means stored keys
|
||||
* from Excel-imported cad_part_materials may have extra suffixes compared to
|
||||
* the actual GLB mesh names.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Exact match: partMaterials[meshKey]
|
||||
* 2. Prefix match: find shortest stored key that starts with meshKey + '_'
|
||||
* e.g. GLB "GE360-EIN_HAELFTE" matches stored "GE360-EIN_HAELFTE_AF0_1"
|
||||
*
|
||||
* Returns undefined when no match exists.
|
||||
*/
|
||||
export function resolvePartMaterial(
|
||||
meshKey: string,
|
||||
partMaterials: PartMaterialMap,
|
||||
): PartMaterialEntry | undefined {
|
||||
// 1. Exact match
|
||||
if (partMaterials[meshKey]) return partMaterials[meshKey]
|
||||
// 2. Shortest stored key that starts with meshKey + '_'
|
||||
let bestKey: string | undefined
|
||||
for (const key of Object.keys(partMaterials)) {
|
||||
if (key.startsWith(meshKey + '_')) {
|
||||
if (!bestKey || key.length < bestKey.length) bestKey = key
|
||||
}
|
||||
}
|
||||
return bestKey ? partMaterials[bestKey] : undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// convertCadPartMaterials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert Product.cad_part_materials (list of {part_name, material}) to
|
||||
* the PartMaterialMap format used by the 3D viewers.
|
||||
*
|
||||
* - Skips entries with blank part_name or material
|
||||
* - Detects hex colors (starting with "#") vs library material names
|
||||
* - Normalizes part names with normalizeMeshName() so they match GLB mesh keys
|
||||
*/
|
||||
export function convertCadPartMaterials(
|
||||
items: Array<{ part_name: string; material: string }>,
|
||||
): PartMaterialMap {
|
||||
const result: PartMaterialMap = {}
|
||||
for (const item of items) {
|
||||
if (!item.part_name.trim() || !item.material.trim()) continue
|
||||
const key = normalizeMeshName(item.part_name.trim())
|
||||
const value = item.material.trim()
|
||||
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -158,7 +158,7 @@ function DashboardGridInner() {
|
||||
className="btn-secondary text-sm flex items-center gap-1.5 ml-auto"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
Anpassen
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,7 @@ function DashboardGridInner() {
|
||||
</div>
|
||||
) : (widgets ?? []).length === 0 ? (
|
||||
<div className="rounded-xl border border-border-default p-8 text-center text-content-muted text-sm">
|
||||
No widgets configured. Click <strong>Anpassen</strong> to add widgets.
|
||||
No widgets configured. Click <strong>Customize</strong> to add widgets.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -18,6 +18,20 @@ const nav = [
|
||||
{ to: '/upload', icon: Upload, label: 'Upload' },
|
||||
]
|
||||
|
||||
const privilegedNav = [
|
||||
{ to: '/admin', icon: Settings, label: 'Admin' },
|
||||
{ to: '/billing', icon: Receipt, label: 'Billing' },
|
||||
{ to: '/media', icon: Image, label: 'Media Browser' },
|
||||
{ to: '/workers', icon: Server, label: 'Workers' },
|
||||
{ to: '/workflows', icon: GitBranch, label: 'Workflows' },
|
||||
{ to: '/asset-libraries', icon: Library, label: 'Asset Libraries' },
|
||||
]
|
||||
|
||||
const adminOnlyNav = [
|
||||
{ to: '/notification-settings', icon: BellRing, label: 'Notification Settings' },
|
||||
{ to: '/tenants', icon: Building2, label: 'Tenants' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
@@ -148,141 +162,60 @@ export default function Layout() {
|
||||
)
|
||||
})}
|
||||
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings size={18} />
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/billing"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Receipt size={18} />
|
||||
Billing
|
||||
</NavLink>
|
||||
)}
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/media"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Image size={18} />
|
||||
Media Browser
|
||||
</NavLink>
|
||||
)}
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/workers"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Server size={18} />
|
||||
Workers
|
||||
</NavLink>
|
||||
)}
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/workflows"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<GitBranch size={18} />
|
||||
Workflows
|
||||
</NavLink>
|
||||
)}
|
||||
{(checkIsPrivileged(user)) && (
|
||||
<NavLink
|
||||
to="/asset-libraries"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Library size={18} />
|
||||
Asset Libraries
|
||||
</NavLink>
|
||||
{checkIsPrivileged(user) && (
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
|
||||
Management
|
||||
</span>
|
||||
</div>
|
||||
{privilegedNav.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{checkIsAdmin(user) && (
|
||||
<NavLink
|
||||
to="/notification-settings"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<BellRing size={18} />
|
||||
Notification Settings
|
||||
</NavLink>
|
||||
)}
|
||||
{checkIsAdmin(user) && (
|
||||
<NavLink
|
||||
to="/tenants"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Building2 size={18} />
|
||||
Tenants
|
||||
</NavLink>
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-content-muted">
|
||||
Admin Only
|
||||
</span>
|
||||
</div>
|
||||
{adminOnlyNav.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent-light text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Cpu, ChevronDown, ChevronUp, Zap } from 'lucide-react'
|
||||
import type { RenderLog } from '../../api/orders'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
renderLog: RenderLog | null | undefined
|
||||
renderStartedAt?: string | null
|
||||
renderCompletedAt?: string | null
|
||||
}
|
||||
|
||||
function formatBytes(n?: number | null): string {
|
||||
if (n == null) return '—'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDuration(s?: number | null): string {
|
||||
if (s == null) return '—'
|
||||
if (s < 60) return `${s.toFixed(1)}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = (s % 60).toFixed(0)
|
||||
return `${m}m ${rem}s`
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4 text-sm py-0.5">
|
||||
<span className="text-content-muted shrink-0">{label}</span>
|
||||
<span className="text-content font-medium text-right">{value ?? '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BoolPill({ value, trueLabel = 'Yes', falseLabel = 'No' }: { value: boolean | undefined; trueLabel?: string; falseLabel?: string }) {
|
||||
if (value == null) return <span className="text-content-muted">—</span>
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${value ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'}`}>
|
||||
{value ? trueLabel : falseLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RenderInfoModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
renderLog,
|
||||
renderStartedAt,
|
||||
renderCompletedAt,
|
||||
}: Props) {
|
||||
const [logExpanded, setLogExpanded] = useState(false)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const rl = renderLog
|
||||
|
||||
const isAnimation = rl?.type === 'turntable'
|
||||
const hasTemplate = !!rl?.template
|
||||
const hasTimestamps = !!(renderStartedAt || renderCompletedAt)
|
||||
const hasLog = (rl?.log_lines?.length ?? 0) > 0
|
||||
const hasError = !!rl?.error
|
||||
|
||||
const engineLabel = rl?.engine_used || rl?.engine || '—'
|
||||
const device = rl?.device_used
|
||||
const isGpu = device?.toLowerCase().includes('gpu')
|
||||
const isCpu = device?.toLowerCase().includes('cpu')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-light sticky top-0"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}>
|
||||
<h2 className="font-semibold text-content">{title} — Render Info</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-content-muted hover:text-content transition-colors p-1 rounded"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Error */}
|
||||
{hasError && (
|
||||
<div className="rounded-md p-3 text-sm" style={{ backgroundColor: 'var(--color-status-error-bg)', color: 'var(--color-status-error-text)' }}>
|
||||
<p className="font-semibold mb-1">Render Error</p>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono break-all">{rl!.error}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Settings */}
|
||||
{rl && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<SectionHeader>Render Settings</SectionHeader>
|
||||
<div className="space-y-0.5">
|
||||
{rl.renderer && <Row label="Renderer" value={rl.renderer} />}
|
||||
<Row label="Engine" value={engineLabel} />
|
||||
{device && (
|
||||
<Row
|
||||
label="Device"
|
||||
value={
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
isGpu
|
||||
? 'bg-status-success-bg text-status-success-text'
|
||||
: isCpu
|
||||
? 'bg-status-warning-bg text-status-warning-text'
|
||||
: 'bg-surface-muted text-content-muted'
|
||||
}`}>
|
||||
{isGpu ? <Zap size={10} /> : <Cpu size={10} />}
|
||||
{device}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{rl.samples != null && <Row label="Samples" value={rl.samples} />}
|
||||
{rl.compute_type && <Row label="Compute Type" value={rl.compute_type} />}
|
||||
{rl.gpu_fallback != null && (
|
||||
<Row label="GPU Fallback" value={<BoolPill value={rl.gpu_fallback} trueLabel="Yes (CPU used)" falseLabel="No" />} />
|
||||
)}
|
||||
{rl.format && <Row label="Format" value={rl.format.toUpperCase()} />}
|
||||
{rl.parts_count != null && <Row label="Parts" value={rl.parts_count} />}
|
||||
{rl.stl_quality && <Row label="STL Quality" value={rl.stl_quality} />}
|
||||
{rl.smooth_angle != null && <Row label="Smooth Angle" value={`${rl.smooth_angle}°`} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
{rl && (rl.total_duration_s != null || rl.stl_duration_s != null || rl.render_duration_s != null) && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<SectionHeader>Timing</SectionHeader>
|
||||
<div className="space-y-0.5">
|
||||
<Row label="Total" value={formatDuration(rl.total_duration_s)} />
|
||||
{rl.stl_duration_s != null && <Row label="STL Conversion" value={formatDuration(rl.stl_duration_s)} />}
|
||||
{rl.render_duration_s != null && <Row label="Render" value={formatDuration(rl.render_duration_s)} />}
|
||||
{isAnimation && rl.ffmpeg_duration_s != null && <Row label="FFmpeg" value={formatDuration(rl.ffmpeg_duration_s)} />}
|
||||
{isAnimation && rl.frame_count != null && <Row label="Frames" value={rl.frame_count} />}
|
||||
{isAnimation && rl.fps != null && <Row label="FPS" value={rl.fps} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{rl && (rl.output_size_bytes != null || rl.stl_size_bytes != null) && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<SectionHeader>Files</SectionHeader>
|
||||
<div className="space-y-0.5">
|
||||
{rl.output_size_bytes != null && <Row label="Output File" value={formatBytes(rl.output_size_bytes)} />}
|
||||
{rl.stl_size_bytes != null && <Row label="STL Cache" value={formatBytes(rl.stl_size_bytes)} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template */}
|
||||
{hasTemplate && rl && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<SectionHeader>Template</SectionHeader>
|
||||
<div className="space-y-0.5">
|
||||
<Row label="Path" value={<span className="font-mono text-xs break-all">{rl.template}</span>} />
|
||||
{rl.lighting_only != null && <Row label="Lighting Only" value={<BoolPill value={rl.lighting_only} />} />}
|
||||
{rl.shadow_catcher != null && <Row label="Shadow Catcher" value={<BoolPill value={rl.shadow_catcher} />} />}
|
||||
{rl.material_replace != null && <Row label="Material Replace" value={<BoolPill value={rl.material_replace} />} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
{hasTimestamps && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<SectionHeader>Timestamps</SectionHeader>
|
||||
<div className="space-y-0.5">
|
||||
{renderStartedAt && <Row label="Started" value={new Date(renderStartedAt).toLocaleString()} />}
|
||||
{renderCompletedAt && <Row label="Completed" value={new Date(renderCompletedAt).toLocaleString()} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blender Log */}
|
||||
{hasLog && rl && (
|
||||
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
|
||||
<button
|
||||
onClick={() => setLogExpanded((v) => !v)}
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
>
|
||||
<SectionHeader>Blender Log</SectionHeader>
|
||||
<span className="ml-auto text-content-muted">
|
||||
{logExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{logExpanded && (
|
||||
<pre className="mt-2 text-xs font-mono whitespace-pre-wrap break-all max-h-64 overflow-y-auto text-content-secondary leading-relaxed">
|
||||
{rl.log_lines!.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
{!logExpanded && (
|
||||
<p className="text-xs text-content-muted mt-1">{rl.log_lines!.length} lines — click to expand</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rl && (
|
||||
<p className="text-sm text-content-muted text-center py-4">No render metadata available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface StepIndicatorProps {
|
||||
step: number // current step (1-based)
|
||||
total: number
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
export default function StepIndicator({ step, total, labels }: StepIndicatorProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: simple text */}
|
||||
<div className="md:hidden flex items-center justify-center py-3">
|
||||
<span className="text-sm font-medium text-content-secondary">
|
||||
Step {step} of {total}
|
||||
{labels[step - 1] ? ` — ${labels[step - 1]}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: full step bar */}
|
||||
<div className="hidden md:flex items-center w-full mb-6">
|
||||
{Array.from({ length: total }, (_, i) => {
|
||||
const num = i + 1
|
||||
const isCompleted = num < step
|
||||
const isActive = num === step
|
||||
const isFuture = num > step
|
||||
|
||||
return (
|
||||
<div key={num} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Step circle + label */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-accent text-accent-text'
|
||||
: isActive
|
||||
? 'bg-accent text-accent-text ring-4 ring-accent-light'
|
||||
: 'bg-surface-muted text-content-muted border border-border-default'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle2 size={16} /> : num}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap ${
|
||||
isActive ? 'text-accent' : isFuture ? 'text-content-muted' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{labels[i] ?? `Step ${num}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line (not after last step) */}
|
||||
{num < total && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 mt-[-16px] transition-colors ${
|
||||
isCompleted ? 'bg-accent' : 'bg-border-default'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user