feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan
Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
transform (X, -Z, Y) * 0.001
Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings
Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints
Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults
Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client
Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+399
-146
@@ -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 } from 'lucide-react'
|
||||
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'
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -29,6 +30,8 @@ export default function AdminPage() {
|
||||
const isAdmin = checkIsAdmin(user)
|
||||
const [showNewUser, setShowNewUser] = useState(false)
|
||||
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
|
||||
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>('')
|
||||
|
||||
@@ -68,6 +71,17 @@ export default function AdminPage() {
|
||||
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
|
||||
@@ -167,6 +181,15 @@ export default function AdminPage() {
|
||||
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) => {
|
||||
@@ -175,6 +198,14 @@ export default function AdminPage() {
|
||||
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) => {
|
||||
@@ -265,19 +296,65 @@ export default function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
|
||||
const [activeTab, setActiveTab] = useState<AdminTab>('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 (
|
||||
<div>
|
||||
{/* Tab header */}
|
||||
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
|
||||
<AlertCircle size={14} />
|
||||
Unsaved changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 -mb-px">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<h1 className="text-2xl font-bold text-content">Admin</h1>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Pricing Summary */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<PricingSummaryCard />
|
||||
{activeTab === 'overview' && <PricingSummaryCard />}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Users (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{isAdmin && <div className="card">
|
||||
{activeTab === 'users' && isAdmin && <div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<h2 className="font-semibold text-content">Users</h2>
|
||||
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
|
||||
@@ -329,35 +406,102 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border-light">
|
||||
{users?.map((user) => (
|
||||
<div key={user.id} className="flex items-center px-6 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-content">{user.full_name}</p>
|
||||
<p className="text-xs text-content-muted">{user.email}</p>
|
||||
</div>
|
||||
<span className={`badge mr-4 ${checkIsAdmin(user) ? 'badge-green' : 'badge-gray'}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
<span className={`badge mr-4 ${user.is_active ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Delete User',
|
||||
message: `Delete user "${user.email}"? This cannot be undone.`,
|
||||
onConfirm: () => {
|
||||
deleteUserMut.mutate(user.id)
|
||||
setConfirmState((s) => ({ ...s, open: false }))
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{users?.map((u) => (
|
||||
<div key={u.id}>
|
||||
{editingUserId === u.id ? (
|
||||
<div className="px-6 py-3 bg-surface-alt space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-content-muted block mb-1">Full name</label>
|
||||
<input
|
||||
value={editUserDraft.full_name}
|
||||
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
|
||||
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-content-muted block mb-1">Role</label>
|
||||
<select
|
||||
value={editUserDraft.role}
|
||||
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
|
||||
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
|
||||
>
|
||||
<option value="client">Client</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="global_admin">Global Admin</option>
|
||||
<option value="tenant_admin">Tenant Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editUserDraft.is_active}
|
||||
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
|
||||
className="rounded"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => setEditingUserId(null)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
|
||||
disabled={updateUserMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{updateUserMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center px-6 py-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-content">{u.full_name}</p>
|
||||
<p className="text-xs text-content-muted">{u.email}</p>
|
||||
</div>
|
||||
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
|
||||
{u.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingUserId(u.id)
|
||||
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
|
||||
}}
|
||||
className="text-content-muted hover:text-accent transition-colors mr-3"
|
||||
title="Edit user"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Delete User',
|
||||
message: `Delete user "${u.email}"? This cannot be undone.`,
|
||||
onConfirm: () => {
|
||||
deleteUserMut.mutate(u.id)
|
||||
setConfirmState((s) => ({ ...s, open: false }))
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -366,7 +510,7 @@ export default function AdminPage() {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Blender Render Settings (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{isAdmin && <div className="card">
|
||||
{activeTab === 'render' && isAdmin && <div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={16} className="text-content-muted" />
|
||||
@@ -788,6 +932,34 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Registers existing renders & CAD thumbnails in the Media Browser.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => cleanupOrphanedMut.mutate()}
|
||||
disabled={cleanupOrphanedMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start"
|
||||
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
|
||||
>
|
||||
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
|
||||
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
|
||||
cleanupOrphanedCadMut.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={cleanupOrphanedCadMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start"
|
||||
title="Delete STEP files and thumbnails that are no longer linked to any product"
|
||||
>
|
||||
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
|
||||
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => reextractMetadataMut.mutate()}
|
||||
@@ -817,10 +989,28 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Global Render Positions (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{activeTab === 'render' && isAdmin && <div className="card">
|
||||
<div className="p-4 border-b border-border-light flex items-center gap-2">
|
||||
<Settings size={16} className="text-content-muted" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Global Render Positions</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Camera rotation presets available to all products. Per-product positions override these.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<GlobalRenderPositionsPanel />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Render Templates (admin/PM) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'render' && <div className="card">
|
||||
<div className="p-4 border-b border-border-light flex items-center gap-2">
|
||||
<FileBox size={16} className="text-content-muted" />
|
||||
<div>
|
||||
@@ -836,17 +1026,17 @@ export default function AdminPage() {
|
||||
<div className="border-t border-border-light p-4">
|
||||
<MaterialLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Asset Libraries */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<AssetLibraryPanel />
|
||||
{activeTab === 'libraries' && <AssetLibraryPanel />}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Output Types */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'pricing' && <div className="card">
|
||||
<div className="p-4 border-b border-border-light flex items-center gap-2">
|
||||
<Layers size={16} className="text-content-muted" />
|
||||
<div>
|
||||
@@ -857,12 +1047,12 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
<OutputTypeTable />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Pricing Tiers */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'pricing' && <div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center gap-2">
|
||||
<DollarSign size={16} className="text-content-muted" />
|
||||
<div>
|
||||
@@ -873,12 +1063,12 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
<PricingTierTable />
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* E-Mail / SMTP Settings */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{isAdmin && (
|
||||
{activeTab === 'config' && isAdmin && (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-default">
|
||||
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
|
||||
@@ -966,7 +1156,7 @@ export default function AdminPage() {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Templates */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'libraries' && <div className="card">
|
||||
<div className="p-4 border-b border-border-default">
|
||||
<h2 className="font-semibold text-content">Templates</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
@@ -1017,12 +1207,12 @@ export default function AdminPage() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Dashboard Widget Configuration (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{isAdmin && (
|
||||
{activeTab === 'config' && isAdmin && (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center gap-2">
|
||||
<LayoutDashboard size={16} className="text-content-muted" />
|
||||
@@ -1066,7 +1256,7 @@ export default function AdminPage() {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* 3D Viewer & GLB Export Settings */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'render' && <div className="card">
|
||||
<div className="p-4 border-b border-border-default">
|
||||
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
|
||||
<p className="text-sm text-content-muted mt-0.5">
|
||||
@@ -1205,12 +1395,12 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Tessellation Quality */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card">
|
||||
{activeTab === 'render' && <div className="card">
|
||||
<div className="p-4 border-b border-border-default">
|
||||
<h2 className="font-semibold text-content">Tessellation Quality</h2>
|
||||
<p className="text-sm text-content-muted mt-0.5">
|
||||
@@ -1218,6 +1408,58 @@ export default function AdminPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Presets */}
|
||||
{(() => {
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'Draft',
|
||||
description: 'Fast export, visible faceting on large curves',
|
||||
color: 'border-amber-400 text-amber-700',
|
||||
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
|
||||
},
|
||||
{
|
||||
label: 'Standard',
|
||||
description: 'Smooth curves, no fan artifacts — recommended',
|
||||
color: 'border-blue-400 text-blue-700',
|
||||
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
|
||||
},
|
||||
{
|
||||
label: 'Fine',
|
||||
description: 'Maximum quality, very large files, slow export',
|
||||
color: 'border-emerald-400 text-emerald-700',
|
||||
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
|
||||
},
|
||||
]
|
||||
const isActive = (preset: typeof PRESETS[0]) =>
|
||||
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
|
||||
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
|
||||
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
|
||||
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
|
||||
<div className="flex gap-3">
|
||||
{PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setTessellationDraft(preset.values)}
|
||||
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${isActive(preset) ? preset.color + ' bg-opacity-10' : 'border-border-default text-content hover:border-blue-300'}`}
|
||||
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
|
||||
>
|
||||
<div className="font-semibold text-sm">{preset.label}</div>
|
||||
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
|
||||
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
|
||||
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
|
||||
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Manual inputs */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
|
||||
@@ -1238,10 +1480,10 @@ export default function AdminPage() {
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0.05"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max="1.5"
|
||||
value={tess.gltf_preview_angular_deflection ?? 0.5}
|
||||
value={tess.gltf_preview_angular_deflection ?? 0.1}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_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"
|
||||
/>
|
||||
@@ -1268,10 +1510,10 @@ export default function AdminPage() {
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0.05"
|
||||
step="0.005"
|
||||
min="0.005"
|
||||
max="1.5"
|
||||
value={tess.gltf_production_angular_deflection ?? 0.2}
|
||||
value={tess.gltf_production_angular_deflection ?? 0.05}
|
||||
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_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"
|
||||
/>
|
||||
@@ -1293,12 +1535,12 @@ export default function AdminPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Material Library link */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="card p-5 flex items-center justify-between">
|
||||
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Material Library</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
@@ -1308,7 +1550,106 @@ export default function AdminPage() {
|
||||
<Link to="/materials" className="btn-secondary text-sm">
|
||||
Open Material Library →
|
||||
</Link>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* GPU Status */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{activeTab === 'render' && isAdmin && (
|
||||
<div className="card">
|
||||
<button
|
||||
className="w-full p-5 flex items-center justify-between text-left"
|
||||
onClick={() => setGpuProbeExpanded((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap size={18} className="text-content-secondary" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">GPU Status</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Verify that the render-worker is using the GPU (not CPU fallback).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{gpuStatusBadge()}
|
||||
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{gpuProbeExpanded && (
|
||||
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRunGpuCheck}
|
||||
disabled={gpuProbing}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{gpuProbing ? (
|
||||
<><RefreshCw size={14} className="animate-spin" /> Running probe…</>
|
||||
) : (
|
||||
<><Zap size={14} /> Run GPU Check</>
|
||||
)}
|
||||
</button>
|
||||
{gpuProbeResult && (
|
||||
<span className="text-xs text-content-muted">
|
||||
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gpuProbeResult && (
|
||||
<div className="bg-surface-alt rounded-md p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
|
||||
{gpuStatusBadge()}
|
||||
</div>
|
||||
{gpuProbeResult.device_type && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
|
||||
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
|
||||
<div className="space-y-0.5">
|
||||
{gpuProbeResult.devices.map((d: string, i: number) => (
|
||||
<span key={i} className="block text-xs text-content">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{gpuProbeResult.render_time_s != null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
|
||||
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
|
||||
</div>
|
||||
)}
|
||||
{gpuProbeResult.error && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
|
||||
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!gpuProbeResult && !gpuProbing && (
|
||||
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={confirmState.onConfirm}
|
||||
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1393,6 +1734,7 @@ function AssetLibraryPanel() {
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [newFile, setNewFile] = useState<File | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(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'],
|
||||
@@ -1586,95 +1928,6 @@ function AssetLibraryPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* GPU Status (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{isAdmin && (
|
||||
<div className="card">
|
||||
<button
|
||||
className="w-full p-4 flex items-center justify-between text-left"
|
||||
onClick={() => setGpuProbeExpanded((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} className="text-content-muted" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">GPU Status</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Check Blender GPU availability on the render worker
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{gpuStatusBadge()}
|
||||
{gpuProbeResult?.probed_at && (
|
||||
<span className="text-xs text-content-muted">
|
||||
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
|
||||
</span>
|
||||
)}
|
||||
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{gpuProbeExpanded && (
|
||||
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRunGpuCheck}
|
||||
disabled={gpuProbing}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{gpuProbing
|
||||
? <RefreshCw size={14} className="animate-spin" />
|
||||
: <Zap size={14} />
|
||||
}
|
||||
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
|
||||
</button>
|
||||
{gpuProbing && (
|
||||
<span className="text-xs text-content-muted">
|
||||
Polling for result (up to 45s)…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gpuProbeResult && (
|
||||
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-content-secondary w-28 shrink-0">Status</span>
|
||||
{gpuStatusBadge()}
|
||||
</div>
|
||||
{gpuProbeResult.device_type && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-content-secondary w-28 shrink-0">Device type</span>
|
||||
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{gpuProbeResult.error && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-content-secondary w-28 shrink-0">Error</span>
|
||||
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{gpuProbeResult.probed_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
|
||||
<span className="text-content-muted text-xs">
|
||||
{new Date(gpuProbeResult.probed_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!gpuProbeResult && !gpuProbing && (
|
||||
<p className="text-sm text-content-muted">
|
||||
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title={confirmState.title}
|
||||
|
||||
Reference in New Issue
Block a user