/** * NodeConfigPanelV2 - Configuration panel for AtomizerSpec v2.0 nodes * * This component uses useSpecStore instead of the legacy useCanvasStore. * It renders type-specific configuration forms based on the selected node. */ import { useState, useMemo, useCallback } from 'react'; import { Microscope, Trash2, X, AlertCircle } from 'lucide-react'; import { useSpecStore, useSpec, useSelectedNodeId, useSelectedNode, } from '../../../hooks/useSpecStore'; import { FileBrowser } from './FileBrowser'; import { IntrospectionPanel } from './IntrospectionPanel'; import { DesignVariable, Extractor, Objective, Constraint, } from '../../../types/atomizer-spec'; // Common input class for dark theme const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors"; const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors"; const labelClass = "block text-sm font-medium text-dark-300 mb-1"; interface NodeConfigPanelV2Props { /** Called when panel should close */ onClose?: () => void; } export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) { const spec = useSpec(); const selectedNodeId = useSelectedNodeId(); const selectedNode = useSelectedNode(); const { updateNode, removeNode, clearSelection } = useSpecStore(); const [showFileBrowser, setShowFileBrowser] = useState(false); const [showIntrospection, setShowIntrospection] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState(null); // Determine node type from ID prefix or from the node itself const nodeType = useMemo(() => { if (!selectedNodeId) return null; // Synthetic nodes have fixed IDs if (selectedNodeId === 'model') return 'model'; if (selectedNodeId === 'solver') return 'solver'; if (selectedNodeId === 'algorithm') return 'algorithm'; if (selectedNodeId === 'surrogate') return 'surrogate'; // Real nodes have prefixed IDs const prefix = selectedNodeId.split('_')[0]; switch (prefix) { case 'dv': return 'designVar'; case 'ext': return 'extractor'; case 'obj': return 'objective'; case 'con': return 'constraint'; default: return null; } }, [selectedNodeId]); // Get label for display const nodeLabel = useMemo(() => { if (!selectedNodeId || !spec) return 'Node'; switch (nodeType) { case 'model': return spec.meta.study_name || 'Model'; case 'solver': return spec.model.sim?.solution_type || 'Solver'; case 'algorithm': return spec.optimization.algorithm?.type || 'Algorithm'; case 'surrogate': return 'Neural Surrogate'; default: if (selectedNode) { return (selectedNode as any).name || selectedNodeId; } return selectedNodeId; } }, [selectedNodeId, selectedNode, nodeType, spec]); // Handle field changes const handleChange = useCallback(async (field: string, value: unknown) => { if (!selectedNodeId || !selectedNode) return; setIsUpdating(true); setError(null); try { await updateNode(selectedNodeId, { [field]: value }); } catch (err) { console.error('Failed to update node:', err); setError(err instanceof Error ? err.message : 'Update failed'); } finally { setIsUpdating(false); } }, [selectedNodeId, selectedNode, updateNode]); // Handle delete const handleDelete = useCallback(async () => { if (!selectedNodeId) return; // Synthetic nodes can't be deleted if (['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId)) { setError('This node cannot be deleted'); return; } setIsUpdating(true); setError(null); try { await removeNode(selectedNodeId); clearSelection(); onClose?.(); } catch (err) { console.error('Failed to delete node:', err); setError(err instanceof Error ? err.message : 'Delete failed'); } finally { setIsUpdating(false); } }, [selectedNodeId, removeNode, clearSelection, onClose]); // Don't render if no node selected if (!selectedNodeId || !spec) { return null; } // Check if this is a synthetic node (model, solver, algorithm, surrogate) const isSyntheticNode = ['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId); return (
{/* Header */}

Configure {nodeLabel}

