fix: smooth normals on non-indexed geometry + sync DB in gltf task
InlineCadViewer: STL-derived GLBs have non-indexed geometry (unique vertex per triangle face). computeVertexNormals() on non-indexed geometry produces per-face normals (faceted shading). Fix: mergeVertices() first to create shared/indexed geometry, then computeVertexNormals() averages across adjacent faces → smooth shading. Indexed Blender GLBs are unaffected. generate_gltf_geometry_task: asyncio.run() inside a Celery worker that already runs asyncpg causes 'Future attached to a different loop'. Replace async _store() with sync SQLAlchemy session (matching the rest of the task). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,81 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { OrbitControls, useGLTF } from '@react-three/drei'
|
||||
import { Loader2, Box, RefreshCw } from 'lucide-react'
|
||||
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 { toast } from 'sonner'
|
||||
import { getMediaAssets } from '../../api/media'
|
||||
import { generateGltfGeometry } from '../../api/cad'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
|
||||
function GlbModel({ url }: { url: string }) {
|
||||
type ViewMode = 'solid' | 'wireframe'
|
||||
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
|
||||
|
||||
const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
{ id: 'warehouse', label: 'Warehouse' },
|
||||
{ id: 'sunset', label: 'Sunset' },
|
||||
{ id: 'park', label: 'Park' },
|
||||
{ id: 'city', label: 'City' },
|
||||
]
|
||||
|
||||
function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
|
||||
const { scene } = useGLTF(url)
|
||||
return <primitive object={scene} />
|
||||
const cloned = useRef<THREE.Group | null>(null)
|
||||
|
||||
if (!cloned.current) {
|
||||
cloned.current = scene.clone(true)
|
||||
cloned.current.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||
let geo = obj.geometry.clone()
|
||||
if (!geo.index) {
|
||||
// Non-indexed geometry (STL→GLB via trimesh): each triangle has unique vertices,
|
||||
// so computeVertexNormals() would give per-face normals (flat shading).
|
||||
// mergeVertices() creates an indexed geometry with shared vertices first,
|
||||
// so the subsequent normal computation averages across adjacent faces → smooth.
|
||||
geo = mergeVertices(geo)
|
||||
}
|
||||
// For indexed geometry (Blender GLB): normals are already baked smooth by Blender.
|
||||
// Recomputing here still works correctly because shared vertices average properly.
|
||||
geo.computeVertexNormals()
|
||||
obj.geometry = geo
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
cloned.current?.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material]
|
||||
mats.forEach((m) => {
|
||||
;(m as THREE.MeshStandardMaterial).wireframe = wireframe
|
||||
m.needsUpdate = true
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [wireframe])
|
||||
|
||||
return <primitive object={cloned.current} />
|
||||
}
|
||||
|
||||
const HEIGHT = 420
|
||||
|
||||
function ToolbarBtn({
|
||||
active, onClick, children, title,
|
||||
}: { active: boolean; onClick: () => void; children: React.ReactNode; title?: string }) {
|
||||
return (
|
||||
<button
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors ${
|
||||
active ? 'bg-white/20 text-white' : 'text-white/50 hover:text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InlineCadViewer({
|
||||
@@ -25,6 +90,8 @@ export default function InlineCadViewer({
|
||||
const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null)
|
||||
const [loadingGlb, setLoadingGlb] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('solid')
|
||||
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
|
||||
|
||||
const { data: gltfAssets } = useQuery({
|
||||
queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
|
||||
@@ -33,7 +100,6 @@ export default function InlineCadViewer({
|
||||
refetchInterval: generating ? 4_000 : false,
|
||||
})
|
||||
|
||||
// Stop polling once asset appears
|
||||
useEffect(() => {
|
||||
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
|
||||
}, [generating, gltfAssets])
|
||||
@@ -41,7 +107,6 @@ export default function InlineCadViewer({
|
||||
const latestAsset = gltfAssets?.[0]
|
||||
const downloadUrl = latestAsset?.download_url
|
||||
|
||||
// Fetch GLB with auth when download URL is available
|
||||
useEffect(() => {
|
||||
if (!downloadUrl || !token) return
|
||||
setLoadingGlb(true)
|
||||
@@ -69,31 +134,55 @@ export default function InlineCadViewer({
|
||||
onError: () => toast.error('Failed to queue GLB generation'),
|
||||
})
|
||||
|
||||
// Show GLB viewer
|
||||
if (glbBlobUrl) {
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950"
|
||||
style={{ height: 280 }}
|
||||
>
|
||||
<div className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950 relative" style={{ height: HEIGHT }}>
|
||||
<Canvas camera={{ position: [0, 0, 2], fov: 45 }}>
|
||||
<ambientLight intensity={1.2} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} />
|
||||
<Suspense fallback={null}>
|
||||
<GlbModel url={glbBlobUrl} />
|
||||
<Environment preset={lightPreset} background={false} />
|
||||
<GlbModel url={glbBlobUrl} wireframe={viewMode === 'wireframe'} />
|
||||
</Suspense>
|
||||
<OrbitControls makeDefault />
|
||||
</Canvas>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
|
||||
{/* 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">
|
||||
<Layers size={12} /> Solid
|
||||
</ToolbarBtn>
|
||||
<div className="w-px bg-white/10" />
|
||||
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
|
||||
<Grid3X3 size={12} /> Wire
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
|
||||
{/* Lighting presets */}
|
||||
<div className="flex rounded-md overflow-hidden border border-white/10 bg-black/50 backdrop-blur-sm">
|
||||
<span className="px-2 py-1 text-[11px] text-white/30 flex items-center">
|
||||
<Sun size={11} />
|
||||
</span>
|
||||
<div className="w-px bg-white/10" />
|
||||
{LIGHT_PRESETS.map((p, i) => (
|
||||
<div key={p.id} className="flex">
|
||||
{i > 0 && <div className="w-px bg-white/10" />}
|
||||
<ToolbarBtn active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
|
||||
{p.label}
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading GLB
|
||||
if (loadingGlb) {
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg border border-border-default bg-surface-muted flex items-center justify-center"
|
||||
style={{ height: 280 }}
|
||||
style={{ height: HEIGHT }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-content-muted">
|
||||
<Loader2 size={28} className="animate-spin" />
|
||||
@@ -103,14 +192,13 @@ export default function InlineCadViewer({
|
||||
)
|
||||
}
|
||||
|
||||
// No GLB yet — show thumbnail + generate button
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3"
|
||||
style={{ height: 280 }}
|
||||
style={{ height: HEIGHT }}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="CAD thumbnail" className="max-h-40 object-contain" />
|
||||
<img src={thumbnailUrl} alt="CAD thumbnail" className="max-h-52 object-contain" />
|
||||
) : (
|
||||
<Box size={48} className="text-content-muted" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user