refactor: clean up Render Settings — remove 11 unused settings, fix Blender status

Removed from UI (saved to DB but never read by any service):
- Max Concurrent Renders, Stall Timeout, Thumbnail Format, Product Thumbnail Priority
- Render Linear/Angular Deflection (only Scene deflections are used)
- GLB Scale Factor, Smooth Normals, GLB Material Mode, PBR Roughness, PBR Metallic

Fixed Blender status check:
- Old: called is_blender_available() in backend container (Blender not installed there)
- New: dispatches Celery task on asset_pipeline queue → runs in render-worker container
- Returns: available=true, version="Blender 5.0.1", binary path
- Status card moved to System Tools tab with refresh button

Kept active: engine, device, samples, smooth angle, tessellation, scene deflections,
3D viewer zoom limits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 09:37:54 +01:00
parent 9a794ff2da
commit 2c7eb81aab
3 changed files with 137 additions and 418 deletions
+8 -14
View File
@@ -723,20 +723,14 @@ async def seed_workflows(
async def renderer_status(
admin: User = Depends(require_global_admin),
):
"""Check health of renderer services."""
from app.services.render_blender import find_blender, is_blender_available
blender_available = is_blender_available()
blender_bin = find_blender()
return {
"blender": {
"available": blender_available,
"note": (
f"render-worker subprocess ({blender_bin})"
if blender_available
else "Blender not found — check render-worker container and BLENDER_BIN"
),
},
}
"""Check Blender availability on the render-worker via Celery task."""
from app.tasks.gpu_tasks import check_blender_status
try:
result = check_blender_status.apply_async()
data = result.get(timeout=10)
except Exception as exc:
data = {"available": False, "blender_bin": "", "version": "", "error": str(exc)}
return {"blender": data}
@router.post("/import-media-assets")
+20
View File
@@ -5,6 +5,26 @@ from app.tasks.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(name="app.tasks.gpu_tasks.check_blender_status", queue="asset_pipeline")
def check_blender_status() -> dict:
"""Quick Blender availability check on the render-worker."""
import subprocess
from app.services.render_blender import find_blender
blender_bin = find_blender()
if not blender_bin:
return {"available": False, "blender_bin": "", "version": ""}
try:
result = subprocess.run(
[blender_bin, "--version"],
capture_output=True, text=True, timeout=10,
)
version = result.stdout.strip().split("\n")[0] if result.returncode == 0 else "unknown"
except Exception:
version = "unknown"
return {"available": True, "blender_bin": blender_bin, "version": version}
@celery_app.task(name="app.tasks.gpu_tasks.probe_gpu", queue="asset_pipeline")
def probe_gpu() -> dict:
"""Run Blender GPU probe on the render-worker. Stores result in system_settings."""
+109 -404
View File
@@ -1,7 +1,7 @@
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, Wrench, HardDrive, Mail, Monitor, Eye, Box } from 'lucide-react'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle, HardDrive, Mail, Monitor, Eye, Box } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
@@ -33,7 +33,6 @@ export default function AdminPage() {
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
const { data: users } = useQuery({
queryKey: ['admin-users'],
@@ -88,12 +87,8 @@ export default function AdminPage() {
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
@@ -101,17 +96,10 @@ export default function AdminPage() {
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
}
@@ -140,15 +128,6 @@ export default function AdminPage() {
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<string, { available: boolean; note: string; url: string | null }>
},
refetchInterval: 30000,
})
const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => api.put('/admin/settings', data),
onSuccess: () => {
@@ -325,6 +304,17 @@ export default function AdminPage() {
type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
// Blender Status (via Celery on render-worker)
const { data: blenderStatus, refetch: refetchBlenderStatus, isFetching: blenderStatusFetching } = useQuery({
queryKey: ['blender-status'],
queryFn: async () => {
const res = await api.get('/admin/settings/renderer-status')
return res.data.blender as { available: boolean; blender_bin: string; version: string; error?: string }
},
enabled: isAdmin && activeTab === 'system',
staleTime: 60_000,
})
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
@@ -549,17 +539,6 @@ export default function AdminPage() {
<p className="text-sm text-content-muted mb-4">Render engine selection, sample counts, smooth angle, and performance tuning for Blender 5.</p>
<div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<span className="text-sm font-medium text-content-secondary">Service Status</span>
<button
onClick={() => refetchStatus()}
className="text-content-muted hover:text-content-secondary transition-colors"
title="Refresh service status"
>
<RefreshCw size={15} />
</button>
</div>
<div className="p-6 space-y-6">
{/* ── Render Quality ─────────────────────────────────────── */}
<div className="space-y-4">
@@ -681,41 +660,6 @@ export default function AdminPage() {
</div>
</div>
{/* ── Performance ────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Performance</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
<input
type="number"
min={1} max={16} step={1}
value={blender.blender_max_concurrent_renders ?? 3}
onChange={(e) => 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"
/>
<p className="text-xs text-content-muted">
Max parallel Blender render jobs (1-16). Higher values use more RAM (~400 MB each). Applied live without restart.
</p>
</div>
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span>
<input
type="number"
min={10} max={10080} step={10}
value={blender.render_stall_timeout_minutes ?? 120}
onChange={(e) => 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"
/>
<p className="text-xs text-content-muted">
Minutes before a stuck render job is auto-restarted (10-10080). Checked every 5 min by the watchdog.
</p>
</div>
</div>
</div>
{/* Save button */}
{Object.keys(blenderDraft).length > 0 && (
<button
@@ -727,182 +671,6 @@ export default function AdminPage() {
</button>
)}
{/* ── Output ────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Output</p>
<div className="flex items-center gap-4 flex-wrap">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Thumbnail format</label>
{(['jpg', 'png'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => updateSettingsMut.mutate({ thumbnail_format: fmt })}
disabled={updateSettingsMut.isPending}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
settings?.thumbnail_format === fmt
? 'text-white'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={settings?.thumbnail_format === fmt ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{fmt === 'jpg' ? 'JPEG (smaller)' : 'PNG (lossless)'}
</button>
))}
<p className="text-xs text-content-muted">
{settings?.thumbnail_format === 'jpg'
? 'JPEG -- ~3-5x smaller files, minimal quality loss at 92% quality.'
: 'PNG -- lossless, larger files.'}
</p>
</div>
{/* 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 (
<div className="flex items-start gap-4 pt-1">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28 pt-1">Product thumbnail:</label>
<div className="flex flex-col gap-2 min-w-[280px]">
{priorityList.map((entry, i) => (
<div
key={entry + i}
className={`flex items-center gap-2 border rounded-lg px-3 py-2 ${entryColor(entry)}`}
>
<span className="text-xs font-mono text-content-muted w-4 shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{entryLabel(entry)}</span>
{entry !== 'cad_thumbnail' && entry !== 'latest_render' && (
<span className="text-xs text-content-muted">newest completed render</span>
)}
</div>
<button
disabled={i === 0 || updateSettingsMut.isPending}
onClick={() => moveUp(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move up"
>
<ChevronUp size={14} />
</button>
<button
disabled={i === priorityList.length - 1 || updateSettingsMut.isPending}
onClick={() => moveDown(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move down"
>
<ChevronDown size={14} />
</button>
<button
disabled={updateSettingsMut.isPending}
onClick={() => remove(i)}
className="p-0.5 rounded hover:bg-status-error-bg text-content-muted hover:text-red-600"
title="Remove"
>
<X size={14} />
</button>
</div>
))}
{addableOptions.length > 0 && (
<div className="flex items-center gap-2">
<select
value={priorityNewEntry}
onChange={(e) => setPriorityNewEntry(e.target.value)}
className="flex-1 px-2 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
>
<option value="">Add entry...</option>
{addableOptions.map((v) => (
<option key={v} value={v}>{entryLabel(v)}</option>
))}
</select>
<button
disabled={!priorityNewEntry || updateSettingsMut.isPending}
onClick={addEntry}
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1 disabled:opacity-40"
>
<Plus size={13} /> Add
</button>
</div>
)}
<p className="text-xs text-content-muted">
Sources are tried top to bottom. For specific output types, the <span className="font-medium">newest completed render</span> of that type is used. "CAD Thumbnail" always matches and stops the search.
</p>
</div>
</div>
)
})()}
</div>{/* end Output */}
{/* ── Service Status ─────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
<div
key={name}
className={`rounded-lg border p-3 flex items-start gap-2.5 ${
info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt'
}`}
>
{info.available
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" />
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
}
<div className="min-w-0">
<p className="text-sm font-semibold text-content capitalize">{name}</p>
<p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p>
</div>
</div>
))}
{!rendererStatus && (
<div className="flex items-center gap-2 text-xs text-content-muted p-2">
<Clock size={13} /> Checking service status...
</div>
)}
</div>
</div>
</div>
</div>
</div>
@@ -927,7 +695,7 @@ export default function AdminPage() {
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 },
values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3 },
},
{
label: 'Standard',
@@ -936,7 +704,7 @@ export default function AdminPage() {
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 },
values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1 },
},
{
label: 'Fine',
@@ -945,7 +713,7 @@ export default function AdminPage() {
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 },
values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05 },
},
{
label: 'Ultra',
@@ -954,14 +722,12 @@ export default function AdminPage() {
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 },
values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02 },
},
]
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
tess.scene_angular_deflection === preset.values.scene_angular_deflection
const isCustom = !PRESETS.some(isActive)
return (
<div>
@@ -1041,70 +807,36 @@ export default function AdminPage() {
{/* Manual inputs */}
{showAdvancedTess && (<>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">3D Viewer + USD Master</p>
<p className="text-xs text-content-muted mt-0.5">Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.</p>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.01"
min="0.001"
max="10"
value={tess.scene_linear_deflection ?? 0.1}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.01"
min="0.01"
max="1.5"
value={tess.scene_angular_deflection ?? 0.1}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Scene Deflection</p>
<p className="text-xs text-content-muted mt-0.5">Controls mesh quality for the 3D viewer GLB, USD scene, and Blender renders.</p>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Blender Render Output</p>
<p className="text-xs text-content-muted mt-0.5">Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.</p>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.005"
min="0.001"
max="10"
value={tess.render_linear_deflection ?? 0.03}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.005"
min="0.005"
max="1.5"
value={tess.render_angular_deflection ?? 0.05}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.01"
min="0.001"
max="10"
value={tess.scene_linear_deflection ?? 0.1}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.01"
min="0.01"
max="1.5"
value={tess.scene_angular_deflection ?? 0.1}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
</div>
</>)}
@@ -1124,51 +856,16 @@ export default function AdminPage() {
</div>
</div>
{/* ── 3D Viewer & GLB Export ────────────────────────────────────── */}
{/* ── 3D Viewer ────────────────────────────────────────────────── */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<Eye size={18} className="text-accent" />
<h2 className="text-base font-semibold text-content">3D Viewer & GLB Export</h2>
<h2 className="text-base font-semibold text-content">3D Viewer</h2>
</div>
<p className="text-sm text-content-muted mb-4">Settings for the interactive 3D viewer and GLB geometry export pipeline.</p>
<p className="text-sm text-content-muted mb-4">Camera zoom limits for the interactive 3D viewer.</p>
<div className="card">
<div className="p-4 space-y-4">
{/* Scale Factor */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Scale Factor (mm to m)
</label>
<input
type="number"
step="0.0001"
min="0.0001"
max="1"
value={viewer3d.gltf_scale_factor ?? 0.001}
onChange={e => setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))}
className="input w-full"
/>
<p className="text-xs text-content-muted mt-0.5">Default 0.001 converts mm to meters</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Smooth Normals
</label>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={viewer3d.gltf_smooth_normals ?? true}
onChange={e => setViewerDraft(d => ({ ...d, gltf_smooth_normals: e.target.checked }))}
className="w-4 h-4"
/>
<span className="text-sm text-content">Apply Laplacian smoothing on export</span>
</label>
<p className="text-xs text-content-muted mt-1">Smooths surface normals during GLB export for a less faceted look in the 3D viewer.</p>
</div>
</div>
{/* Camera / Zoom Limits */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
@@ -1204,57 +901,6 @@ export default function AdminPage() {
</div>
</div>
{/* PBR Material Quality */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Material Mode
</label>
<select
value={viewer3d.gltf_material_quality ?? 'pbr_colors'}
onChange={e => setViewerDraft(d => ({ ...d, gltf_material_quality: e.target.value }))}
title="Controls what material data is embedded in exported GLB files. 'None' exports bare geometry; 'PBR Colors' bakes part colours into PBR materials."
className="input w-full"
>
<option value="none">None (geometry only)</option>
<option value="pbr_colors">PBR Colors (from part colors)</option>
</select>
<p className="text-xs text-content-muted mt-1">Material data embedded in exported GLB files.</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Roughness (0-1)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_roughness ?? 0.4}
onChange={e => 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"
/>
<p className="text-xs text-content-muted mt-1">0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Metallic (0-1)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_metallic ?? 0.6}
onChange={e => 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"
/>
<p className="text-xs text-content-muted mt-1">0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
@@ -1264,7 +910,7 @@ export default function AdminPage() {
disabled={Object.keys(viewerDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save 3D Settings
Save 3D Viewer Settings
</button>
{Object.keys(viewerDraft).length > 0 && (
<button
@@ -1434,6 +1080,65 @@ export default function AdminPage() {
{/* ================================================================== */}
{activeTab === 'system' && isAdmin && <>
{/* ── Blender Status ─────────────────────────────────────────────── */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">
<Cpu size={18} className="text-accent" />
<h2 className="text-base font-semibold text-content">Render Worker Status</h2>
</div>
<p className="text-sm text-content-muted mb-4">Checks Blender availability on the render-worker container via Celery.</p>
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{blenderStatus ? (
blenderStatus.available ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-status-success-bg text-status-success-text">
<CheckCircle2 size={12} /> Blender Available
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-status-error-bg text-status-error-text">
<XCircle size={12} /> Blender Unavailable
</span>
)
) : (
<span className="text-xs text-content-muted">Not checked yet</span>
)}
</div>
<button
onClick={() => refetchBlenderStatus()}
disabled={blenderStatusFetching}
className="btn-secondary text-sm flex items-center gap-1.5"
>
<RefreshCw size={13} className={blenderStatusFetching ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{blenderStatus && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
{blenderStatus.version && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Version</span>
<span className="text-xs text-content font-mono">{blenderStatus.version}</span>
</div>
)}
{blenderStatus.blender_bin && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Binary</span>
<span className="text-xs text-content font-mono">{blenderStatus.blender_bin}</span>
</div>
)}
{blenderStatus.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-24 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{blenderStatus.error}</span>
</div>
)}
</div>
)}
</div>
</div>
{/* ── Reprocessing ──────────────────────────────────────────────── */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1">