import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState, useRef } from 'react' import { toast } from 'sonner' import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react' import { Link } from 'react-router-dom' import api from '../api/client' import ConfirmModal from '../components/ConfirmModal' import HelpTooltip from '../components/HelpTooltip' import TemplateEditor from '../components/admin/TemplateEditor' import PricingTierTable from '../components/admin/PricingTierTable' import OutputTypeTable from '../components/admin/OutputTypeTable' import RenderTemplateTable from '../components/admin/RenderTemplateTable' import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel' import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth' import { listPricingTiers } from '../api/pricing' import { listOutputTypes } from '../api/outputTypes' import { listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog, type AssetLibrary, } from '../api/assetLibraries' import { getTenantDefaultDashboard } from '../api/dashboard' import type { WidgetConfig } from '../api/dashboard' import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal' import { getGpuProbeResult, triggerGpuProbe } from '../api/worker' import type { GPUProbeResult } from '../api/worker' export default function AdminPage() { const qc = useQueryClient() const user = useAuthStore((s) => s.user) const isAdmin = checkIsAdmin(user) const [showNewUser, setShowNewUser] = useState(false) const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' }) const [editingUserId, setEditingUserId] = useState(null) const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true }) const [editingTemplateId, setEditingTemplateId] = useState(null) const [priorityNewEntry, setPriorityNewEntry] = useState('') const { data: users } = useQuery({ queryKey: ['admin-users'], queryFn: async () => { const res = await api.get('/admin/users') return res.data as any[] }, }) const { data: templates } = useQuery({ queryKey: ['admin-templates'], queryFn: async () => { const res = await api.get('/templates?include_inactive=true') return res.data as any[] }, }) const createUserMut = useMutation({ mutationFn: (data: typeof newUser) => api.post('/admin/users', data), onSuccess: () => { toast.success('User created') qc.invalidateQueries({ queryKey: ['admin-users'] }) setShowNewUser(false) setNewUser({ email: '', password: '', full_name: '', role: 'client' }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const deleteUserMut = useMutation({ mutationFn: (id: string) => api.delete(`/admin/users/${id}`), onSuccess: () => { toast.success('User deleted') qc.invalidateQueries({ queryKey: ['admin-users'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const updateUserMut = useMutation({ mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) => api.patch(`/admin/users/${id}`, data), onSuccess: () => { toast.success('User updated') qc.invalidateQueries({ queryKey: ['admin-users'] }) setEditingUserId(null) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) type Settings = { thumbnail_renderer: string blender_engine: string blender_cycles_samples: number blender_eevee_samples: number threejs_render_size: number thumbnail_format: string blender_smooth_angle: number cycles_device: string blender_max_concurrent_renders: number render_stall_timeout_minutes: number product_thumbnail_priority: string // JSON array render_backend: string smtp_enabled: boolean smtp_host: string smtp_port: number smtp_user: string smtp_password: string smtp_from_address: string gltf_scale_factor: number gltf_smooth_normals: boolean viewer_max_distance: number viewer_min_distance: number gltf_material_quality: string gltf_pbr_roughness: number gltf_pbr_metallic: number scene_linear_deflection: number scene_angular_deflection: number render_linear_deflection: number render_angular_deflection: number tessellation_engine: string } const { data: settings } = useQuery({ queryKey: ['admin-settings'], queryFn: async () => { const res = await api.get('/admin/settings') return res.data as Settings }, }) const { data: outputTypes } = useQuery({ queryKey: ['output-types-all'], queryFn: () => listOutputTypes(false), enabled: isAdmin, }) // Local draft for Blender options so the user can change multiple fields before saving const [blenderDraft, setBlenderDraft] = useState>({}) const blender = { ...settings, ...blenderDraft } as Settings const [viewerDraft, setViewerDraft] = useState>({}) const viewer3d = { ...settings, ...viewerDraft } as Settings const [tessellationDraft, setTessellationDraft] = useState>({}) const tess = { ...settings, ...tessellationDraft } as Settings const [showAdvancedTess, setShowAdvancedTess] = useState(false) const { data: rendererStatus, refetch: refetchStatus } = useQuery({ queryKey: ['renderer-status'], queryFn: async () => { const res = await api.get('/admin/settings/renderer-status') return res.data as Record }, refetchInterval: 30000, }) const updateSettingsMut = useMutation({ mutationFn: (data: Partial) => api.put('/admin/settings', data), onSuccess: () => { toast.success('Settings saved') qc.invalidateQueries({ queryKey: ['admin-settings'] }) setBlenderDraft({}) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const processUnprocessedMut = useMutation({ mutationFn: () => api.post('/admin/settings/process-unprocessed'), onSuccess: (res) => { toast.success(res.data.message || 'Processing queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const regenerateMut = useMutation({ mutationFn: () => api.post('/admin/settings/regenerate-thumbnails'), onSuccess: (res) => { toast.success(res.data.message || 'Thumbnails re-queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const importMediaAssetsMut = useMutation({ mutationFn: () => api.post('/admin/import-media-assets'), onSuccess: (res) => { toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'), }) const cleanupOrphanedMut = useMutation({ mutationFn: () => api.post('/media/cleanup-orphaned'), onSuccess: (res) => { toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`) qc.invalidateQueries({ queryKey: ['media-browser'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'), }) const reextractMetadataMut = useMutation({ mutationFn: () => api.post('/admin/settings/reextract-metadata'), onSuccess: (res) => { toast.success(res.data.message || 'Metadata re-extraction queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const cleanupOrphanedCadMut = useMutation({ mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'), onSuccess: (res) => { toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'), }) const recoverStuckMut = useMutation({ mutationFn: () => api.post('/admin/settings/recover-stuck-processing'), onSuccess: (res) => { toast.success(res.data.message || 'Stuck files recovered') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const seedWorkflowsMut = useMutation({ mutationFn: () => api.post('/admin/settings/seed-workflows'), onSuccess: (res) => { toast.success(res.data.message || 'Workflows seeded') qc.invalidateQueries({ queryKey: ['workflows'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const generateMissingUsdMastersMut = useMutation({ mutationFn: () => api.post('/admin/settings/generate-missing-usd-masters'), onSuccess: (res) => toast.success(res.data.message || 'USD master export queued'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const generateMissingCanonicalScenesMut = useMutation({ mutationFn: () => api.post('/admin/settings/generate-missing-canonical-scenes'), onSuccess: (res) => toast.success(res.data.message || 'Canonical scene export queued'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const [smtpDraft, setSmtpDraft] = useState>({}) const smtp = { ...settings, ...smtpDraft } as Settings const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} }) const [showTenantDashboardModal, setShowTenantDashboardModal] = useState(false) const { data: tenantDefaultWidgets } = useQuery({ queryKey: ['tenant-default-dashboard'], queryFn: getTenantDefaultDashboard, enabled: isAdmin, staleTime: 300_000, }) // GPU Probe const [gpuProbeExpanded, setGpuProbeExpanded] = useState(false) const [gpuProbing, setGpuProbing] = useState(false) const gpuPollRef = useRef | null>(null) const { data: gpuProbeResult, refetch: refetchGpuProbe } = useQuery({ queryKey: ['gpu-probe-result'], queryFn: getGpuProbeResult, enabled: isAdmin, refetchInterval: gpuProbing ? 2000 : false, staleTime: 0, }) const handleRunGpuCheck = async () => { if (!isAdmin) return setGpuProbing(true) try { await triggerGpuProbe() // Poll for up to 45 seconds let elapsed = 0 const interval = setInterval(async () => { elapsed += 2 await refetchGpuProbe() if (elapsed >= 45) { clearInterval(interval) setGpuProbing(false) } }, 2000) gpuPollRef.current = interval } catch { toast.error('Failed to trigger GPU check') setGpuProbing(false) } } const gpuStatusBadge = () => { if (!gpuProbeResult) return null const s = gpuProbeResult.status if (s === 'ok') { return ( GPU OK{gpuProbeResult.device_type ? ` (${gpuProbeResult.device_type})` : ''} ) } if (s === 'failed') { return ( CPU Fallback ) } return ( {s === 'error' ? 'Error' : 'Unknown'} ) } type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config' const [activeTab, setActiveTab] = useState('overview') const hasUnsavedChanges = Object.keys(blenderDraft).length > 0 || Object.keys(viewerDraft).length > 0 || Object.keys(tessellationDraft).length > 0 || Object.keys(smtpDraft).length > 0 const TABS: { id: AdminTab; label: string }[] = [ { id: 'overview', label: 'Overview' }, { id: 'users', label: 'Users' }, { id: 'render', label: 'Render' }, { id: 'pricing', label: 'Pricing' }, { id: 'libraries', label: 'Libraries' }, { id: 'config', label: 'Config' }, ] return (
{/* Tab header */}

Admin

{hasUnsavedChanges && (
Unsaved changes
)}
{TABS.map((tab) => ( ))}
{/* ------------------------------------------------------------------ */} {/* Pricing Summary */} {/* ------------------------------------------------------------------ */} {activeTab === 'overview' && } {/* ------------------------------------------------------------------ */} {/* Users (admin only) */} {/* ------------------------------------------------------------------ */} {activeTab === 'users' && isAdmin &&

Users

{showNewUser && (
setNewUser({ ...newUser, full_name: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" /> setNewUser({ ...newUser, email: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" /> setNewUser({ ...newUser, password: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" />
)}
{users?.map((u) => (
{editingUserId === u.id ? (
setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))} className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full" />
) : (

{u.full_name}

{u.email}

{u.role} {u.is_active ? 'active' : 'inactive'}
)}
))}
} {/* ------------------------------------------------------------------ */} {/* Blender Render Settings (admin only) */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' && isAdmin &&

Blender Render Settings

Render quality, performance and thumbnail output options for Blender 5.

{/* ── Render Quality ───────────────────────────────────────────── */}

Render Quality

{/* Engine */}
Render engine {(['cycles', 'eevee'] as const).map((eng) => ( ))}
{/* Cycles device — only relevant for Cycles */} {blender.blender_engine === 'cycles' && (
Cycles device {(['auto', 'gpu', 'cpu'] as const).map((dev) => ( ))}

{blender.cycles_device === 'auto' ? 'Tries OptiX / CUDA / HIP, falls back to CPU if no GPU is available.' : blender.cycles_device === 'gpu' ? 'Always use GPU. Logs a warning if no compatible GPU is found.' : 'Always use CPU — useful for debugging or when GPU is busy.'}

)} {/* Sample counts */}
setBlenderDraft((d) => ({ ...d, blender_cycles_samples: Number(e.target.value) }))} title="Number of Cycles path-tracing samples (1–4096). Higher values = better quality + longer render time. Default: 256" className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Higher = better quality, slower

setBlenderDraft((d) => ({ ...d, blender_eevee_samples: Number(e.target.value) }))} title="EEVEE anti-aliasing sample count (1–1024). Higher values = smoother edges + longer render time. Default: 64" className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Higher = better AA, slower

{/* Smooth by angle */}
Smooth angle setBlenderDraft((d) => ({ ...d, blender_smooth_angle: Number(e.target.value) }))} title="Auto-smooth angle in degrees (0–180°). Faces with dihedral angles below this threshold are shaded smooth; sharper edges stay hard. 30° works well for most mechanical parts." className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> °

{(blender.blender_smooth_angle ?? 30) === 0 ? '0° = flat shading on all faces.' : `Faces with edges sharper than ${blender.blender_smooth_angle ?? 30}° stay hard; others smooth. 30° works well for most mechanical parts.`}

{/* ── Performance ──────────────────────────────────────────────── */}

Performance

Max concurrent setBlenderDraft((d) => ({ ...d, blender_max_concurrent_renders: Number(e.target.value) }))} title="Maximum parallel Blender render jobs (1–16). Each job uses ~400 MB RAM. Applied live without restart. Default: 3" className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Max parallel Blender render jobs (1–16). Higher values use more RAM (~400 MB each). Applied live without restart.

Stall timeout setBlenderDraft((d) => ({ ...d, render_stall_timeout_minutes: Number(e.target.value) }))} title="Minutes before a stuck render job is automatically restarted (10–10080). The watchdog checks every 5 minutes. Default: 120" className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Minutes before a stuck render job is auto-restarted (10–10080). Checked every 5 min by the watchdog.

{/* Save button — appears when draft has unsaved changes */} {Object.keys(blenderDraft).length > 0 && ( )} {/* ── Output ───────────────────────────────────────────────────── */}

Output

{(['jpg', 'png'] as const).map((fmt) => ( ))}

{settings?.thumbnail_format === 'jpg' ? 'JPEG — ~3–5× smaller files, minimal quality loss at 92% quality.' : 'PNG — lossless, larger files.'}

{/* Product thumbnail priority chain */} {(() => { let priorityList: string[] = ['latest_render', 'cad_thumbnail'] try { const parsed = JSON.parse(settings?.product_thumbnail_priority ?? '["latest_render","cad_thumbnail"]') if (Array.isArray(parsed)) priorityList = parsed } catch {} const savePriority = (list: string[]) => { updateSettingsMut.mutate({ product_thumbnail_priority: JSON.stringify(list) } as any) } const moveUp = (i: number) => { if (i === 0) return const next = [...priorityList] ;[next[i - 1], next[i]] = [next[i], next[i - 1]] savePriority(next) } const moveDown = (i: number) => { if (i === priorityList.length - 1) return const next = [...priorityList] ;[next[i], next[i + 1]] = [next[i + 1], next[i]] savePriority(next) } const remove = (i: number) => savePriority(priorityList.filter((_, j) => j !== i)) const addEntry = () => { if (!priorityNewEntry || priorityList.includes(priorityNewEntry)) return savePriority([...priorityList, priorityNewEntry]) setPriorityNewEntry('') } const entryLabel = (e: string) => e === 'cad_thumbnail' ? 'CAD Thumbnail' : e === 'latest_render' ? 'Latest Render (any type)' : outputTypes?.find((ot) => ot.id === e)?.name ?? `Output type …${e.slice(-8)}` const entryColor = (e: string) => e === 'cad_thumbnail' ? 'bg-surface-alt border-border-default text-content-muted' : e === 'latest_render' ? 'bg-status-info-bg border-border-default text-status-info-text' : 'bg-status-success-bg border-border-default text-status-success-text' // Options not yet in the list const addableOptions = [ ...(['latest_render', 'cad_thumbnail'] as string[]).filter((v) => !priorityList.includes(v)), ...(outputTypes ?? []).filter((ot) => !priorityList.includes(ot.id)).map((ot) => ot.id), ] return (
{priorityList.map((entry, i) => (
{i + 1}
{entryLabel(entry)} {entry !== 'cad_thumbnail' && entry !== 'latest_render' && ( newest completed render )}
))} {addableOptions.length > 0 && (
)}

Sources are tried top to bottom. For specific output types, the newest completed render of that type is used. "CAD Thumbnail" always matches and stops the search.

) })()}
{/* end Output */} {/* ── Service Status ───────────────────────────────────────────── */}

Service Status

{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
{info.available ? : }

{name}

{info.note || (info.available ? 'Online' : 'Offline')}

))} {!rendererStatus && (
Checking service status…
)}
{/* ── Maintenance ──────────────────────────────────────────────── */}

Maintenance

Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.

Queues all pending/failed STEP files for initial processing.

Re-renders thumbnails for all completed CAD files.

Exports USD canonical scene for all completed CAD files missing one.

Queues geometry GLB + USD master for all completed CAD files missing a canonical scene.

Registers existing renders & CAD thumbnails in the Media Browser.

Removes DB records for renders whose files no longer exist on disk.

Removes STEP files, thumbnails, and DB records not linked to any product.

Updates dimensions and edge data for existing files (no re-render).

Creates the 4 standard workflow definitions if they don't exist yet.

} {/* ------------------------------------------------------------------ */} {/* Global Render Positions (admin only) */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' && isAdmin &&

Global Render Positions

Camera rotation presets available to all products. Per-product positions override these.

} {/* ------------------------------------------------------------------ */} {/* Render Templates (admin/PM) */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' &&

Render Templates

Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.

} {/* ------------------------------------------------------------------ */} {/* Asset Libraries */} {/* ------------------------------------------------------------------ */} {activeTab === 'libraries' && } {/* ------------------------------------------------------------------ */} {/* Output Types */} {/* ------------------------------------------------------------------ */} {activeTab === 'pricing' &&

Output Types

Define what kinds of outputs orders can request (thumbnails, views, formats).

} {/* ------------------------------------------------------------------ */} {/* Pricing Tiers */} {/* ------------------------------------------------------------------ */} {activeTab === 'pricing' &&

Pricing Tiers

Configure price per rendering item by category and quality level.

} {/* ------------------------------------------------------------------ */} {/* E-Mail / SMTP Settings */} {/* ------------------------------------------------------------------ */} {activeTab === 'config' && isAdmin && (

E-Mail Notifications (SMTP)

Configure outbound SMTP for email notifications. Enable only when credentials are set.

{smtp.smtp_enabled && ( Active )}
setSmtpDraft(d => ({ ...d, smtp_host: e.target.value }))} placeholder="smtp.example.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_port: parseInt(e.target.value) || 587 }))} className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_user: e.target.value }))} placeholder="user@example.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_password: e.target.value }))} placeholder="••••••••" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))} placeholder="noreply@schaeffler.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
)} {/* ------------------------------------------------------------------ */} {/* Templates */} {/* ------------------------------------------------------------------ */} {activeTab === 'libraries' &&

Templates

Click Edit to configure standard fields and component schema for each template.

{templates?.map((t) => { const isEditing = editingTemplateId === t.id return (
{/* Row */}

{t.name}

{t.category_key}

{t.is_active ? 'active' : 'inactive'}
{/* Inline editor panel */} {isEditing && (
setEditingTemplateId(null)} />
)}
) })}
} {/* ------------------------------------------------------------------ */} {/* Dashboard Widget Configuration (admin only) */} {/* ------------------------------------------------------------------ */} {activeTab === 'config' && isAdmin && (

Dashboard Widget-Konfiguration

Sets the default widget layout for all users of this tenant. Users can customize their own layout individually.

Tenant default:{' '} {tenantDefaultWidgets && tenantDefaultWidgets.length > 0 ? `${tenantDefaultWidgets.length} Widget${tenantDefaultWidgets.length !== 1 ? 's' : ''} configured` : 'No default set yet (system default active)'}

)} {showTenantDashboardModal && ( setShowTenantDashboardModal(false)} tenantMode={true} /> )} {/* ------------------------------------------------------------------ */} {/* 3D Viewer & GLB Export Settings */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' &&

3D Viewer & GLB Export

Settings for the 3D viewer and GLB geometry export

{/* Scale Factor */}
setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))} className="input w-full" />

Default 0.001 converts mm to meters

Smooths surface normals during GLB export for a less faceted look in the 3D viewer.

{/* Camera / Zoom Limits */}
setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))} title="Maximum camera distance from the model in the 3D viewer (in metres after mm→m conversion). Default: 50" className="input w-full" />

Maximum camera pull-back distance in the 3D viewer (metres).

setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))} title="Minimum camera distance from the model in the 3D viewer (in metres). Default: 0.001. Prevents clipping into the geometry." className="input w-full" />

Closest the camera can zoom in (metres). Prevents clipping through geometry.

{/* PBR Material Quality */}

Material data embedded in exported GLB files.

setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))} title="Surface roughness for GLB PBR materials (0 = mirror-smooth, 1 = fully matte). Default: 0.4 — appropriate for brushed metal." className="input w-full" />

0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.

setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))} title="Metallic factor for GLB PBR materials (0 = dielectric/plastic, 1 = fully metallic). Default: 0.6 — suitable for steel parts." className="input w-full" />

0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.

{Object.keys(viewerDraft).length > 0 && ( )}
} {/* ------------------------------------------------------------------ */} {/* Tessellation Quality */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' &&

Tessellation Quality

Controls how STEP geometry is converted to triangle meshes. These settings affect both the 3D viewer and Blender renders.

{/* Presets */} {(() => { const PRESETS = [ { label: 'Draft', icon: '⚡', description: 'Fast preview — visible faceting on curved surfaces', useCase: 'Quick checks, large assemblies', color: 'border-amber-400', activeColor: 'border-amber-500 ring-2 ring-amber-200', values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3, render_linear_deflection: 0.05, render_angular_deflection: 0.1 }, }, { label: 'Standard', icon: '●', description: 'Smooth curves, good quality-to-size ratio', useCase: 'Recommended for most parts', color: 'border-blue-400', activeColor: 'border-blue-500 ring-2 ring-blue-200', values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1, render_linear_deflection: 0.03, render_angular_deflection: 0.05 }, }, { label: 'Fine', icon: '◆', description: 'Near-perfect surfaces, 3-5x larger files', useCase: 'Close-up renders, small precision parts', color: 'border-emerald-400', activeColor: 'border-emerald-500 ring-2 ring-emerald-200', values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05, render_linear_deflection: 0.01, render_angular_deflection: 0.02 }, }, { label: 'Ultra', icon: '★', description: 'Maximum fidelity, very slow export', useCase: 'Marketing renders, extreme close-ups', color: 'border-purple-400', activeColor: 'border-purple-500 ring-2 ring-purple-200', values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02, render_linear_deflection: 0.005, render_angular_deflection: 0.01 }, }, ] const isActive = (preset: typeof PRESETS[0]) => tess.scene_linear_deflection === preset.values.scene_linear_deflection && tess.scene_angular_deflection === preset.values.scene_angular_deflection && tess.render_linear_deflection === preset.values.render_linear_deflection && tess.render_angular_deflection === preset.values.render_angular_deflection const isCustom = !PRESETS.some(isActive) return (

Quality Presets

{PRESETS.map(preset => ( ))}
{isCustom && (

Current values don't match any preset (custom configuration)

)}
) })()} {/* Tessellation engine selector */}

Tessellation Engine

{[ { value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan-shaped triangles at cylinder seam lines.' }, { value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Uniform mesh — eliminates fan artifacts on cylindrical parts. 10-30% slower. Recommended for bearings.' }, ].map(opt => ( ))}
{/* Explanation of deflection parameters */}

How deflection values work

Linear deflection (mm)

Maximum allowed distance between the original curved surface and the generated triangles. A value of 0.1 mm means no triangle edge can deviate more than 0.1 mm from the true surface. Lower values produce smoother curves but more triangles.

Angular deflection (rad)

Maximum angle between adjacent triangle normals. Controls how finely curved regions are subdivided. A value of 0.1 rad (~6°) means neighboring triangles can differ by at most ~6°. Primarily affects small fillets and tight curvatures.

{/* Manual inputs */} {showAdvancedTess && (<>

3D Viewer + USD Master

Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.

setTessellationDraft(d => ({ ...d, scene_linear_deflection: parseFloat(e.target.value) }))} className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> mm
setTessellationDraft(d => ({ ...d, scene_angular_deflection: parseFloat(e.target.value) }))} className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> rad

Blender Render Output

Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.

setTessellationDraft(d => ({ ...d, render_linear_deflection: parseFloat(e.target.value) }))} className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> mm
setTessellationDraft(d => ({ ...d, render_angular_deflection: parseFloat(e.target.value) }))} className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> rad
)}
{Object.keys(tessellationDraft).length > 0 && ( )}
} {/* ------------------------------------------------------------------ */} {/* Material Library link */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' &&

Material Library

Manage shared materials for CAD part assignments.

Open Material Library →
} {/* ------------------------------------------------------------------ */} {/* GPU Status */} {/* ------------------------------------------------------------------ */} {activeTab === 'render' && isAdmin && (
{gpuProbeExpanded && (
{gpuProbeResult && ( Last checked: {gpuProbeResult.timestamp ? new Date(gpuProbeResult.timestamp).toLocaleString() : '—'} )}
{gpuProbeResult && (
Status {gpuStatusBadge()}
{gpuProbeResult.device_type && (
Device type {gpuProbeResult.device_type}
)} {gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
Devices
{gpuProbeResult.devices.map((d: string, i: number) => ( {d} ))}
)} {gpuProbeResult.render_time_s != null && (
Render time {gpuProbeResult.render_time_s.toFixed(2)}s
)} {gpuProbeResult.error && (
Error {gpuProbeResult.error}
)}
)} {!gpuProbeResult && !gpuProbing && (

No probe result yet. Click "Run GPU Check" to trigger a test render.

)}
)}
)} setConfirmState((s) => ({ ...s, open: false }))} />
) } function MaterialLibraryPanel() { return (

Material Library

Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.

Manage Asset Libraries
) } function PricingSummaryCard() { const { data: tiers } = useQuery({ queryKey: ['pricing-tiers'], queryFn: listPricingTiers, }) const { data: outputTypes } = useQuery({ queryKey: ['output-types-admin'], queryFn: () => listOutputTypes(true), }) const defaultTier = tiers?.find((t) => t.category_key === 'default' && t.is_active) const activeTiers = tiers?.filter((t) => t.is_active).length ?? 0 const totalOTs = outputTypes?.length ?? 0 const otsWithTier = outputTypes?.filter((ot) => ot.pricing_tier_id != null).length ?? 0 return (

Pricing Overview

{defaultTier ? `${Number(defaultTier.price_per_item).toFixed(2)}` : '—'}

Global default price

{!defaultTier && (

Not configured

)}

{activeTiers}

Active pricing tiers

{otsWithTier} / {totalOTs}

Output types with explicit tier

{totalOTs - otsWithTier}

Using category default

) } // ── Asset Library Panel ─────────────────────────────────────────────────────── function AssetLibraryPanel() { const qc = useQueryClient() const [showCreate, setShowCreate] = useState(false) const [newName, setNewName] = useState('') const [newDesc, setNewDesc] = useState('') const [newFile, setNewFile] = useState(null) const [expanded, setExpanded] = useState>(new Set()) const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} }) const { data: libraries = [] } = useQuery({ queryKey: ['asset-libraries'], queryFn: listAssetLibraries, }) const createMut = useMutation({ mutationFn: () => createAssetLibrary({ name: newName, description: newDesc || undefined, blend_file: newFile! }), onSuccess: () => { toast.success('Asset library created') qc.invalidateQueries({ queryKey: ['asset-libraries'] }) setShowCreate(false) setNewName('') setNewDesc('') setNewFile(null) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'), }) const deleteMut = useMutation({ mutationFn: (id: string) => deleteAssetLibrary(id), onSuccess: () => { toast.success('Asset library deleted') qc.invalidateQueries({ queryKey: ['asset-libraries'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'), }) const refreshMut = useMutation({ mutationFn: (id: string) => refreshAssetLibraryCatalog(id), onSuccess: () => { toast.success('Catalog refresh queued') setTimeout(() => qc.invalidateQueries({ queryKey: ['asset-libraries'] }), 3000) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to refresh'), }) const toggle = (id: string) => setExpanded((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n }) return (

Asset Libraries

Upload Blender .blend files containing production materials and node groups.

{showCreate && (
setNewName(e.target.value)} /> setNewDesc(e.target.value)} />
)} {libraries.length === 0 ? (
No asset libraries yet. Upload a .blend file to get started.
) : (
{(libraries as AssetLibrary[]).map((lib) => { const isExpanded = expanded.has(lib.id) const matCount = lib.catalog.materials.length const ngCount = lib.catalog.node_groups.length return (

{lib.name}

{lib.description && (

{lib.description}

)}
{lib.original_filename ?? '—'} {matCount} materials {ngCount} node groups
{isExpanded && (
{matCount > 0 && (

Materials

{lib.catalog.materials.map((m) => { const name = typeof m === 'string' ? m : m.name return ( {name} ) })}
)} {ngCount > 0 && (

Node Groups

{lib.catalog.node_groups.map((ng) => ( {ng} ))}
)} {matCount === 0 && ngCount === 0 && (

No assets found. Click "Refresh" to scan the .blend for marked assets.

)}
)}
) })}
)} setConfirmState((s) => ({ ...s, open: false }))} />
) }