From 2377cb192af2f19cd5451df43299ed198f43e739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Mar 2026 15:17:20 +0100 Subject: [PATCH] fix: smooth normals on non-indexed geometry + sync DB in gltf task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/tasks/step_tasks.py | 57 ++++---- .../src/components/cad/InlineCadViewer.tsx | 128 +++++++++++++++--- 2 files changed, 136 insertions(+), 49 deletions(-) diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py index 4f08c4c..dd152c7 100644 --- a/backend/app/tasks/step_tasks.py +++ b/backend/app/tasks/step_tasks.py @@ -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} diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index 238d615..beaa838 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -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 + const cloned = useRef(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 +} + +const HEIGHT = 420 + +function ToolbarBtn({ + active, onClick, children, title, +}: { active: boolean; onClick: () => void; children: React.ReactNode; title?: string }) { + return ( + + ) } export default function InlineCadViewer({ @@ -25,6 +90,8 @@ export default function InlineCadViewer({ const [glbBlobUrl, setGlbBlobUrl] = useState(null) const [loadingGlb, setLoadingGlb] = useState(false) const [generating, setGenerating] = useState(false) + const [viewMode, setViewMode] = useState('solid') + const [lightPreset, setLightPreset] = useState('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 ( -
+
- - - + + + + {/* Toolbar */} +
+ {/* View mode */} +
+ setViewMode('solid')} title="Solid"> + Solid + +
+ setViewMode('wireframe')} title="Wireframe"> + Wire + +
+ + {/* Lighting presets */} +
+ + + +
+ {LIGHT_PRESETS.map((p, i) => ( +
+ {i > 0 &&
} + setLightPreset(p.id)} title={p.label}> + {p.label} + +
+ ))} +
+
) } - // Loading GLB if (loadingGlb) { return (
@@ -103,14 +192,13 @@ export default function InlineCadViewer({ ) } - // No GLB yet — show thumbnail + generate button return (
{thumbnailUrl ? ( - CAD thumbnail + CAD thumbnail ) : ( )}