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")
|
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}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user