Phase 1-7 of Canvas V4 Ralph Loop implementation: Backend: - Add /api/files routes for browsing model files - Add /api/nx routes for NX model introspection - Add NXIntrospector service to discover expressions and extractors - Add health check with database status Frontend: - Add FileBrowser component for selecting .sim/.prt/.fem files - Add IntrospectionPanel to discover expressions and extractors - Update NodeConfigPanel with browse and introspect buttons - Update schema with NODE_HANDLES for proper flow direction - Update validation for correct DesignVar -> Model -> Solver flow - Update useCanvasStore.addNode() to accept custom data Flow correction: Design Variables now connect TO Model (as source), not FROM Model. This matches the actual data flow in optimization. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { FolderSearch, Microscope } from 'lucide-react';
|
|
import { useCanvasStore } from '../../../hooks/useCanvasStore';
|
|
import { ExpressionSelector } from './ExpressionSelector';
|
|
import { FileBrowser } from './FileBrowser';
|
|
import { IntrospectionPanel } from './IntrospectionPanel';
|
|
import {
|
|
ModelNodeData,
|
|
SolverNodeData,
|
|
DesignVarNodeData,
|
|
AlgorithmNodeData,
|
|
ObjectiveNodeData,
|
|
ExtractorNodeData,
|
|
ConstraintNodeData,
|
|
SurrogateNodeData
|
|
} from '../../../lib/canvas/schema';
|
|
|
|
interface NodeConfigPanelProps {
|
|
nodeId: string;
|
|
}
|
|
|
|
// 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";
|
|
|
|
export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
|
|
const { nodes, updateNodeData, deleteSelected } = useCanvasStore();
|
|
const node = nodes.find((n) => n.id === nodeId);
|
|
|
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
|
const [showIntrospection, setShowIntrospection] = useState(false);
|
|
|
|
if (!node) return null;
|
|
|
|
const { data } = node;
|
|
|
|
const handleChange = (field: string, value: unknown) => {
|
|
updateNodeData(nodeId, { [field]: value, configured: true });
|
|
};
|
|
|
|
return (
|
|
<div className="w-80 bg-dark-850 border-l border-dark-700 p-4 overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-semibold text-white">Configure {data.label}</h3>
|
|
<button
|
|
onClick={deleteSelected}
|
|
className="text-red-400 hover:text-red-300 text-sm transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{/* Common: Label */}
|
|
<div>
|
|
<label className={labelClass}>
|
|
Label
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={data.label}
|
|
onChange={(e) => handleChange('label', e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
|
|
{/* Type-specific fields */}
|
|
{data.type === 'model' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Model File
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={(data as ModelNodeData).filePath || ''}
|
|
onChange={(e) => handleChange('filePath', e.target.value)}
|
|
placeholder="path/to/model.sim"
|
|
className={`${inputClass} font-mono text-sm flex-1`}
|
|
/>
|
|
<button
|
|
onClick={() => setShowFileBrowser(true)}
|
|
className="px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-dark-300 hover:text-white transition-colors"
|
|
title="Browse files"
|
|
>
|
|
<FolderSearch size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
File Type
|
|
</label>
|
|
<select
|
|
value={(data as ModelNodeData).fileType || ''}
|
|
onChange={(e) => handleChange('fileType', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="">Select...</option>
|
|
<option value="prt">Part (.prt)</option>
|
|
<option value="fem">FEM (.fem)</option>
|
|
<option value="sim">Simulation (.sim)</option>
|
|
<option value="afem">Assembled FEM (.afem)</option>
|
|
</select>
|
|
</div>
|
|
{/* Introspect Button */}
|
|
{(data as ModelNodeData).filePath && (
|
|
<button
|
|
onClick={() => setShowIntrospection(true)}
|
|
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
|
|
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
|
|
text-primary-400 text-sm font-medium transition-colors"
|
|
>
|
|
<Microscope size={16} />
|
|
Introspect Model
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'solver' && (
|
|
<div>
|
|
<label className={labelClass}>
|
|
Solution Type
|
|
</label>
|
|
<select
|
|
value={(data as SolverNodeData).solverType || ''}
|
|
onChange={(e) => handleChange('solverType', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="">Select...</option>
|
|
<option value="SOL101">SOL 101 - Linear Static</option>
|
|
<option value="SOL103">SOL 103 - Modal Analysis</option>
|
|
<option value="SOL105">SOL 105 - Buckling</option>
|
|
<option value="SOL106">SOL 106 - Nonlinear Static</option>
|
|
<option value="SOL111">SOL 111 - Frequency Response</option>
|
|
<option value="SOL112">SOL 112 - Transient Response</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{data.type === 'designVar' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Expression Name
|
|
</label>
|
|
<ExpressionSelector
|
|
value={(data as DesignVarNodeData).expressionName || ''}
|
|
onChange={(name, value, units) => {
|
|
handleChange('expressionName', name);
|
|
handleChange('label', name || 'Design Variable');
|
|
if (units) handleChange('unit', units);
|
|
// Set default min/max around current value
|
|
if (value !== undefined) {
|
|
const dvData = data as DesignVarNodeData;
|
|
if (dvData.minValue === undefined) {
|
|
handleChange('minValue', value * 0.5);
|
|
}
|
|
if (dvData.maxValue === undefined) {
|
|
handleChange('maxValue', value * 1.5);
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Select NX expression..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className={labelClass}>
|
|
Min
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={(data as DesignVarNodeData).minValue ?? ''}
|
|
onChange={(e) => handleChange('minValue', parseFloat(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Max
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={(data as DesignVarNodeData).maxValue ?? ''}
|
|
onChange={(e) => handleChange('maxValue', parseFloat(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Unit
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={(data as DesignVarNodeData).unit || ''}
|
|
onChange={(e) => handleChange('unit', e.target.value)}
|
|
placeholder="mm"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'extractor' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Extractor ID
|
|
</label>
|
|
<select
|
|
value={(data as ExtractorNodeData).extractorId || ''}
|
|
onChange={(e) => {
|
|
const id = e.target.value;
|
|
const names: Record<string, string> = {
|
|
'E1': 'Displacement',
|
|
'E2': 'Frequency',
|
|
'E3': 'Solid Stress',
|
|
'E4': 'BDF Mass',
|
|
'E5': 'CAD Mass',
|
|
'E8': 'Zernike (OP2)',
|
|
'E9': 'Zernike (CSV)',
|
|
'E10': 'Zernike (RMS)',
|
|
};
|
|
handleChange('extractorId', id);
|
|
handleChange('extractorName', names[id] || id);
|
|
}}
|
|
className={selectClass}
|
|
>
|
|
<option value="">Select...</option>
|
|
<option value="E1">E1 - Displacement</option>
|
|
<option value="E2">E2 - Frequency</option>
|
|
<option value="E3">E3 - Solid Stress</option>
|
|
<option value="E4">E4 - BDF Mass</option>
|
|
<option value="E5">E5 - CAD Mass</option>
|
|
<option value="E8">E8 - Zernike (OP2)</option>
|
|
<option value="E9">E9 - Zernike (CSV)</option>
|
|
<option value="E10">E10 - Zernike (RMS)</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'algorithm' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Method
|
|
</label>
|
|
<select
|
|
value={(data as AlgorithmNodeData).method || ''}
|
|
onChange={(e) => handleChange('method', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="">Select...</option>
|
|
<option value="TPE">TPE (Tree Parzen Estimator)</option>
|
|
<option value="CMA-ES">CMA-ES (Evolution Strategy)</option>
|
|
<option value="NSGA-II">NSGA-II (Multi-Objective)</option>
|
|
<option value="GP-BO">GP-BO (Gaussian Process)</option>
|
|
<option value="RandomSearch">Random Search</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Max Trials
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={(data as AlgorithmNodeData).maxTrials ?? ''}
|
|
onChange={(e) => handleChange('maxTrials', parseInt(e.target.value))}
|
|
placeholder="100"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'objective' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Objective Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={(data as ObjectiveNodeData).name || ''}
|
|
onChange={(e) => handleChange('name', e.target.value)}
|
|
placeholder="mass"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Direction
|
|
</label>
|
|
<select
|
|
value={(data as ObjectiveNodeData).direction || 'minimize'}
|
|
onChange={(e) => handleChange('direction', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="minimize">Minimize</option>
|
|
<option value="maximize">Maximize</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Weight
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={(data as ObjectiveNodeData).weight ?? 1}
|
|
onChange={(e) => handleChange('weight', parseFloat(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'constraint' && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Constraint Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={(data as ConstraintNodeData).name || ''}
|
|
onChange={(e) => handleChange('name', e.target.value)}
|
|
placeholder="max_stress"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className={labelClass}>
|
|
Operator
|
|
</label>
|
|
<select
|
|
value={(data as ConstraintNodeData).operator || '<='}
|
|
onChange={(e) => handleChange('operator', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="<"><</option>
|
|
<option value="<="><=</option>
|
|
<option value=">">></option>
|
|
<option value=">=">>=</option>
|
|
<option value="==">==</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Value
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={(data as ConstraintNodeData).value ?? ''}
|
|
onChange={(e) => handleChange('value', parseFloat(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{data.type === 'surrogate' && (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="surrogate-enabled"
|
|
checked={(data as SurrogateNodeData).enabled || false}
|
|
onChange={(e) => handleChange('enabled', e.target.checked)}
|
|
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
|
|
/>
|
|
<label htmlFor="surrogate-enabled" className="text-sm font-medium text-dark-300">
|
|
Enable Neural Surrogate
|
|
</label>
|
|
</div>
|
|
{(data as SurrogateNodeData).enabled && (
|
|
<>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Model Type
|
|
</label>
|
|
<select
|
|
value={(data as SurrogateNodeData).modelType || ''}
|
|
onChange={(e) => handleChange('modelType', e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
<option value="">Select...</option>
|
|
<option value="MLP">MLP (Multi-Layer Perceptron)</option>
|
|
<option value="GNN">GNN (Graph Neural Network)</option>
|
|
<option value="Ensemble">Ensemble</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Min Trials Before Activation
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={(data as SurrogateNodeData).minTrials ?? 20}
|
|
onChange={(e) => handleChange('minTrials', parseInt(e.target.value))}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* File Browser Modal */}
|
|
<FileBrowser
|
|
isOpen={showFileBrowser}
|
|
onClose={() => setShowFileBrowser(false)}
|
|
onSelect={(path, fileType) => {
|
|
handleChange('filePath', path);
|
|
handleChange('fileType', fileType.replace('.', ''));
|
|
}}
|
|
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
|
/>
|
|
|
|
{/* Introspection Panel */}
|
|
{showIntrospection && (data as ModelNodeData).filePath && (
|
|
<div className="fixed top-20 right-96 z-40">
|
|
<IntrospectionPanel
|
|
filePath={(data as ModelNodeData).filePath!}
|
|
onClose={() => setShowIntrospection(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|