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

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

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

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

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

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

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

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