feat(N): workflow pipeline, 3D viewer, worker management, QC tests

- workflow_builder.py: fix broken stubs, add render_order_line_still_task
  (resolves step_path from DB instead of passing order_line_id as step_path)
- domains/rendering/tasks.py: add render_order_line_still_task,
  export_gltf_for_order_line_task, export_blend_for_order_line_task,
  generate_gltf_geometry_task (trimesh STL→GLB, no Blender needed)
- tasks/step_tasks.py: add generate_gltf_geometry_task for CadFile GLB export
- cad router: POST /{id}/generate-gltf-geometry endpoint (admin/PM)
- worker router: GET /celery-workers + POST /scale (docker compose subprocess)
- Dockerfile: pip install -e "[dev]" to enable pytest
- docker-compose.yml: docker socket + compose file mount on backend
- ThreeDViewer.tsx: mode toggle (geometry/production), wireframe, env presets,
  download buttons (GLB + .blend)
- CadPreview.tsx: load gltf_geometry/gltf_production/blend_production assets
  from MediaAsset table and pass URLs to ThreeDViewer
- ProductDetail.tsx: "View 3D" button → /cad/:id, "Generate GLB" button
- media router/service: cad_file_id filter on GET /api/media
- WorkerManagement.tsx: new page with worker status, queue depth, scale controls
- App.tsx + Layout.tsx: /workers route + sidebar link (admin/PM)
- tests: test_rendering_service.py, test_orders_service.py (backend)
- tests: WorkerActivity.test.tsx, WorkerManagement.test.tsx (frontend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:56:53 +01:00
parent 208eb21988
commit a70cb55d01
24 changed files with 1828 additions and 448 deletions
+196 -51
View File
@@ -11,24 +11,50 @@ import {
import { Canvas, useThree, useFrame } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import { toast } from 'sonner'
import { X, Camera, Loader2, AlertTriangle } from 'lucide-react'
import {
X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown,
} from 'lucide-react'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ThreeDViewerProps {
export interface ThreeDViewerProps {
cadFileId: string
onClose: () => void
/** URL for the geometry-only GLB (from STL export) */
geometryGltfUrl?: string
/** URL for the production-quality GLB (from asset library render) */
productionGltfUrl?: string
/** Download URLs for GLB and .blend assets */
downloadUrls?: {
glb?: string
blend?: string
}
}
type ViewMode = 'geometry' | 'production'
const ENV_PRESETS = ['city', 'sunset', 'dawn', 'night', 'warehouse', 'forest', 'apartment', 'studio', 'park', 'lobby'] as const
type EnvPreset = typeof ENV_PRESETS[number]
// ---------------------------------------------------------------------------
// Inner model loader separated so Suspense can catch it
// ---------------------------------------------------------------------------
function GltfModel({ url }: { url: string }) {
function GltfModel({ url, wireframe }: { url: string; wireframe: boolean }) {
const { scene } = useGLTF(url)
useEffect(() => {
scene.traverse((child: any) => {
if (child.isMesh) {
child.material = child.material.clone()
child.material.wireframe = wireframe
}
})
}, [scene, wireframe])
return <primitive object={scene} />
}
@@ -50,11 +76,7 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
if (!enabled || didCapture.current) return
didCapture.current = true
// Grab the canvas as a data-URL after the current frame has been rendered
const dataUrl = gl.domElement.toDataURL('image/png')
// Convert data-URL → Blob without a network fetch:
// data:[<mediatype>][;base64],<data>
const [header, base64Data] = dataUrl.split(',')
const mimeMatch = header.match(/:(.*?);/)
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
@@ -64,7 +86,6 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: mimeType })
const formData = new FormData()
formData.append('thumbnail', blob, 'thumbnail.png')
@@ -72,14 +93,8 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
.post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then(() => {
toast.success('Thumbnail captured and saved')
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
console.error('Thumbnail upload failed', msg)
toast.error('Failed to save thumbnail')
})
.then(() => toast.success('Thumbnail captured and saved'))
.catch(() => toast.error('Failed to save thumbnail'))
.finally(() => {
didCapture.current = false
onDone()
@@ -90,7 +105,7 @@ function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProp
}
// ---------------------------------------------------------------------------
// Error boundary for the GLTF loader inside Suspense
// Error boundary
// ---------------------------------------------------------------------------
class GltfErrorBoundary extends Component<
@@ -101,15 +116,12 @@ class GltfErrorBoundary extends Component<
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true }
}
componentDidCatch(error: Error, _info: ErrorInfo): void {
this.props.onError(error.message || 'Failed to parse GLTF')
}
render(): ReactNode {
if (this.state.hasError) return null
return this.props.children
@@ -117,7 +129,7 @@ class GltfErrorBoundary extends Component<
}
// ---------------------------------------------------------------------------
// Loading overlay (shown while model resolves inside Canvas)
// Loading overlay
// ---------------------------------------------------------------------------
function LoadingOverlay() {
@@ -130,60 +142,199 @@ function LoadingOverlay() {
}
// ---------------------------------------------------------------------------
// Model loader with resolved tracking
// Model loader with ready tracking
// ---------------------------------------------------------------------------
interface ModelWithReadyProps {
url: string
wireframe: boolean
onReady: () => void
}
function ModelWithReady({ url, onReady }: ModelWithReadyProps) {
function ModelWithReady({ url, wireframe, onReady }: ModelWithReadyProps) {
const { scene } = useGLTF(url)
useEffect(() => {
onReady()
}, [onReady])
scene.traverse((child: any) => {
if (child.isMesh) {
child.material = child.material.clone()
child.material.wireframe = wireframe
}
})
}, [scene, wireframe])
useEffect(() => { onReady() }, [onReady])
return <primitive object={scene} />
}
// ---------------------------------------------------------------------------
// Env preset dropdown
// ---------------------------------------------------------------------------
function EnvDropdown({
value,
onChange,
}: {
value: EnvPreset
onChange: (v: EnvPreset) => void
}) {
const [open, setOpen] = useState(false)
return (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
{value}
<ChevronDown size={12} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 bg-gray-800 border border-gray-700 rounded-md shadow-xl min-w-[130px]">
{ENV_PRESETS.map((p) => (
<button
key={p}
onClick={() => { onChange(p); setOpen(false) }}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-700 transition-colors ${
p === value ? 'text-accent font-semibold' : 'text-gray-300'
}`}
>
{p}
</button>
))}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Main exported component
// ---------------------------------------------------------------------------
export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps) {
const modelUrl = `/api/cad/${cadFileId}/model`
export default function ThreeDViewer({
cadFileId,
onClose,
geometryGltfUrl,
productionGltfUrl,
downloadUrls,
}: ThreeDViewerProps) {
const defaultUrl = `/api/cad/${cadFileId}/model`
const [mode, setMode] = useState<ViewMode>('geometry')
const [wireframe, setWireframe] = useState(false)
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
const [capturing, setCapturing] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [modelReady, setModelReady] = useState(false)
// Resolve the active model URL based on mode
const activeUrl =
mode === 'production' && productionGltfUrl
? productionGltfUrl
: geometryGltfUrl || defaultUrl
const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), [])
const handleCaptureDone = useCallback(() => setCapturing(false), [])
// Reset ready state when URL changes
useEffect(() => {
setModelReady(false)
setLoadError(null)
}, [activeUrl])
function handleDownload(url: string, filename: string) {
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const hasBothModes = !!(geometryGltfUrl && productionGltfUrl)
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
{/* ------------------------------------------------------------------ */}
{/* Toolbar */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
{/* Toolbar */}
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0 gap-3 flex-wrap">
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-wrap">
{/* Mode toggle */}
{hasBothModes && (
<div className="flex rounded-md overflow-hidden border border-gray-700">
<button
onClick={() => setMode('geometry')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
mode === 'geometry'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<Box size={12} />
Geometry
</button>
<button
onClick={() => setMode('production')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
mode === 'production'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<Cpu size={12} />
Production
</button>
</div>
)}
{/* Wireframe toggle */}
<button
onClick={() => setWireframe((w) => !w)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors border ${
wireframe
? 'bg-accent border-accent text-white'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:bg-gray-700'
}`}
>
Wireframe
</button>
{/* Environment preset */}
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
{/* Download buttons */}
{downloadUrls?.glb && (
<button
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}.glb`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
<Download size={12} />
GLB
</button>
)}
{downloadUrls?.blend && (
<button
onClick={() => handleDownload(downloadUrls.blend!, `${cadFileId}.blend`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
<Download size={12} />
.blend
</button>
)}
{/* Capture button */}
<button
onClick={() => setCapturing(true)}
disabled={capturing || !modelReady || loadError !== null}
className="flex items-center gap-2 px-4 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{capturing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Camera size={15} />
)}
{capturing ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
{capturing ? 'Capturing…' : 'Capture Angle'}
</button>
{/* Close */}
<button
onClick={onClose}
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
@@ -194,11 +345,8 @@ export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps)
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Viewport */}
{/* ------------------------------------------------------------------ */}
{/* Viewport */}
<div className="relative flex-1">
{/* Error state */}
{loadError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-4 z-20">
<AlertTriangle size={48} className="text-red-400" />
@@ -213,34 +361,31 @@ export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps)
</div>
)}
{/* Loading overlay visible until model signals ready */}
{!modelReady && !loadError && <LoadingOverlay />}
{/* Three.js Canvas */}
<Canvas
camera={{ position: [0, 2, 5], fov: 45 }}
gl={{ preserveDrawingBuffer: true }}
style={{ width: '100%', height: '100%', background: '#111827' }}
>
{/* Lights */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
{/* GLTF model */}
<GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}>
<ModelWithReady url={modelUrl} onReady={handleModelReady} />
<ModelWithReady
key={activeUrl}
url={activeUrl}
wireframe={wireframe}
onReady={handleModelReady}
/>
</Suspense>
</GltfErrorBoundary>
{/* Camera controls */}
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
<Environment preset={envPreset} />
{/* Environment map for PBR materials */}
<Environment preset="city" />
{/* Screenshot capture only active when triggered */}
{capturing && (
<ScreenshotCapture
enabled={capturing}