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