{!isSyntheticNode && ( )} {onClose && ( )}
{/* Error display */} {error && (

{error}

)} {/* Content */}
{/* Loading indicator */} {isUpdating && (
Updating...
)} {/* Model node (synthetic) */} {nodeType === 'model' && spec.model && ( )} {/* Solver node (synthetic) */} {nodeType === 'solver' && ( )} {/* Algorithm node (synthetic) */} {nodeType === 'algorithm' && ( )} {/* Surrogate node (synthetic) */} {nodeType === 'surrogate' && ( )} {/* Design Variable */} {nodeType === 'designVar' && selectedNode && ( )} {/* Extractor */} {nodeType === 'extractor' && selectedNode && ( )} {/* Objective */} {nodeType === 'objective' && selectedNode && ( )} {/* Constraint */} {nodeType === 'constraint' && selectedNode && ( )}
{/* File Browser Modal */} setShowFileBrowser(false)} onSelect={() => { // This would update the model path - but model is synthetic setShowFileBrowser(false); }} fileTypes={['.sim', '.prt', '.fem', '.afem']} /> {/* Introspection Panel */} {showIntrospection && spec.model.sim?.path && (
setShowIntrospection(false)} />
)}
); } // ============================================================================ // Type-specific configuration components // ============================================================================ interface SpecConfigProps { spec: NonNullable>; } function ModelNodeConfig({ spec }: SpecConfigProps) { const [showIntrospection, setShowIntrospection] = useState(false); return ( <>

Read-only. Set in study configuration.

{spec.model.sim?.path && ( )} {showIntrospection && spec.model.sim?.path && (
setShowIntrospection(false)} />
)} ); } function SolverNodeConfig({ spec }: SpecConfigProps) { return (

Detected from model file.

); } function AlgorithmNodeConfig({ spec }: SpecConfigProps) { const algo = spec.optimization.algorithm; return ( <>

Edit in optimization settings.

); } function SurrogateNodeConfig({ spec }: SpecConfigProps) { const surrogate = spec.optimization.surrogate; return ( <>
{surrogate?.enabled && ( <>
)}

Edit in optimization settings.

); } // ============================================================================ // Editable node configs // ============================================================================ interface DesignVarNodeConfigProps { node: DesignVariable; onChange: (field: string, value: unknown) => void; } function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('expression_name', e.target.value)} placeholder="NX expression name" className={`${inputClass} font-mono text-sm`} />
onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })} className={inputClass} />
onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })} className={inputClass} />
{node.baseline !== undefined && (
onChange('baseline', parseFloat(e.target.value))} className={inputClass} />
)}
onChange('units', e.target.value)} placeholder="mm" className={inputClass} />
onChange('enabled', e.target.checked)} className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500" />
); } interface ExtractorNodeConfigProps { node: Extractor; onChange: (field: string, value: unknown) => void; } function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) { const extractorOptions = [ { id: 'E1', name: 'Displacement', type: 'displacement' }, { id: 'E2', name: 'Frequency', type: 'frequency' }, { id: 'E3', name: 'Solid Stress', type: 'solid_stress' }, { id: 'E4', name: 'BDF Mass', type: 'mass_bdf' }, { id: 'E5', name: 'CAD Mass', type: 'mass_expression' }, { id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' }, { id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' }, { id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' }, ]; return ( <>
onChange('name', e.target.value)} className={inputClass} />
{node.type === 'custom_function' && node.function && (

Edit custom code in dedicated editor.

)}
o.name).join(', ') || ''} readOnly placeholder="value, unit" className={`${inputClass} bg-dark-900 cursor-not-allowed`} />

Outputs are defined by extractor type.

); } interface ObjectiveNodeConfigProps { node: Objective; onChange: (field: string, value: unknown) => void; } function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('weight', parseFloat(e.target.value))} className={inputClass} />
{node.target !== undefined && (
onChange('target', parseFloat(e.target.value))} className={inputClass} />
)} ); } interface ConstraintNodeConfigProps { node: Constraint; onChange: (field: string, value: unknown) => void; } function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('threshold', parseFloat(e.target.value))} className={inputClass} />
); } export default NodeConfigPanelV2;