diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py index 94f6575..bef4bd8 100644 --- a/backend/app/domains/rendering/workflow_config_utils.py +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -4,6 +4,7 @@ from copy import deepcopy from typing import Any from app.core.process_steps import StepName +from app.domains.rendering.workflow_node_registry import get_node_type_for_step WorkflowPresetType = str @@ -16,18 +17,10 @@ _PRESET_TYPES = { "custom", } -_STEP_TO_NODE_TYPE: dict[str, str] = { - StepName.RESOLVE_STEP_PATH.value: "inputNode", - StepName.STL_CACHE_GENERATE.value: "convertNode", - StepName.BLENDER_STILL.value: "renderNode", - StepName.BLENDER_TURNTABLE.value: "renderFramesNode", - StepName.OUTPUT_SAVE.value: "outputNode", - StepName.EXPORT_BLEND.value: "outputNode", -} - _NODE_TYPE_TO_STEP: dict[str, str] = { "inputNode": StepName.RESOLVE_STEP_PATH.value, "convertNode": StepName.STL_CACHE_GENERATE.value, + "processNode": StepName.ORDER_LINE_SETUP.value, "renderNode": StepName.BLENDER_STILL.value, "renderFramesNode": StepName.BLENDER_TURNTABLE.value, "ffmpegNode": StepName.OUTPUT_SAVE.value, @@ -50,7 +43,7 @@ def _make_node( "step": step.value, "params": deepcopy(params or {}), "ui": { - "type": node_type or _STEP_TO_NODE_TYPE.get(step.value), + "type": node_type or get_node_type_for_step(step.value), "position": {"x": x, "y": y}, "label": label, }, diff --git a/backend/app/domains/rendering/workflow_node_registry.py b/backend/app/domains/rendering/workflow_node_registry.py new file mode 100644 index 0000000..6b95e05 --- /dev/null +++ b/backend/app/domains/rendering/workflow_node_registry.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel + +from app.core.process_steps import StepName + + +StepCategory = Literal["input", "processing", "rendering", "output"] +FieldType = Literal["number", "select", "boolean"] +ExecutionKind = Literal["native", "bridge"] + + +class WorkflowNodeFieldOption(BaseModel): + value: str | int | float | bool + label: str + + +class WorkflowNodeFieldDefinition(BaseModel): + key: str + label: str + type: FieldType + description: str = "" + section: str = "General" + default: Any = None + min: float | None = None + max: float | None = None + step: float | None = None + unit: str | None = None + options: list[WorkflowNodeFieldOption] = [] + + +class WorkflowNodeDefinition(BaseModel): + step: str + label: str + category: StepCategory + description: str + node_type: str + icon: str + defaults: dict[str, Any] = {} + fields: list[WorkflowNodeFieldDefinition] = [] + execution_kind: ExecutionKind = "native" + legacy_compatible: bool = True + + +def _field( + key: str, + label: str, + field_type: FieldType, + *, + description: str = "", + section: str = "General", + default: Any = None, + min: float | None = None, + max: float | None = None, + step: float | None = None, + unit: str | None = None, + options: list[tuple[str | int | float | bool, str]] | None = None, +) -> WorkflowNodeFieldDefinition: + return WorkflowNodeFieldDefinition( + key=key, + label=label, + type=field_type, + description=description, + section=section, + default=default, + min=min, + max=max, + step=step, + unit=unit, + options=[ + WorkflowNodeFieldOption(value=value, label=option_label) + for value, option_label in (options or []) + ], + ) + + +def _definition( + step: StepName, + label: str, + category: StepCategory, + description: str, + *, + node_type: str, + icon: str, + defaults: dict[str, Any] | None = None, + fields: list[WorkflowNodeFieldDefinition] | None = None, + execution_kind: ExecutionKind = "native", +) -> WorkflowNodeDefinition: + return WorkflowNodeDefinition( + step=step.value, + label=label, + category=category, + description=description, + node_type=node_type, + icon=icon, + defaults=defaults or {}, + fields=fields or [], + execution_kind=execution_kind, + ) + + +_NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [ + _definition( + StepName.RESOLVE_STEP_PATH, + "Resolve STEP Path", + "input", + "Locate the STEP file on disk from the CAD file record.", + node_type="inputNode", + icon="file-up", + execution_kind="bridge", + ), + _definition( + StepName.OCC_OBJECT_EXTRACT, + "Extract STEP Objects", + "processing", + "Extract part objects and metadata from the STEP file via OCC/cadquery.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.OCC_GLB_EXPORT, + "Export GLB", + "processing", + "Convert STEP geometry into GLB for previews and downstream rendering.", + node_type="processNode", + icon="refresh-cw", + execution_kind="bridge", + ), + _definition( + StepName.GLB_BBOX, + "Compute Bounding Box", + "processing", + "Compute the model bounding box from the exported GLB for framing decisions.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.MATERIAL_MAP_RESOLVE, + "Resolve Material Map", + "processing", + "Map raw part material names to HartOMat material records via aliases.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.AUTO_POPULATE_MATERIALS, + "Auto Populate Materials", + "processing", + "Create missing material records for newly discovered part materials.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.BLENDER_RENDER, + "Render Thumbnail (Blender)", + "rendering", + "Render a thumbnail image with Blender.", + node_type="renderNode", + icon="camera", + defaults={"render_engine": "cycles", "samples": 64, "width": 512, "height": 512}, + fields=[ + _field( + "render_engine", + "Render Engine", + "select", + description="Renderer backend for this Blender job.", + section="Render", + default="cycles", + options=[("cycles", "Cycles"), ("eevee", "EEVEE")], + ), + _field( + "samples", + "Samples", + "number", + description="Quality samples for the render.", + section="Render", + default=64, + min=1, + max=4096, + step=1, + ), + _field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), + _field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), + ], + ), + _definition( + StepName.THREEJS_RENDER, + "Render Thumbnail (Three.js)", + "rendering", + "Render a thumbnail image with the headless Three.js renderer.", + node_type="renderNode", + icon="camera", + defaults={"width": 512, "height": 512, "transparent_bg": True}, + fields=[ + _field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), + _field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"), + _field( + "transparent_bg", + "Transparent Background", + "boolean", + description="Render with alpha channel instead of an opaque background.", + section="Output", + default=True, + ), + ], + execution_kind="bridge", + ), + _definition( + StepName.THUMBNAIL_SAVE, + "Save Thumbnail", + "output", + "Persist the generated thumbnail back onto the CAD file record.", + node_type="outputNode", + icon="download", + execution_kind="bridge", + ), + _definition( + StepName.ORDER_LINE_SETUP, + "Order Line Setup", + "processing", + "Validate order-line inputs and prepare the render job context.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.RESOLVE_TEMPLATE, + "Resolve Template", + "processing", + "Resolve the render template for the order line and output type.", + node_type="processNode", + icon="layers", + execution_kind="bridge", + ), + _definition( + StepName.BLENDER_STILL, + "Render Still", + "rendering", + "Render a production still image with Blender.", + node_type="renderNode", + icon="camera", + defaults={"render_engine": "cycles", "samples": 256, "width": 2048, "height": 2048, "rotation_z": 0}, + fields=[ + _field( + "render_engine", + "Render Engine", + "select", + description="Renderer backend for the still render.", + section="Render", + default="cycles", + options=[("cycles", "Cycles"), ("eevee", "EEVEE")], + ), + _field( + "samples", + "Samples", + "number", + description="Quality samples for the still render.", + section="Render", + default=256, + min=1, + max=4096, + step=1, + ), + _field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), + _field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), + _field( + "rotation_z", + "Rotation Z", + "number", + description="Additional Z-axis rotation in degrees.", + section="Camera", + default=0, + min=-360, + max=360, + step=1, + unit="deg", + ), + ], + ), + _definition( + StepName.BLENDER_TURNTABLE, + "Render Turntable", + "rendering", + "Render an animated turntable sequence with Blender.", + node_type="renderFramesNode", + icon="film", + defaults={ + "render_engine": "cycles", + "samples": 64, + "width": 2048, + "height": 2048, + "fps": 24, + "duration_s": 5, + "rotation_z": 0, + }, + fields=[ + _field( + "render_engine", + "Render Engine", + "select", + description="Renderer backend for the turntable job.", + section="Render", + default="cycles", + options=[("cycles", "Cycles"), ("eevee", "EEVEE")], + ), + _field( + "samples", + "Samples", + "number", + description="Quality samples for each frame.", + section="Render", + default=64, + min=1, + max=4096, + step=1, + ), + _field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), + _field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"), + _field("fps", "FPS", "number", section="Animation", default=24, min=1, max=120, step=1), + _field( + "duration_s", + "Duration", + "number", + description="Length of the animation clip.", + section="Animation", + default=5, + min=1, + max=120, + step=1, + unit="s", + ), + _field( + "rotation_z", + "Rotation Z", + "number", + description="Base Z-axis rotation before the turntable motion is applied.", + section="Camera", + default=0, + min=-360, + max=360, + step=1, + unit="deg", + ), + ], + ), + _definition( + StepName.OUTPUT_SAVE, + "Save Output", + "output", + "Persist the rendered output file and create the media record.", + node_type="outputNode", + icon="download", + execution_kind="bridge", + ), + _definition( + StepName.EXPORT_BLEND, + "Export Blend", + "output", + "Persist the generated .blend file as a downloadable media asset.", + node_type="outputNode", + icon="download", + execution_kind="bridge", + ), + _definition( + StepName.STL_CACHE_GENERATE, + "Generate STL Cache", + "processing", + "Generate and cache STL derivatives next to the STEP source.", + node_type="convertNode", + icon="refresh-cw", + execution_kind="bridge", + ), + _definition( + StepName.NOTIFY, + "Notify", + "output", + "Emit a user-visible notification for workflow completion or failure.", + node_type="outputNode", + icon="bell", + defaults={"channel": "audit_log"}, + fields=[ + _field( + "channel", + "Channel", + "select", + description="Notification target channel.", + section="Notification", + default="audit_log", + options=[("audit_log", "Audit Log")], + ), + ], + execution_kind="bridge", + ), +] + + +_NODE_DEFINITION_BY_STEP = {definition.step: definition for definition in _NODE_DEFINITIONS} + + +def list_node_definitions() -> list[WorkflowNodeDefinition]: + return list(_NODE_DEFINITIONS) + + +def get_node_definition(step: StepName | str) -> WorkflowNodeDefinition | None: + step_value = step.value if isinstance(step, StepName) else step + return _NODE_DEFINITION_BY_STEP.get(step_value) + + +def get_node_type_for_step(step: StepName | str) -> str | None: + definition = get_node_definition(step) + return definition.node_type if definition else None diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index 554c120..c315db4 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -1,16 +1,5 @@ -"""Workflow definition CRUD API. - -Endpoints: - 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) -""" +"""Workflow definition CRUD API.""" import uuid -from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ValidationError @@ -29,53 +18,12 @@ from app.domains.rendering.schemas import ( WorkflowRunOut, ) from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config +from app.domains.rendering.workflow_node_registry import ( + StepCategory, + WorkflowNodeDefinition, + list_node_definitions, +) from app.domains.rendering.workflow_schema import WorkflowConfig -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_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 HARTOMAT 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_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): @@ -88,6 +36,11 @@ class PipelineStepOut(BaseModel): class PipelineStepsResponse(BaseModel): steps: list[PipelineStepOut] + +class NodeDefinitionsResponse(BaseModel): + definitions: list[WorkflowNodeDefinition] + + router = APIRouter(prefix="/api/workflows", tags=["workflows"]) @@ -102,19 +55,25 @@ def _workflow_to_out(wf: WorkflowDefinition) -> WorkflowDefinitionOut: ) +@router.get("/node-definitions", response_model=NodeDefinitionsResponse) +async def get_node_definitions( + _user: User = Depends(require_admin_or_pm), +): + return NodeDefinitionsResponse(definitions=list_node_definitions()) + + @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, ""), + name=definition.step, + label=definition.label, + category=definition.category, + description=definition.description, ) - for step in StepName + for definition in list_node_definitions() ] return PipelineStepsResponse(steps=steps) diff --git a/backend/tests/domains/test_workflow_node_registry.py b/backend/tests/domains/test_workflow_node_registry.py new file mode 100644 index 0000000..3cabbfd --- /dev/null +++ b/backend/tests/domains/test_workflow_node_registry.py @@ -0,0 +1,47 @@ +import pytest + +from app.core.process_steps import StepName +from app.domains.rendering.workflow_node_registry import ( + get_node_definition, + list_node_definitions, +) + + +def test_node_registry_covers_all_step_names(): + registered_steps = {definition.step for definition in list_node_definitions()} + expected_steps = {step.value for step in StepName} + + assert registered_steps == expected_steps + + +def test_turntable_node_definition_exposes_expected_schema(): + definition = get_node_definition(StepName.BLENDER_TURNTABLE) + + assert definition is not None + assert definition.node_type == "renderFramesNode" + assert definition.defaults["fps"] == 24 + assert definition.defaults["duration_s"] == 5 + assert {field.key for field in definition.fields} >= { + "render_engine", + "samples", + "width", + "height", + "fps", + "duration_s", + "rotation_z", + } + + +@pytest.mark.asyncio +async def test_node_definitions_endpoint_returns_registry(client, auth_headers): + response = await client.get("/api/workflows/node-definitions", headers=auth_headers) + + assert response.status_code == 200 + body = response.json() + assert len(body["definitions"]) == len(StepName) + + blender_still = next( + definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value + ) + assert blender_still["node_type"] == "renderNode" + assert blender_still["defaults"]["render_engine"] == "cycles" diff --git a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md index c1a2e95..2124eeb 100644 --- a/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md +++ b/docs/workflows/WORKFLOW_DELIVERY_CHECKLIST.md @@ -4,18 +4,18 @@ ### Phase 1 -- [ ] Canonical workflow schema finalized -- [ ] Frontend and backend workflow types aligned -- [ ] Preset workflow migration helpers added -- [ ] Tests added for legacy preset conversion -- [ ] Legacy dispatch remains default +- [x] Canonical workflow schema finalized +- [x] Frontend and backend workflow types aligned +- [x] Preset workflow migration helpers added +- [x] Tests added for legacy preset conversion +- [x] Legacy dispatch remains default ### Phase 2 -- [ ] Node registry implemented -- [ ] Node definitions API available -- [ ] All required nodes have settings schemas -- [ ] Editor consumes node definitions from backend +- [x] Node registry implemented +- [x] Node definitions API available +- [x] All required nodes have settings schemas +- [x] Editor consumes node definitions from backend ### Phase 3 diff --git a/docs/workflows/WORKFLOW_MIGRATION_PLAN.md b/docs/workflows/WORKFLOW_MIGRATION_PLAN.md index 33e602d..4e97bbb 100644 --- a/docs/workflows/WORKFLOW_MIGRATION_PLAN.md +++ b/docs/workflows/WORKFLOW_MIGRATION_PLAN.md @@ -4,6 +4,12 @@ Bring `/workflows` to full production parity with the existing legacy render pipeline without breaking the current legacy path at any time. +## Current Status + +- Phase 1 completed on canonical config storage, preset migration, and legacy-safe runtime extraction. +- Phase 2 completed on backend node registry, node definitions API, and schema-driven editor palette/settings. +- Next execution target: Phase 3 legacy step extraction for runtime parity. + ## Non-Negotiables - The legacy render path remains operational throughout the migration. diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index a879621..2c45532 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -101,9 +101,47 @@ export const deleteWorkflow = (id: string): Promise => export const getWorkflowRuns = (workflowId: string): Promise => api.get(`/workflows/${workflowId}/runs`).then(r => r.data) -// ─── Pipeline Steps ─────────────────────────────────────────────────────────── +// ─── Node Definitions / Pipeline Steps ─────────────────────────────────────── export type StepCategory = 'input' | 'processing' | 'rendering' | 'output' +export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean' +export type WorkflowNodeExecutionKind = 'native' | 'bridge' + +export interface WorkflowNodeFieldOption { + value: string | number | boolean + label: string +} + +export interface WorkflowNodeFieldDefinition { + key: string + label: string + type: WorkflowNodeFieldType + description: string + section: string + default: unknown + min: number | null + max: number | null + step: number | null + unit: string | null + options: WorkflowNodeFieldOption[] +} + +export interface WorkflowNodeDefinition { + step: string + label: string + category: StepCategory + description: string + node_type: string + icon: string + defaults: WorkflowParams + fields: WorkflowNodeFieldDefinition[] + execution_kind: WorkflowNodeExecutionKind + legacy_compatible: boolean +} + +export interface WorkflowNodeDefinitionsResponse { + definitions: WorkflowNodeDefinition[] +} export interface PipelineStep { name: string @@ -116,6 +154,9 @@ export interface PipelineStepsResponse { steps: PipelineStep[] } +export const getNodeDefinitions = (): Promise => + api.get('/workflows/node-definitions').then(r => r.data) + 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 96ec842..78d115e 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, DragEvent } from 'react' +import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react' import { ReactFlow, Background, @@ -22,7 +22,7 @@ import { createWorkflow, updateWorkflow, deleteWorkflow, - getPipelineSteps, + getNodeDefinitions, createPresetWorkflowConfig, getWorkflowPresetType, type WorkflowDefinition, @@ -30,8 +30,9 @@ import { type WorkflowEdge, type WorkflowPresetType, type WorkflowParams, - type PipelineStep, type StepCategory, + type WorkflowNodeDefinition, + type WorkflowNodeFieldDefinition, } from '../api/workflows' import { FileUp, @@ -40,6 +41,7 @@ import { Film, Layers, Download, + Bell, Plus, Save, Trash2, @@ -59,15 +61,49 @@ function normalizeWorkflowParams(params: WorkflowParams): WorkflowParams { return normalized } -function getResolutionSelection(params: WorkflowParams): number { - const resolution = Array.isArray(params.resolution) ? params.resolution : undefined - if (resolution && typeof resolution[0] === 'number') { - return Number(resolution[0]) +type WorkflowCanvasNodeData = { + label: string + params: WorkflowParams + step: string + description?: string + icon?: string + category?: StepCategory +} + +function renderWorkflowIcon(iconName?: string, size = 14) { + switch (iconName) { + case 'file-up': + return + case 'film': + return + case 'layers': + return + case 'download': + return + case 'bell': + return + case 'camera': + return + case 'refresh-cw': + default: + return } - if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) { - return params.width +} + +function buildNodeData( + step: string, + params: WorkflowParams = {}, + definition?: WorkflowNodeDefinition, + overrides?: Partial, +): WorkflowCanvasNodeData { + return { + label: overrides?.label ?? definition?.label ?? inferNodeLabel(step), + params: normalizeWorkflowParams(params), + step, + description: overrides?.description ?? definition?.description, + icon: overrides?.icon ?? definition?.icon, + category: overrides?.category ?? definition?.category, } - return 2048 } // ─── Custom Node Components ────────────────────────────────────────────────── @@ -75,14 +111,14 @@ function getResolutionSelection(params: WorkflowParams): number { interface BaseNodeProps { label: string icon: React.ReactNode - color: string + accentClass: string description?: string selected?: boolean hasSource?: boolean hasTarget?: boolean } -function BaseNode({ label, icon, color, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) { +function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) { return (
)} -
+
{icon} {label}
@@ -104,76 +140,80 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true, ) } -function InputNode({ selected }: { selected?: boolean }) { +function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( } - color="green" - description="STEP file input" + label={data.label} + icon={renderWorkflowIcon(data.icon)} + accentClass="text-green-600" + description={data.description} selected={selected} hasTarget={false} /> ) } -function ConvertNode({ selected }: { selected?: boolean }) { +function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( } - color="blue" - description="STEP → STL (cadquery)" + label={data.label} + icon={renderWorkflowIcon(data.icon)} + accentClass="text-blue-600" + description={data.description} selected={selected} /> ) } -function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) { +function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { + return ( + + ) +} + +function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { const params = data.params ?? {} return ( } - color="orange" - description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined} + label={data.label} + icon={renderWorkflowIcon(data.icon)} + accentClass="text-orange-600" + description={ + params.render_engine + ? `${params.render_engine} · ${params.samples ?? 256} samples` + : data.description + } selected={selected} /> ) } -function RenderFramesNode({ data, selected }: { data: { params?: WorkflowParams }; selected?: boolean }) { +function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { const params = data.params ?? {} return ( } - color="orange" - description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined} + label={data.label} + icon={renderWorkflowIcon(data.icon)} + accentClass="text-orange-600" + description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description} selected={selected} /> ) } -function FFmpegNode({ selected }: { selected?: boolean }) { +function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( } - color="purple" - description="Frames → MP4" - selected={selected} - /> - ) -} - -function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) { - return ( - } - color="gray" - description="Output file" + label={data.label} + icon={renderWorkflowIcon(data.icon)} + accentClass="text-slate-600" + description={data.description} selected={selected} hasSource={false} /> @@ -183,9 +223,9 @@ function OutputNode({ data, selected }: { data: { label?: string }; selected?: b const nodeTypes: NodeTypes = { inputNode: InputNode as any, convertNode: ConvertNode as any, + processNode: ProcessNode as any, renderNode: RenderNode as any, renderFramesNode: RenderFramesNode as any, - ffmpegNode: FFmpegNode as any, outputNode: OutputNode as any, } @@ -193,7 +233,11 @@ function inferNodeType(step: string): string { if (step === 'resolve_step_path') return 'inputNode' if (step === 'stl_cache_generate') return 'convertNode' if (step === 'blender_turntable') return 'renderFramesNode' - if (step === 'output_save' || step === 'export_blend') return 'outputNode' + if (step === 'output_save' || step === 'export_blend' || step === 'notify' || step === 'thumbnail_save') return 'outputNode' + if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode' + if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') { + return 'processNode' + } return 'renderNode' } @@ -207,22 +251,24 @@ function inferNodeLabel(step: string): string { function inferStepFromNodeType(type?: string): string { if (type === 'inputNode') return 'resolve_step_path' if (type === 'convertNode') return 'stl_cache_generate' + if (type === 'processNode') return 'order_line_setup' if (type === 'renderFramesNode') return 'blender_turntable' if (type === 'outputNode') return 'output_save' return 'blender_still' } -function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } { +function workflowToGraph( + config: WorkflowConfig, + nodeDefinitionsByStep: Record, +): { nodes: Node[]; edges: Edge[] } { return { nodes: config.nodes.map(node => ({ id: node.id, - type: node.ui?.type ?? inferNodeType(node.step), + type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step), position: node.ui?.position ?? { x: 0, y: 0 }, - data: { - label: node.ui?.label ?? inferNodeLabel(node.step), - params: node.params ?? {}, - step: node.step, - }, + data: buildNodeData(node.step, node.params ?? {}, nodeDefinitionsByStep[node.step], { + label: node.ui?.label ?? undefined, + }), })), edges: config.edges.map((edge, index) => ({ id: `e_${edge.from}_${edge.to}_${index}`, @@ -234,149 +280,155 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] // ─── Config Sidepanel ───────────────────────────────────────────────────────── +function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) { + return fields.reduce>((sections, field) => { + const section = field.section || 'General' + sections[section] = [...(sections[section] ?? []), field] + return sections + }, {}) +} + function ConfigSidepanel({ params, onChange, - pipelineStep, - onPipelineStepChange, - pipelineSteps, + nodeDefinition, + step, + onStepChange, + nodeDefinitions, }: { params: WorkflowParams onChange: (p: WorkflowParams) => void - pipelineStep?: string - onPipelineStepChange?: (step: string) => void - pipelineSteps: PipelineStep[] + nodeDefinition?: WorkflowNodeDefinition + step?: string + onStepChange?: (step: string) => void + nodeDefinitions: WorkflowNodeDefinition[] }) { + const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => { + onChange( + normalizeWorkflowParams({ + ...params, + [field.key]: value, + }), + ) + } + + const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent) => { + const rawValue = event.target.value + if (rawValue === '') { + const nextParams = { ...params } + delete nextParams[field.key] + onChange(nextParams) + return + } + updateField(field, Number(rawValue)) + } + + const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? []) + return (

Node Configuration

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

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

+ {nodeDefinition && ( +
+

{nodeDefinition.description}

+ + {nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'} + +
)}
)} - {/* Render Engine */} -
- -
- {(['cycles', 'eevee'] as const).map(eng => ( - - ))} -
-
+ {Object.keys(fieldsBySection).length === 0 && ( +

+ This node currently has no configurable settings in the editor. +

+ )} - {/* Samples */} -
- - onChange({ ...params, samples: Number(e.target.value) })} - className="w-full accent-accent" - /> -
- 1 - 4096 -
-
+ {Object.entries(fieldsBySection).map(([section, fields]) => ( +
+

+ {section} +

+ {fields.map(field => { + const rawValue = params[field.key] + const value = rawValue ?? field.default - {/* Resolution */} -
- -
- {([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => ( - - ))} + return ( +
+ + {field.type === 'select' && ( + + )} + {field.type === 'number' && ( + handleNumberChange(field, event)} + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" + /> + )} + {field.type === 'boolean' && ( + + )} + {field.description && ( +

{field.description}

+ )} +
+ ) + })}
-
- - {/* FPS (only relevant for animation nodes) */} -
- -
- {[12, 24, 30, 60].map(fps => ( - - ))} -
-
- - {/* Duration */} -
- - onChange({ ...params, duration_s: Number(e.target.value) })} - className="w-full accent-accent" - /> -
+ ))}
) } -// ─── Pipeline Steps Panel ───────────────────────────────────────────────────── +// ─── Node Definitions Panel ─────────────────────────────────────────────────── const CATEGORY_LABELS: Record = { input: 'Input', @@ -392,12 +444,12 @@ const CATEGORY_COLORS: Record = { output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', } -function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { +function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) { const [expanded, setExpanded] = useState(null) - const grouped = steps.reduce>( - (acc, step) => { - acc[step.category] = [...(acc[step.category] ?? []), step] + const grouped = definitions.reduce>( + (acc, definition) => { + acc[definition.category] = [...(acc[definition.category] ?? []), definition] return acc }, { input: [], processing: [], rendering: [], output: [] }, @@ -408,7 +460,7 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { return (

- Pipeline Steps + Available Nodes

{categories.map(cat => ( @@ -424,14 +476,26 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { {expanded === cat && (
- {grouped[cat].map(step => ( + {grouped[cat].map(definition => (
-

{step.name}

-

{step.description}

+
+

{definition.label}

+ + {definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'} + +
+

{definition.step}

+

{definition.description}

))}
@@ -443,16 +507,6 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { ) } -// ─── Node Palette ────────────────────────────────────────────────────────────── - -const NODE_PALETTE = [ - { type: 'convertNode', label: 'STEP→STL', icon: }, - { type: 'renderNode', label: 'Still Render', icon: }, - { type: 'renderFramesNode', label: 'Frame Render', icon: }, - { type: 'ffmpegNode', label: 'FFmpeg', icon: }, - { type: 'outputNode', label: 'Output', icon: }, -] - // ─── New Workflow Modal ─────────────────────────────────────────────────────── interface NewWorkflowModalProps { @@ -543,19 +597,26 @@ interface FlowCanvasProps { } function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { - const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config) + const { data: nodeDefinitionsData } = useQuery({ + queryKey: ['workflow-node-definitions'], + queryFn: getNodeDefinitions, + staleTime: 5 * 60 * 1000, + }) + const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] + const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])) + const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep) const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [selectedNodeId, setSelectedNodeId] = useState(null) 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 ?? [] + useEffect(() => { + const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep) + setNodes(graph.nodes) + setEdges(graph.edges) + setSelectedNodeId(null) + }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) const onConnect = useCallback( (connection: Connection) => setEdges(eds => addEdge(connection, eds)), @@ -586,15 +647,24 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const handlePipelineStepChange = useCallback( (stepName: string) => { + const definition = nodeDefinitionsByStep[stepName] setNodes(nds => nds.map(n => { if (n.id === selectedNodeId) { + const currentData = (n.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName) return { ...n, + type: definition?.node_type ?? inferNodeType(stepName), data: { - ...n.data, + ...buildNodeData( + stepName || inferStepFromNodeType(n.type), + { + ...(definition?.defaults ?? {}), + ...currentData.params, + }, + definition, + ), step: stepName || inferStepFromNodeType(n.type), - label: (n.data as any).label ?? inferNodeLabel(stepName), }, } } @@ -602,7 +672,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { }), ) }, - [selectedNodeId, setNodes], + [nodeDefinitionsByStep, selectedNodeId, setNodes], ) // Drag-drop new nodes from palette @@ -614,8 +684,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const onDrop = useCallback( (event: DragEvent) => { event.preventDefault() - const type = event.dataTransfer.getData('application/reactflow') - if (!type || !reactFlowInstance) return + const step = event.dataTransfer.getData('application/workflow-step') + if (!step || !reactFlowInstance) return + + const definition = nodeDefinitionsByStep[step] + const type = definition?.node_type ?? inferNodeType(step) const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, @@ -623,18 +696,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { }) const newNode: Node = { - id: `${type}_${Date.now()}`, + id: `${step}_${Date.now()}`, type, position, - data: { - label: type, - params: {}, - step: inferStepFromNodeType(type), - }, + data: buildNodeData(step, definition?.defaults ?? {}, definition), } setNodes(nds => [...nds, newNode]) }, - [reactFlowInstance, setNodes], + [nodeDefinitionsByStep, reactFlowInstance, setNodes], ) const handleSave = () => { @@ -667,20 +736,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { return (
{/* Canvas Toolbar */} -
- Nodes - {NODE_PALETTE.map(item => ( +
+ Nodes + {nodeDefinitions.map(definition => (
{ - e.dataTransfer.setData('application/reactflow', item.type) + e.dataTransfer.setData('application/workflow-step', definition.step) e.dataTransfer.effectAllowed = 'move' }} - className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none" + className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none whitespace-nowrap" + title={definition.description} > - {item.icon} - {item.label} + {renderWorkflowIcon(definition.icon)} + {definition.label}
))}
@@ -722,14 +792,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { )} - {!selectedNode && pipelineSteps.length > 0 && ( + {!selectedNode && nodeDefinitions.length > 0 && (
- +
)}