feat: GPU rendering + material matching + perf improvements

- GPU: fix Cycles device activation order — set compute_device_type
  BEFORE engine init, re-set AFTER open_mainfile wipes preferences
- GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with
  Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts
- Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys
  and add prefix fallback in _apply_material_library (blender_render.py)
- Material: production GLB now uses get_material_library_path() which
  checks active AssetLibrary instead of empty legacy system setting
- Admin: RenderTemplateTable multi-select output types (M2M frontend)
- Admin: MaterialLibraryPanel replaced with link to Asset Libraries
- UX: move Toaster to top-left to avoid dispatch button overlap
- SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries
- Logging: flush=True on all Blender progress prints, stdout reconfigure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 19:05:03 +01:00
parent 934728da77
commit ee6eb34b4c
34 changed files with 1274 additions and 511 deletions
+125 -66
View File
@@ -9,8 +9,6 @@ import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import { useAuthStore } from '../store/auth'
import { getMaterialLibraryInfo, uploadMaterialLibrary, deleteMaterialLibrary } from '../api/renderTemplates'
import type { MaterialLibraryInfo } from '../api/renderTemplates'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
import {
@@ -92,6 +90,10 @@ export default function AdminPage() {
gltf_material_quality: string
gltf_pbr_roughness: number
gltf_pbr_metallic: number
gltf_preview_linear_deflection: number
gltf_preview_angular_deflection: number
gltf_production_linear_deflection: number
gltf_production_angular_deflection: number
}
const { data: settings } = useQuery({
@@ -115,6 +117,9 @@ export default function AdminPage() {
const [viewerDraft, setViewerDraft] = useState<Partial<Settings>>({})
const viewer3d = { ...settings, ...viewerDraft } as Settings
const [tessellationDraft, setTessellationDraft] = useState<Partial<Settings>>({})
const tess = { ...settings, ...tessellationDraft } as Settings
const { data: rendererStatus, refetch: refetchStatus } = useQuery({
queryKey: ['renderer-status'],
queryFn: async () => {
@@ -166,6 +171,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
toast.success(res.data.message || 'Stuck files recovered')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const seedWorkflowsMut = useMutation({
mutationFn: () => api.post('/admin/settings/seed-workflows'),
onSuccess: (res) => {
@@ -636,6 +649,18 @@ export default function AdminPage() {
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<button
onClick={() => recoverStuckMut.mutate()}
disabled={recoverStuckMut.isPending}
className="btn-secondary text-sm w-full justify-start border-amber-400/40 text-amber-600 hover:bg-amber-50"
title="Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'. Runs automatically every 5 min."
>
<RefreshCw size={14} className={recoverStuckMut.isPending ? 'animate-spin' : ''} />
{recoverStuckMut.isPending ? 'Recovering…' : 'Recover Stuck Processing'}
</button>
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => processUnprocessedMut.mutate()}
@@ -1091,6 +1116,94 @@ export default function AdminPage() {
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<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">
OCC mesh precision for GLB export. Lower values = finer mesh + larger files + slower export.
</p>
</div>
<div className="p-4 space-y-6">
<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>
<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.gltf_preview_linear_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_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.05"
min="0.05"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Geometry GLB".</p>
</div>
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Production (Production GLB)</p>
<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.gltf_production_linear_deflection ?? 0.03}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_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.05"
min="0.05"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Production GLB". Smaller = smoother surfaces.</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => { updateSettingsMut.mutate(tessellationDraft); setTessellationDraft({}) }}
disabled={Object.keys(tessellationDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save Tessellation Settings
</button>
{Object.keys(tessellationDraft).length > 0 && (
<button onClick={() => setTessellationDraft({})} className="btn-secondary">Reset</button>
)}
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
@@ -1111,73 +1224,19 @@ export default function AdminPage() {
function MaterialLibraryPanel() {
const qc = useQueryClient()
const { data: info } = useQuery({
queryKey: ['material-library-info'],
queryFn: getMaterialLibraryInfo,
})
const uploadMut = useMutation({
mutationFn: (file: File) => uploadMaterialLibrary(file),
onSuccess: () => {
toast.success('Material library uploaded')
qc.invalidateQueries({ queryKey: ['material-library-info'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
})
const deleteMut = useMutation({
mutationFn: deleteMaterialLibrary,
onSuccess: () => {
toast.success('Material library removed')
qc.invalidateQueries({ queryKey: ['material-library-info'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) uploadMut.mutate(file)
e.target.value = ''
}
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-content-secondary">Material Library (.blend)</h4>
<div className="space-y-2">
<h4 className="text-sm font-semibold text-content-secondary">Material Library</h4>
<p className="text-xs text-content-muted">
Materials in this file can be assigned to product parts when "Material Replace" is enabled on a template.
Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.
</p>
{info?.exists ? (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border-default bg-status-success-bg">
<CheckCircle2 size={16} className="text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content">{info.filename}</p>
<p className="text-xs text-content-muted">
{info.size_bytes ? `${(info.size_bytes / 1024 / 1024).toFixed(1)} MB` : ''}
</p>
</div>
<label className="flex items-center gap-1 px-3 py-1.5 text-sm border border-border-default rounded-md bg-surface text-content-secondary hover:bg-surface-hover cursor-pointer">
<Upload size={14} /> Replace
<input type="file" accept=".blend" className="hidden" onChange={handleFileChange} />
</label>
<button
onClick={() => { if (confirm('Remove material library?')) deleteMut.mutate() }}
disabled={deleteMut.isPending}
className="p-1.5 text-red-500 hover:bg-red-50 rounded"
title="Remove library"
>
<Trash2 size={16} />
</button>
</div>
) : (
<label className="flex items-center gap-2 px-4 py-3 border-2 border-dashed border-border-default rounded-lg text-sm text-content-muted hover:border-blue-400 hover:text-blue-600 cursor-pointer transition-colors">
<Upload size={16} />
{uploadMut.isPending ? 'Uploading...' : 'Click to upload material library .blend file'}
<input type="file" accept=".blend" className="hidden" onChange={handleFileChange} />
</label>
)}
<Link
to="/asset-libraries"
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-hover"
>
<Layers size={14} />
Manage Asset Libraries
</Link>
</div>
)
}
+7 -2
View File
@@ -79,8 +79,8 @@ export default function CadPreviewPage() {
)
}
// No GLB available yet — show generate prompt
if (!latestGltf) {
// No GLB at all — show generate prompt
if (!latestGltf && !latestProduction) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
@@ -130,8 +130,13 @@ export default function CadPreviewPage() {
onClose={() => navigate(-1)}
geometryGltfUrl={latestGltf?.download_url ?? undefined}
productionGltfUrl={latestProduction?.download_url ?? undefined}
hasGeometryGlb={!!latestGltf}
hasProductionGlb={!!latestProduction}
isGeneratingGeometry={generating}
onGenerateGeometry={() => generateMutation.mutate()}
downloadUrls={{
glb: latestGltf?.download_url ?? undefined,
production: latestProduction?.download_url ?? undefined,
blend: latestBlend?.download_url ?? undefined,
}}
/>
+4
View File
@@ -739,6 +739,10 @@ function OrderLineRow({
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
) : (line.render_status === 'processing' || line.render_status === 'pending') ? (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center animate-pulse">
<Loader2 size={16} className="text-accent animate-spin" />
</div>
) : (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center">
<Box size={16} className="text-content-muted" />
+150 -24
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropzone } from 'react-dropzone'
import {
ArrowLeft, Pencil, Save, X, Box, Image,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -18,9 +18,86 @@ import { listMaterials } from '../api/materials'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction } from '../api/cad'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
}: {
label: string
url: string | null
filename: string
onGenerate: () => void
isGenerating: boolean
title: string
}) {
const token = useAuthStore((s) => s.token)
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (!url || !token) return
setIsDownloading(true)
try {
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
} catch {
toast.error(`Failed to download ${label}`)
} finally {
setIsDownloading(false)
}
}
if (url) {
return (
<div className="flex gap-1 w-full">
<button
className="btn-secondary text-xs flex-1 justify-start"
onClick={handleDownload}
disabled={isDownloading}
title={title}
>
{isDownloading
? <><Loader2 size={12} className="animate-spin" />Downloading</>
: <><Download size={12} />Download {label}</>}
</button>
<button
className="btn-secondary text-xs px-2 shrink-0"
onClick={onGenerate}
disabled={isGenerating}
title={`Re-generate ${label}`}
>
{isGenerating
? <Loader2 size={12} className="animate-spin" />
: <RotateCcw size={12} />}
</button>
</div>
)
}
return (
<button
className="btn-secondary text-xs w-full justify-start"
onClick={onGenerate}
disabled={isGenerating}
title={title}
>
{isGenerating
? <><Loader2 size={12} className="animate-spin" />Queuing</>
: <><Download size={12} />Generate {label}</>}
</button>
)
}
function CadStatusBadge({ status }: { status: string | null }) {
if (!status) return (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">No STEP</span>
@@ -92,6 +169,25 @@ export default function ProductDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product?.id, product?.cad_parsed_objects?.length, product?.cad_part_materials.length])
const cadFileId = product?.cad_file_id ?? null
const { data: geometryGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_geometry'] }),
enabled: !!cadFileId,
staleTime: 0,
})
const { data: productionGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
enabled: !!cadFileId,
staleTime: 0,
})
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
const { data: renders = [] } = useQuery<ProductRender[]>({
queryKey: ['product-renders', id],
queryFn: () => getProductRenders(id!),
@@ -234,6 +330,33 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const generateGeometryGlbMut = useMutation({
mutationFn: () => generateGltfGeometry(product!.cad_file_id!),
onSuccess: () => {
toast.info('Geometry GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_geometry'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
})
const generateProductionGlbMut = useMutation({
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
onSuccess: () => {
toast.info('Production GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
})
const resetStuckMut = useMutation({
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
onSuccess: (res) => {
toast.success(res.message)
qc.invalidateQueries({ queryKey: ['product', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reset failed'),
})
const reprocessMut = useMutation({
mutationFn: () => reprocessProduct(id!),
onSuccess: () => {
@@ -545,6 +668,17 @@ export default function ProductDetailPage() {
{isPrivileged && (
<>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
{product.processing_status === 'processing' && (
<button
className="btn-secondary text-xs w-full justify-start border-amber-400/40 text-amber-700 hover:bg-amber-50"
onClick={() => resetStuckMut.mutate()}
disabled={resetStuckMut.isPending}
title="Force-reset a CAD file stuck in 'processing'. Use if the spinner never goes away."
>
<Loader2 size={12} className={resetStuckMut.isPending ? 'animate-spin' : ''} />
{resetStuckMut.isPending ? 'Resetting…' : 'Reset Stuck Processing'}
</button>
)}
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs w-full justify-start" disabled={cadUploadMut.isPending}>
@@ -573,30 +707,22 @@ export default function ProductDetailPage() {
</div>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('Geometry GLB export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
<GlbDownloadButton
label="Geometry GLB"
url={geometryGlbUrl}
filename={`${product.name ?? product.pim_id}_geometry.glb`}
onGenerate={() => generateGeometryGlbMut.mutate()}
isGenerating={generateGeometryGlbMut.isPending}
title="Export geometry GLB directly from STEP via OCC (no Blender)"
>
<Download size={12} />
Generate Geometry GLB
</button>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfProduction(product.cad_file_id!)
.then(() => toast.info('Production GLB export queued'))
.catch(() => toast.error('Failed to queue production GLB export'))
}
/>
<GlbDownloadButton
label="Production GLB"
url={productionGlbUrl}
filename={`${product.name ?? product.pim_id}_production.glb`}
onGenerate={() => generateProductionGlbMut.mutate()}
isGenerating={generateProductionGlbMut.isPending}
title="Export production GLB with PBR materials via Blender"
>
<Download size={12} />
Generate Production GLB
</button>
/>
</div>
</>
)}
+7 -4
View File
@@ -389,10 +389,11 @@ function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProp
<label className="block text-sm text-content-secondary mb-1">Typ</label>
<div className="grid grid-cols-2 gap-2">
{([
{ value: 'still', label: 'Still', desc: 'Einzelbild PNG' },
{ value: 'turntable', label: 'Turntable', desc: 'Animations-MP4' },
{ value: 'multi_angle', label: 'Multi-Angle', desc: 'Mehrere Winkel' },
{ value: 'custom', label: 'Custom', desc: 'Freier Editor' },
{ value: 'still', label: 'Still', desc: 'Single PNG image' },
{ value: 'turntable', label: 'Turntable', desc: 'Animation MP4' },
{ value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' },
{ value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' },
{ value: 'custom', label: 'Custom', desc: 'Free canvas' },
] as { value: WorkflowConfig['type']; label: string; desc: string }[]).map(opt => (
<button
key={opt.value}
@@ -650,6 +651,7 @@ export default function WorkflowEditor() {
still: 'Still',
turntable: 'Turntable',
multi_angle: 'Multi-Angle',
still_with_exports: 'Still + GLB',
custom: 'Custom',
}
@@ -657,6 +659,7 @@ export default function WorkflowEditor() {
still: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
turntable: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
multi_angle: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
still_with_exports: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
custom: 'bg-surface-hover text-content-muted',
}