diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index 3629a47..e9b251f 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -1,16 +1,19 @@ """Workflow definition CRUD API. Endpoints: - GET /api/workflows/ — list all workflow definitions (admin/PM) - GET /api/workflows/{id} — get single definition (admin/PM) - POST /api/workflows/ — create definition (admin only) - PUT /api/workflows/{id} — update definition (admin only) - DELETE /api/workflows/{id} — delete definition (admin only) - GET /api/workflows/{id}/runs — list runs for a definition (admin/PM) + GET /api/workflows/ — list all workflow definitions (admin/PM) + GET /api/workflows/pipeline-steps — list available pipeline step definitions + GET /api/workflows/{id} — get single definition (admin/PM) + POST /api/workflows/ — create definition (admin only) + PUT /api/workflows/{id} — update definition (admin only) + DELETE /api/workflows/{id} — delete definition (admin only) + GET /api/workflows/{id}/runs — list runs for a definition (admin/PM) """ import uuid +from typing import Literal from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession @@ -25,10 +28,88 @@ from app.domains.rendering.schemas import ( WorkflowDefinitionOut, WorkflowRunOut, ) +from app.core.process_steps import StepName + + +# ── Pipeline-step metadata helpers ────────────────────────────────────────── + +StepCategory = Literal["input", "processing", "rendering", "output"] + +_STEP_CATEGORIES: dict[StepName, StepCategory] = { + StepName.RESOLVE_STEP_PATH: "input", + StepName.OCC_OBJECT_EXTRACT: "processing", + StepName.OCC_GLB_EXPORT: "processing", + StepName.GLB_BBOX: "processing", + StepName.MATERIAL_MAP_RESOLVE: "processing", + StepName.AUTO_POPULATE_MATERIALS: "processing", + StepName.BLENDER_RENDER: "rendering", + StepName.THREEJS_RENDER: "rendering", + StepName.THUMBNAIL_SAVE: "output", + StepName.ORDER_LINE_SETUP: "processing", + StepName.RESOLVE_TEMPLATE: "processing", + StepName.BLENDER_STILL: "rendering", + StepName.BLENDER_TURNTABLE: "rendering", + StepName.OUTPUT_SAVE: "output", + StepName.EXPORT_GLB_GEOMETRY: "output", + StepName.EXPORT_GLB_PRODUCTION: "output", + StepName.EXPORT_BLEND: "output", + StepName.STL_CACHE_GENERATE: "processing", + StepName.NOTIFY: "output", +} + +_STEP_DESCRIPTIONS: dict[StepName, str] = { + StepName.RESOLVE_STEP_PATH: "Locate the STEP file on disk from the CadFile record", + StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC", + StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery", + StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing", + StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to SCHAEFFLER library materials via alias table", + StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names", + StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)", + StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser", + StepName.THUMBNAIL_SAVE: "Persist the rendered thumbnail bytes to the CadFile record", + StepName.ORDER_LINE_SETUP: "Validate and prepare an order line for rendering (check STEP path, output type)", + StepName.RESOLVE_TEMPLATE: "Look up the matching RenderTemplate for the order line's category + output type", + StepName.BLENDER_STILL: "Render a production still image (PNG) via Blender HTTP micro-service", + StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service", + StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record", + StepName.EXPORT_GLB_GEOMETRY: "Export a geometry-only GLB for the 3-D viewer (no materials)", + StepName.EXPORT_GLB_PRODUCTION: "Export a production GLB with full materials from the .blend template", + StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset", + StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file", + StepName.NOTIFY: "Emit a user notification via the audit-log notification channel", +} + + +class PipelineStepOut(BaseModel): + name: str + label: str + category: StepCategory + description: str + + +class PipelineStepsResponse(BaseModel): + steps: list[PipelineStepOut] router = APIRouter(prefix="/api/workflows", tags=["workflows"]) +@router.get("/pipeline-steps", response_model=PipelineStepsResponse) +async def get_pipeline_steps( + _user: User = Depends(require_admin_or_pm), +): + """Return all available pipeline step definitions for the workflow editor.""" + steps = [ + PipelineStepOut( + name=step.value, + label=step.value.replace("_", " ").title(), + category=_STEP_CATEGORIES.get(step, "processing"), + description=_STEP_DESCRIPTIONS.get(step, ""), + ) + for step in StepName + ] + return PipelineStepsResponse(steps=steps) + + @router.get("", response_model=list[WorkflowDefinitionOut]) async def list_workflows( _user: User = Depends(require_admin_or_pm), diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 52b4376..56efeb2 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -78,3 +78,21 @@ export const deleteWorkflow = (id: string): Promise => export const getWorkflowRuns = (workflowId: string): Promise => api.get(`/workflows/${workflowId}/runs`).then(r => r.data) + +// ─── Pipeline Steps ─────────────────────────────────────────────────────────── + +export type StepCategory = 'input' | 'processing' | 'rendering' | 'output' + +export interface PipelineStep { + name: string + label: string + category: StepCategory + description: string +} + +export interface PipelineStepsResponse { + steps: PipelineStep[] +} + +export const getPipelineSteps = (): Promise => + api.get('/workflows/pipeline-steps').then(r => r.data) diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 2ada68f..2b2e9c4 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -22,9 +22,12 @@ import { createWorkflow, updateWorkflow, deleteWorkflow, + getPipelineSteps, type WorkflowDefinition, type WorkflowConfig, type WorkflowParams, + type PipelineStep, + type StepCategory, } from '../api/workflows' import { FileUp, @@ -232,14 +235,44 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] function ConfigSidepanel({ params, onChange, + pipelineStep, + onPipelineStepChange, + pipelineSteps, }: { params: WorkflowParams onChange: (p: WorkflowParams) => void + pipelineStep?: string + onPipelineStepChange?: (step: string) => void + pipelineSteps: PipelineStep[] }) { return (

