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