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:
@@ -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")
|
||||
|
||||
# --- 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():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
from app.config import settings as _cfg
|
||||
import uuid
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Delete previous gltf_geometry assets for this cad_file to avoid stale records
|
||||
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,
|
||||
)
|
||||
_sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
_eng2 = _ce(_sync_url)
|
||||
with _Session(_eng2) as _sess:
|
||||
_sess.execute(
|
||||
_del(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("/") + "/"
|
||||
if _key.startswith(_prefix):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=uuid.UUID(cad_file_id),
|
||||
asset_type=MediaAssetType.gltf_geometry,
|
||||
storage_key=_key,
|
||||
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()
|
||||
return str(asset.id)
|
||||
)
|
||||
_key = str(output_path)
|
||||
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
|
||||
if _key.startswith(_prefix):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=_uuid.UUID(cad_file_id),
|
||||
asset_type=MediaAssetType.gltf_geometry,
|
||||
storage_key=_key,
|
||||
mime_type="model/gltf-binary",
|
||||
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
|
||||
)
|
||||
_sess.add(asset)
|
||||
_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)
|
||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
||||
|
||||
|
||||
@@ -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