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", "text"] ExecutionKind = Literal["native", "bridge"] WorkflowNodeFamily = Literal["cad_file", "order_line"] 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 family: WorkflowNodeFamily module_key: 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 input_contract: dict[str, Any] = {} output_contract: dict[str, Any] = {} artifact_roles_produced: list[str] = [] artifact_roles_consumed: list[str] = [] legacy_source: str | None = None 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, family: WorkflowNodeFamily, module_key: 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", input_contract: dict[str, Any] | None = None, output_contract: dict[str, Any] | None = None, artifact_roles_produced: list[str] | None = None, artifact_roles_consumed: list[str] | None = None, legacy_source: str | None = None, ) -> WorkflowNodeDefinition: return WorkflowNodeDefinition( step=step.value, label=label, family=family, module_key=module_key, category=category, description=description, node_type=node_type, icon=icon, defaults=defaults or {}, fields=fields or [], execution_kind=execution_kind, input_contract=input_contract or {}, output_contract=output_contract or {}, artifact_roles_produced=artifact_roles_produced or [], artifact_roles_consumed=artifact_roles_consumed or [], legacy_source=legacy_source or f"legacy_step:{step.value}", ) _BLENDER_ENGINE_OPTIONS = [("cycles", "Cycles"), ("eevee", "EEVEE")] _CYCLES_DEVICE_OPTIONS = [("auto", "Auto"), ("gpu", "GPU"), ("cpu", "CPU")] _GPU_TOGGLE_OPTIONS = [("", "Inherited / Default"), ("1", "Enabled"), ("0", "Disabled")] _TURNTABLE_AXIS_OPTIONS = [ ("world_z", "World Z"), ("world_x", "World X"), ("world_y", "World Y"), ] _NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [ _definition( StepName.RESOLVE_STEP_PATH, "Resolve STEP Path", "cad_file", "cad.resolve_step_path", "input", "Locate the STEP file on disk from the CAD file record.", node_type="inputNode", icon="file-up", execution_kind="bridge", input_contract={"context": "cad_file", "requires": ["cad_file_record"]}, output_contract={"context": "cad_file", "provides": ["step_path", "step_file_hash"]}, artifact_roles_produced=["step_path"], ), _definition( StepName.OCC_OBJECT_EXTRACT, "Extract STEP Objects", "cad_file", "cad.extract_objects", "processing", "Extract part objects and metadata from the STEP file via OCC/cadquery.", node_type="processNode", icon="layers", execution_kind="bridge", input_contract={"context": "cad_file", "requires": ["step_path"]}, output_contract={"context": "cad_file", "provides": ["cad_objects", "cad_metadata"]}, artifact_roles_consumed=["step_path"], artifact_roles_produced=["cad_objects", "cad_metadata"], ), _definition( StepName.OCC_GLB_EXPORT, "Export GLB", "cad_file", "cad.export_glb", "processing", "Convert STEP geometry into GLB for previews and downstream rendering.", node_type="processNode", icon="refresh-cw", execution_kind="bridge", input_contract={"context": "cad_file", "requires": ["step_path"]}, output_contract={"context": "cad_file", "provides": ["glb_preview"]}, artifact_roles_consumed=["step_path"], artifact_roles_produced=["glb_preview"], ), _definition( StepName.GLB_BBOX, "Compute Bounding Box", "order_line", "geometry.compute_bbox", "processing", "Compute the model bounding box from the exported GLB for framing decisions.", node_type="processNode", icon="layers", execution_kind="bridge", fields=[ _field( "glb_path", "GLB Path Override", "text", description="Optional absolute path to a specific GLB file. Leave empty to reuse the prepared preview/export artifact automatically.", section="Inputs", default="", ), ], input_contract={"context": "order_line", "requires": ["glb_preview"]}, output_contract={"context": "order_line", "provides": ["bbox"]}, artifact_roles_consumed=["glb_preview"], artifact_roles_produced=["bbox"], ), _definition( StepName.MATERIAL_MAP_RESOLVE, "Resolve Material Map", "order_line", "materials.resolve_map", "processing", "Map raw part material names to HartOMat material records via aliases.", node_type="processNode", icon="layers", execution_kind="bridge", input_contract={"context": "order_line", "requires": ["order_line_context", "cad_materials"]}, output_contract={"context": "order_line", "provides": ["material_assignments"]}, artifact_roles_consumed=["order_line_context", "cad_materials"], artifact_roles_produced=["material_assignments"], ), _definition( StepName.AUTO_POPULATE_MATERIALS, "Auto Populate Materials", "order_line", "materials.auto_populate", "processing", "Create missing material records for newly discovered part materials.", node_type="processNode", icon="layers", execution_kind="bridge", input_contract={"context": "order_line", "requires": ["cad_materials"]}, output_contract={"context": "order_line", "provides": ["material_catalog_updates"]}, artifact_roles_consumed=["cad_materials"], artifact_roles_produced=["material_catalog_updates"], ), _definition( StepName.BLENDER_RENDER, "Render Thumbnail (Blender)", "cad_file", "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=_BLENDER_ENGINE_OPTIONS, ), _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"), ], input_contract={"context": "cad_file", "requires": ["step_path"]}, output_contract={"context": "cad_file", "provides": ["rendered_image"]}, artifact_roles_consumed=["step_path"], artifact_roles_produced=["rendered_image"], ), _definition( StepName.THREEJS_RENDER, "Render Thumbnail (Three.js)", "cad_file", "render.thumbnail.threejs", "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", input_contract={"context": "cad_file", "requires": ["glb_preview", "bbox"]}, output_contract={"context": "cad_file", "provides": ["rendered_image"]}, artifact_roles_consumed=["glb_preview", "bbox"], artifact_roles_produced=["rendered_image"], ), _definition( StepName.THUMBNAIL_SAVE, "Save Thumbnail", "cad_file", "media.save_thumbnail", "output", "Persist the generated thumbnail back onto the CAD file record.", node_type="outputNode", icon="download", execution_kind="bridge", input_contract={"context": "cad_file", "requires": ["rendered_image"]}, output_contract={"context": "cad_file", "provides": ["cad_thumbnail_media"]}, artifact_roles_consumed=["rendered_image"], artifact_roles_produced=["cad_thumbnail_media"], ), _definition( StepName.ORDER_LINE_SETUP, "Order Line Setup", "order_line", "order_line.prepare_render_context", "processing", "Validate order-line inputs and prepare the render job context.", node_type="processNode", icon="layers", execution_kind="bridge", input_contract={"context": "order_line", "requires": ["order_line_record"]}, output_contract={ "context": "order_line", "provides": [ "order_line_context", "cad_file_ref", "step_path", "cad_materials", "glb_preview", "bbox", "usd_render_path", "glb_reuse_path", ], }, artifact_roles_produced=[ "order_line_context", "cad_file_ref", "step_path", "cad_materials", "glb_preview", "bbox", "usd_render_path", "glb_reuse_path", ], ), _definition( StepName.RESOLVE_TEMPLATE, "Resolve Template", "order_line", "rendering.resolve_template", "processing", "Resolve the render template for the order line and output type.", node_type="processNode", icon="layers", execution_kind="bridge", input_contract={"context": "order_line", "requires": ["order_line_context"]}, output_contract={ "context": "order_line", "provides": [ "render_template", "output_profile", "template_path", "material_library", "material_map", "use_materials", "override_material", "category_key", ], }, artifact_roles_consumed=["order_line_context"], artifact_roles_produced=[ "render_template", "output_profile", "template_path", "material_library", "material_map", "use_materials", "override_material", "category_key", ], ), _definition( StepName.BLENDER_STILL, "Render Still", "order_line", "render.production.still", "rendering", "Render a production still image with Blender.", node_type="renderNode", icon="camera", defaults={"use_custom_render_settings": False, "rotation_z": 0}, fields=[ _field( "use_custom_render_settings", "Custom Render Settings", "boolean", description="Enable explicit engine, sample, and resolution overrides for Graph/Shadow mode. When disabled, authoritative output-type and template settings are inherited.", section="Render", default=False, ), _field( "render_engine", "Render Engine", "select", description="Renderer backend for the still render.", section="Render", default="cycles", options=_BLENDER_ENGINE_OPTIONS, ), _field( "cycles_device", "Cycles Device", "select", description="Force CPU, GPU, or automatic device selection.", section="Render", default="auto", options=_CYCLES_DEVICE_OPTIONS, ), _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( "transparent_bg", "Transparent Background", "boolean", description="Render with alpha output instead of an opaque background.", section="Output", default=False, ), _field( "noise_threshold", "Noise Threshold", "text", description="Optional Cycles adaptive sampling threshold, for example 0.01.", section="Denoising", default="", ), _field( "denoiser", "Denoiser", "text", description="Optional Blender denoiser name, for example OPENIMAGEDENOISE or OPTIX.", section="Denoising", default="", ), _field( "denoising_input_passes", "Input Passes", "text", description="Optional Cycles denoising input pass mode.", section="Denoising", default="", ), _field( "denoising_prefilter", "Prefilter", "text", description="Optional Cycles denoising prefilter mode.", section="Denoising", default="", ), _field( "denoising_quality", "Denoising Quality", "text", description="Optional Cycles denoising quality mode.", section="Denoising", default="", ), _field( "denoising_use_gpu", "Use GPU Denoising", "select", description="Override whether Blender denoising should run on the GPU.", section="Denoising", default="", options=_GPU_TOGGLE_OPTIONS, ), _field( "target_collection", "Target Collection", "text", description="Template collection name that receives the imported product geometry.", section="Scene", default="Product", ), _field( "lighting_only", "Lighting Only", "boolean", description="Use template lighting and auto-framing without template materials.", section="Scene", default=False, ), _field( "shadow_catcher", "Shadow Catcher", "boolean", description="Enable a shadow catcher plane for composited renders.", section="Scene", default=False, ), _field( "rotation_x", "Rotation X", "number", description="Additional X-axis rotation in degrees.", section="Camera", default=0, min=-360, max=360, step=1, unit="deg", ), _field( "rotation_y", "Rotation Y", "number", description="Additional Y-axis rotation in degrees.", section="Camera", default=0, min=-360, max=360, step=1, unit="deg", ), _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", ), _field( "focal_length_mm", "Focal Length", "number", description="Optional camera focal length override.", section="Camera", default=None, min=1, max=500, step=0.1, unit="mm", ), _field( "sensor_width_mm", "Sensor Width", "number", description="Optional camera sensor width override.", section="Camera", default=None, min=1, max=100, step=0.1, unit="mm", ), _field( "material_override", "Material Override", "text", description="Optional material name forced onto all parts during rendering.", section="Materials", default="", ), ], input_contract={ "context": "order_line", "requires": ["order_line_context", "render_template", "material_assignments", "bbox"], }, output_contract={"context": "order_line", "provides": ["rendered_image"]}, artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"], artifact_roles_produced=["rendered_image"], ), _definition( StepName.BLENDER_TURNTABLE, "Render Turntable", "order_line", "render.production.turntable", "rendering", "Render an animated turntable sequence with Blender.", node_type="renderFramesNode", icon="film", defaults={ "use_custom_render_settings": False, "fps": 24, "duration_s": 5, "rotation_z": 0, }, fields=[ _field( "use_custom_render_settings", "Custom Render Settings", "boolean", description="Enable explicit engine, sample, and resolution overrides for Graph/Shadow mode. When disabled, authoritative output-type and template settings are inherited.", section="Render", default=False, ), _field( "render_engine", "Render Engine", "select", description="Renderer backend for the turntable job.", section="Render", default="cycles", options=_BLENDER_ENGINE_OPTIONS, ), _field( "cycles_device", "Cycles Device", "select", description="Force CPU, GPU, or automatic device selection.", section="Render", default="auto", options=_CYCLES_DEVICE_OPTIONS, ), _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( "transparent_bg", "Transparent Background", "boolean", description="Render transparent PNG frames for alpha output or FFmpeg background compositing.", section="Output", default=False, ), _field( "bg_color", "Background Color", "text", description="Optional hex color used during FFmpeg compositing, for example #FFFFFF.", section="Output", default="", ), _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( "turntable_degrees", "Turntable Degrees", "number", description="How far the model or camera rotates during the animation.", section="Animation", default=360, min=1, max=3600, step=1, unit="deg", ), _field( "turntable_axis", "Turntable Axis", "select", description="Axis used for the model or camera orbit.", section="Animation", default="world_z", options=_TURNTABLE_AXIS_OPTIONS, ), _field( "camera_orbit", "Camera Orbit", "boolean", description="Orbit the camera instead of rotating the model.", section="Animation", default=True, ), _field( "target_collection", "Target Collection", "text", description="Template collection name that receives the imported product geometry.", section="Scene", default="Product", ), _field( "lighting_only", "Lighting Only", "boolean", description="Use template lighting and auto-framing without template materials.", section="Scene", default=False, ), _field( "shadow_catcher", "Shadow Catcher", "boolean", description="Enable a shadow catcher plane for composited renders.", section="Scene", default=False, ), _field( "rotation_x", "Rotation X", "number", description="Additional X-axis rotation in degrees.", section="Camera", default=0, min=-360, max=360, step=1, unit="deg", ), _field( "rotation_y", "Rotation Y", "number", description="Additional Y-axis rotation in degrees.", section="Camera", default=0, min=-360, max=360, step=1, unit="deg", ), _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", ), _field( "focal_length_mm", "Focal Length", "number", description="Optional camera focal length override.", section="Camera", default=None, min=1, max=500, step=0.1, unit="mm", ), _field( "sensor_width_mm", "Sensor Width", "number", description="Optional camera sensor width override.", section="Camera", default=None, min=1, max=100, step=0.1, unit="mm", ), _field( "material_override", "Material Override", "text", description="Optional material name forced onto all parts during rendering.", section="Materials", default="", ), ], input_contract={ "context": "order_line", "requires": ["order_line_context", "render_template", "material_assignments", "bbox"], }, output_contract={"context": "order_line", "provides": ["rendered_frames", "rendered_video"]}, artifact_roles_consumed=["order_line_context", "render_template", "material_assignments", "bbox"], artifact_roles_produced=["rendered_frames", "rendered_video"], ), _definition( StepName.OUTPUT_SAVE, "Save Output", "order_line", "media.save_output", "output", "Persist the rendered output file and create the media record.", node_type="outputNode", icon="download", execution_kind="bridge", input_contract={ "context": "order_line", "requires": ["order_line_context"], "requires_any": ["rendered_image", "rendered_frames", "rendered_video"], }, output_contract={"context": "order_line", "provides": ["media_asset", "workflow_result"]}, artifact_roles_consumed=["rendered_image", "rendered_frames", "rendered_video"], artifact_roles_produced=["media_asset", "workflow_result"], ), _definition( StepName.EXPORT_BLEND, "Export Blend", "order_line", "media.export_blend", "output", "Persist the generated .blend file as a downloadable media asset.", node_type="outputNode", icon="download", defaults={"output_name_suffix": ""}, fields=[ _field( "output_name_suffix", "Output Name Suffix", "text", description="Optional suffix appended to the generated `.blend` filename.", section="Output", default="", ), ], execution_kind="bridge", input_contract={"context": "order_line", "requires": ["order_line_context", "render_template"]}, output_contract={"context": "order_line", "provides": ["blend_asset"]}, artifact_roles_consumed=["order_line_context", "render_template"], artifact_roles_produced=["blend_asset"], ), _definition( StepName.STL_CACHE_GENERATE, "Generate STL Cache", "cad_file", "cad.generate_stl_cache", "processing", "Generate and cache STL derivatives next to the STEP source.", node_type="convertNode", icon="refresh-cw", execution_kind="bridge", input_contract={"context": "cad_file", "requires": ["step_path"]}, output_contract={"context": "cad_file", "provides": ["stl_cache"]}, artifact_roles_consumed=["step_path"], artifact_roles_produced=["stl_cache"], ), _definition( StepName.NOTIFY, "Notify", "order_line", "notifications.emit", "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", input_contract={ "context": "order_line", "requires": ["order_line_context"], "requires_any": ["rendered_image", "rendered_frames", "rendered_video", "workflow_result"], }, output_contract={"context": "order_line", "provides": ["notification_event"]}, artifact_roles_consumed=["workflow_result"], artifact_roles_produced=["notification_event"], ), ] _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