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:
2026-03-07 15:17:20 +01:00
parent e2eda92d82
commit 2377cb192a
2 changed files with 136 additions and 49 deletions
+28 -29
View File
@@ -530,38 +530,37 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done") log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done")
# --- Store MediaAsset (replace existing gltf_geometry for this cad_file) --- # --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
import asyncio # Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers.
import uuid as _uuid
from sqlalchemy import create_engine as _ce, delete as _del
from sqlalchemy.orm import Session as _Session
from app.domains.media.models import MediaAsset, MediaAssetType
async def _store(): _sync_url = app_settings.database_url.replace("+asyncpg", "")
from app.database import AsyncSessionLocal _eng2 = _ce(_sync_url)
from app.domains.media.models import MediaAsset, MediaAssetType with _Session(_eng2) as _sess:
from app.config import settings as _cfg _sess.execute(
import uuid _del(MediaAsset).where(
async with AsyncSessionLocal() as db: MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
# Delete previous gltf_geometry assets for this cad_file to avoid stale records MediaAsset.asset_type == MediaAssetType.gltf_geometry,
from sqlalchemy import delete as _delete
await db.execute(
_delete(MediaAsset).where(
MediaAsset.cad_file_id == uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
) )
_key = str(output_path) )
_prefix = str(_cfg.upload_dir).rstrip("/") + "/" _key = str(output_path)
if _key.startswith(_prefix): _prefix = str(app_settings.upload_dir).rstrip("/") + "/"
_key = _key[len(_prefix):] if _key.startswith(_prefix):
asset = MediaAsset( _key = _key[len(_prefix):]
cad_file_id=uuid.UUID(cad_file_id), asset = MediaAsset(
asset_type=MediaAssetType.gltf_geometry, cad_file_id=_uuid.UUID(cad_file_id),
storage_key=_key, asset_type=MediaAssetType.gltf_geometry,
mime_type="model/gltf-binary", storage_key=_key,
file_size_bytes=output_path.stat().st_size if output_path.exists() else None, mime_type="model/gltf-binary",
) file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
db.add(asset) )
await db.commit() _sess.add(asset)
return str(asset.id) _sess.commit()
asset_id = str(asset.id)
_eng2.dispose()
asset_id = asyncio.run(_store())
logger.info("generate_gltf_geometry_task: MediaAsset %s created for cad %s", asset_id, cad_file_id) logger.info("generate_gltf_geometry_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
return {"glb_path": str(output_path), "asset_id": asset_id} return {"glb_path": str(output_path), "asset_id": asset_id}
+108 -20
View File
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas } from '@react-three/fiber' import { Canvas } from '@react-three/fiber'
import { OrbitControls, useGLTF } from '@react-three/drei' import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import { Loader2, Box, RefreshCw } from 'lucide-react' 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 { toast } from 'sonner'
import { getMediaAssets } from '../../api/media' import { getMediaAssets } from '../../api/media'
import { generateGltfGeometry } from '../../api/cad' import { generateGltfGeometry } from '../../api/cad'
import { useAuthStore } from '../../store/auth' 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) 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({ export default function InlineCadViewer({
@@ -25,6 +90,8 @@ export default function InlineCadViewer({
const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null) const [glbBlobUrl, setGlbBlobUrl] = useState<string | null>(null)
const [loadingGlb, setLoadingGlb] = useState(false) const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const { data: gltfAssets } = useQuery({ const { data: gltfAssets } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_geometry'], queryKey: ['media-assets', cadFileId, 'gltf_geometry'],
@@ -33,7 +100,6 @@ export default function InlineCadViewer({
refetchInterval: generating ? 4_000 : false, refetchInterval: generating ? 4_000 : false,
}) })
// Stop polling once asset appears
useEffect(() => { useEffect(() => {
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false) if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
}, [generating, gltfAssets]) }, [generating, gltfAssets])
@@ -41,7 +107,6 @@ export default function InlineCadViewer({
const latestAsset = gltfAssets?.[0] const latestAsset = gltfAssets?.[0]
const downloadUrl = latestAsset?.download_url const downloadUrl = latestAsset?.download_url
// Fetch GLB with auth when download URL is available
useEffect(() => { useEffect(() => {
if (!downloadUrl || !token) return if (!downloadUrl || !token) return
setLoadingGlb(true) setLoadingGlb(true)
@@ -69,31 +134,55 @@ export default function InlineCadViewer({
onError: () => toast.error('Failed to queue GLB generation'), onError: () => toast.error('Failed to queue GLB generation'),
}) })
// Show GLB viewer
if (glbBlobUrl) { if (glbBlobUrl) {
return ( return (
<div <div className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950 relative" style={{ height: HEIGHT }}>
className="w-full rounded-lg overflow-hidden border border-border-default bg-gray-950"
style={{ height: 280 }}
>
<Canvas camera={{ position: [0, 0, 2], fov: 45 }}> <Canvas camera={{ position: [0, 0, 2], fov: 45 }}>
<ambientLight intensity={1.2} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<Suspense fallback={null}> <Suspense fallback={null}>
<GlbModel url={glbBlobUrl} /> <Environment preset={lightPreset} background={false} />
<GlbModel url={glbBlobUrl} wireframe={viewMode === 'wireframe'} />
</Suspense> </Suspense>
<OrbitControls makeDefault /> <OrbitControls makeDefault />
</Canvas> </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> </div>
) )
} }
// Loading GLB
if (loadingGlb) { if (loadingGlb) {
return ( return (
<div <div
className="w-full rounded-lg border border-border-default bg-surface-muted flex items-center justify-center" 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"> <div className="flex flex-col items-center gap-2 text-content-muted">
<Loader2 size={28} className="animate-spin" /> <Loader2 size={28} className="animate-spin" />
@@ -103,14 +192,13 @@ export default function InlineCadViewer({
) )
} }
// No GLB yet — show thumbnail + generate button
return ( return (
<div <div
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3" 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 ? ( {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" /> <Box size={48} className="text-content-muted" />
)} )}