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
@@ -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">