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
@@ -186,7 +186,8 @@ export default function OutputTypeTable() {
return (
<div>
<table className="w-full text-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary">Name</th>
@@ -1025,6 +1026,7 @@ export default function OutputTypeTable() {
)}
</tbody>
</table>
</div>
{!showAdd && (
<div className="px-4 py-3">
@@ -115,7 +115,7 @@ export default function RenderTemplateTable() {
setEditDraft({
name: t.name,
category_key: t.category_key,
output_type_id: t.output_type_id,
output_type_ids: t.output_type_ids ?? [],
target_collection: t.target_collection,
material_replace_enabled: t.material_replace_enabled,
lighting_only: t.lighting_only,
@@ -320,18 +320,39 @@ export default function RenderTemplateTable() {
</td>
<td className="px-3 py-2">
{isEditing ? (
<select
className={inputCls}
value={editDraft.output_type_id ?? t.output_type_id ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, output_type_id: e.target.value || null })}
>
<option value="">Any</option>
{outputTypes?.map((ot: OutputType) => (
<option key={ot.id} value={ot.id}>{ot.name}</option>
))}
</select>
<div className="flex flex-col gap-0.5 max-h-32 overflow-y-auto">
{outputTypes?.map((ot: OutputType) => {
const checked = (editDraft.output_type_ids ?? []).includes(ot.id)
return (
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={checked}
onChange={() => {
const current = editDraft.output_type_ids ?? []
const next = checked
? current.filter((id: string) => id !== ot.id)
: [...current, ot.id]
setEditDraft({ ...editDraft, output_type_ids: next })
}}
/>
{ot.name}
</label>
)
})}
</div>
) : (
t.output_type_name || <span className="text-content-muted">Any</span>
t.output_type_names && t.output_type_names.length > 0 ? (
<div className="flex flex-wrap gap-1">
{t.output_type_names.map((name, i) => (
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
{name}
</span>
))}
</div>
) : (
<span className="text-content-muted">Any</span>
)
)}
</td>
<td className="px-3 py-2">
@@ -4,13 +4,14 @@ import { Canvas } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun } from 'lucide-react'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, Cpu } from 'lucide-react'
import { toast } from 'sonner'
import { getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad'
import { useAuthStore } from '../../store/auth'
type ViewMode = 'solid' | 'wireframe'
type GlbSource = 'geometry' | 'production'
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
@@ -91,6 +92,7 @@ export default function InlineCadViewer({
const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const { data: gltfAssets } = useQuery({
@@ -100,20 +102,35 @@ export default function InlineCadViewer({
refetchInterval: generating ? 4_000 : false,
})
const { data: productionAssets } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_production'] }),
staleTime: 0,
})
useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
}, [generating, gltfAssets])
const latestAsset = gltfAssets?.[0]
const downloadUrl = latestAsset?.download_url
const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasProduction = (productionAssets?.length ?? 0) > 0
// Auto-switch to production if it's the only available source
useEffect(() => {
if (!hasGeometry && hasProduction) setGlbSource('production')
}, [hasGeometry, hasProduction])
const activeDownloadUrl =
glbSource === 'production'
? productionAssets?.[0]?.download_url
: gltfAssets?.[0]?.download_url
useEffect(() => {
if (!downloadUrl || !token) return
// Clear stale mesh immediately so the loading spinner shows instead of old geometry
if (!activeDownloadUrl || !token) return
setGlbBlobUrl(null)
setLoadingGlb(true)
let blobUrl = ''
fetch(downloadUrl, { headers: { Authorization: `Bearer ${token}` } })
fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => r.blob())
.then((blob) => {
blobUrl = URL.createObjectURL(blob)
@@ -124,7 +141,7 @@ export default function InlineCadViewer({
return () => {
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
}, [downloadUrl, token])
}, [activeDownloadUrl, token])
const generateMut = useMutation({
mutationFn: () => generateGltfGeometry(cadFileId),
@@ -149,6 +166,19 @@ export default function InlineCadViewer({
{/* Toolbar */}
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
{/* Geometry / Production toggle — only when both exist */}
{hasGeometry && hasProduction && (
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC, no materials)">
<Box size={12} /> Geo
</ToolbarBtn>
<div className="w-px bg-white/10" />
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender + PBR materials)">
<Cpu size={12} /> PBR
</ToolbarBtn>
</div>
)}
{/* View mode */}
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
+63 -8
View File
@@ -24,13 +24,22 @@ import api from '../../api/client'
export interface ThreeDViewerProps {
cadFileId: string
onClose: () => void
/** URL for the geometry-only GLB (from STL export) */
/** URL for the geometry-only GLB (from OCC export) */
geometryGltfUrl?: string
/** URL for the production-quality GLB (from asset library render) */
/** URL for the production-quality GLB (Blender + PBR materials) */
productionGltfUrl?: string
/** Download URLs for GLB and .blend assets */
/** Whether a geometry GLB exists (for hint display) */
hasGeometryGlb?: boolean
/** Whether a production GLB exists (for hint display) */
hasProductionGlb?: boolean
/** Called when the user clicks "Generate Geometry GLB" from the hint banner */
onGenerateGeometry?: () => void
/** Whether a geometry GLB generation is in progress */
isGeneratingGeometry?: boolean
/** Download URLs for assets */
downloadUrls?: {
glb?: string
production?: string
blend?: string
}
}
@@ -217,9 +226,15 @@ export default function ThreeDViewer({
onClose,
geometryGltfUrl,
productionGltfUrl,
hasGeometryGlb,
hasProductionGlb,
onGenerateGeometry,
isGeneratingGeometry,
downloadUrls,
}: ThreeDViewerProps) {
const [mode, setMode] = useState<ViewMode>('geometry')
// Default to production mode if only production GLB is available
const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry'
const [mode, setMode] = useState<ViewMode>(initialMode)
const [wireframe, setWireframe] = useState(false)
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
const [capturing, setCapturing] = useState(false)
@@ -232,11 +247,11 @@ export default function ThreeDViewer({
staleTime: 60_000,
})
// Resolve the active model URL based on mode
// Resolve the active model URL: prefer selected mode, fall back to whichever URL exists
const activeUrl =
mode === 'production' && productionGltfUrl
? productionGltfUrl
: geometryGltfUrl
: geometryGltfUrl ?? productionGltfUrl
const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), [])
@@ -312,11 +327,20 @@ export default function ThreeDViewer({
{/* Download buttons */}
{downloadUrls?.glb && (
<button
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}.glb`)}
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}_geometry.glb`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
<Download size={12} />
GLB
Geometry GLB
</button>
)}
{downloadUrls?.production && (
<button
onClick={() => handleDownload(downloadUrls.production!, `${cadFileId}_production.glb`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
<Download size={12} />
Production GLB
</button>
)}
{downloadUrls?.blend && (
@@ -350,6 +374,37 @@ export default function ThreeDViewer({
</div>
</div>
{/* Hint banners */}
{!hasProductionGlb && (
<div className="bg-amber-900/60 border-b border-amber-700/50 px-4 py-2 flex items-center gap-2 text-amber-200 text-xs shrink-0">
<Cpu size={13} className="shrink-0" />
<span>
<strong>No Production GLB yet.</strong> Go to the product page and click "Generate Production GLB" to create a high-quality version with PBR materials and proper mesh smoothing.
</span>
</div>
)}
{!hasGeometryGlb && hasProductionGlb && onGenerateGeometry && (
<div className="bg-blue-900/50 border-b border-blue-700/50 px-4 py-2 flex items-center gap-3 text-blue-200 text-xs shrink-0">
<Box size={13} className="shrink-0" />
<span>
<strong>Showing Production GLB.</strong> Generate a Geometry GLB to enable the mode toggle and compare geometry vs. production quality.
</span>
{isGeneratingGeometry ? (
<span className="flex items-center gap-1 text-blue-300 ml-auto shrink-0">
<Loader2 size={11} className="animate-spin" />
Generating
</span>
) : (
<button
onClick={onGenerateGeometry}
className="ml-auto shrink-0 px-3 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white text-xs font-medium transition-colors"
>
Generate Geometry GLB
</button>
)}
</div>
)}
{/* Viewport */}
<div className="relative flex-1">
{loadError && (
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Settings2, BarChart2, Activity, ImageIcon, DollarSign, Cpu,
@@ -123,8 +123,22 @@ function TimeframeSelector({ widgets }: { widgets: WidgetType[] }) {
)
}
function useLargeScreen() {
const [isLarge, setIsLarge] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth >= 1024 : true
)
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)')
const handler = (e: MediaQueryListEvent) => setIsLarge(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isLarge
}
function DashboardGridInner() {
const [showCustomize, setShowCustomize] = useState(false)
const isLarge = useLargeScreen()
const { data: widgets, isLoading } = useQuery({
queryKey: ['dashboard-config'],
@@ -150,7 +164,7 @@ function DashboardGridInner() {
{/* Grid */}
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[0, 1, 2].map((i) => (
<div key={i} className="h-40 rounded-xl animate-pulse bg-surface-muted" />
))}
@@ -162,7 +176,7 @@ function DashboardGridInner() {
) : (
<div
className="grid gap-4"
style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}
style={isLarge ? { gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' } : { gridTemplateColumns: '1fr' }}
>
{(widgets ?? []).map((w, i) => {
const pos = w.position
@@ -173,12 +187,12 @@ function DashboardGridInner() {
return (
<div
key={`${w.widget_type}-${i}`}
style={{
style={isLarge ? {
gridColumnStart: pos.col + 1,
gridColumnEnd: `span ${pos.w}`,
gridRowStart: pos.row + 1,
gridRowEnd: `span ${pos.h}`,
}}
} : {}}
>
<WidgetContainer title={meta.title} icon={meta.icon}>
<WidgetBody type={w.widget_type as WidgetType} />