Node Configuration

+ {/* Pipeline Step binding */} + {pipelineSteps.length > 0 && onPipelineStepChange && ( +
+ + + {pipelineStep && ( +

+ {pipelineSteps.find(s => s.name === pipelineStep)?.description ?? ''} +

+ )} +
+ )} + {/* Render Engine */}
@@ -341,6 +374,73 @@ function ConfigSidepanel({ ) } +// ─── Pipeline Steps Panel ───────────────────────────────────────────────────── + +const CATEGORY_LABELS: Record = { + input: 'Input', + processing: 'Processing', + rendering: 'Rendering', + output: 'Output', +} + +const CATEGORY_COLORS: Record = { + input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', + output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', +} + +function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { + const [expanded, setExpanded] = useState(null) + + const grouped = steps.reduce>( + (acc, step) => { + acc[step.category] = [...(acc[step.category] ?? []), step] + return acc + }, + { input: [], processing: [], rendering: [], output: [] }, + ) + + const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output'] + + return ( +
+

+ Pipeline Steps +

+
+ {categories.map(cat => ( +
+ + {expanded === cat && ( +
+ {grouped[cat].map(step => ( +
+

{step.name}

+

{step.description}

+
+ ))} +
+ )} +
+ ))} +
+
+ ) +} + // ─── Node Palette ────────────────────────────────────────────────────────────── const NODE_PALETTE = [ @@ -449,19 +549,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState(null) + const { data: pipelineStepsData } = useQuery({ + queryKey: ['pipeline-steps'], + queryFn: getPipelineSteps, + staleTime: 5 * 60 * 1000, + }) + const pipelineSteps = pipelineStepsData?.steps ?? [] + const onConnect = useCallback( (connection: Connection) => setEdges(eds => addEdge(connection, eds)), [setEdges], ) const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { - if (node.type === 'renderNode' || node.type === 'renderFramesNode') { - setSelectedNodeId(node.id) - const nodeParams = (node.data as any).params as WorkflowParams | undefined - if (nodeParams) setParams(nodeParams) - } else { - setSelectedNodeId(null) - } + setSelectedNodeId(node.id) + const nodeParams = (node.data as any).params as WorkflowParams | undefined + if (nodeParams) setParams(nodeParams) }, []) const onPaneClick = useCallback(() => { @@ -483,6 +586,20 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { [selectedNodeId, setNodes], ) + const handlePipelineStepChange = useCallback( + (stepName: string) => { + setNodes(nds => + nds.map(n => { + if (n.id === selectedNodeId) { + return { ...n, data: { ...n.data, pipeline_step: stepName || undefined } } + } + return n + }), + ) + }, + [selectedNodeId, setNodes], + ) + // Drag-drop new nodes from palette const onDragOver = useCallback((event: DragEvent) => { event.preventDefault() @@ -579,8 +696,19 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
- {selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && ( - + {selectedNode && ( + + )} + {!selectedNode && pipelineSteps.length > 0 && ( +
+ +
)}
@@ -686,16 +814,18 @@ export default function WorkflowEditor() {

Loading…

)} {!isLoading && workflows.length === 0 && ( -

- No workflows yet. -
+

+

No workflows configured.

+

+ Workflows define the sequence of pipeline steps for rendering orders. +

-

+
)} {workflows.map(wf => (