chore: Project cleanup and Canvas UX improvements (Phase 7-9)

## Cleanup (v0.5.0)
- Delete 102+ orphaned MCP session temp files
- Remove build artifacts (htmlcov, dist, __pycache__)
- Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Move debug/analysis scripts from tests/ to tools/analysis/
- Archive redundant NX journals to archive/nx_journals/
- Archive monolithic PROTOCOL.md to docs/archive/
- Update .gitignore with missing patterns
- Clean old study files (optimization_log_old.txt, run_optimization_old.py)

## Canvas UX (Phases 7-9)
- Phase 7: Resizable panels with localStorage persistence
  - Left sidebar: 200-400px, Right panel: 280-600px
  - New useResizablePanel hook and ResizeHandle component
- Phase 8: Enable all palette items
  - All 8 node types now draggable
  - Singleton logic for model/solver/algorithm/surrogate
- Phase 9: Solver configuration
  - Add SolverEngine type (nxnastran, mscnastran, python, etc.)
  - Add NastranSolutionType (SOL101-SOL200)
  - Engine/solution dropdowns in config panel
  - Python script path support

## Documentation
- Update CHANGELOG.md with recent versions
- Update docs/00_INDEX.md
- Create examples/README.md
- Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
This commit is contained in:
2026-01-24 15:17:34 -05:00
parent 2cb8dccc3a
commit a3f18dc377
38 changed files with 1172 additions and 2570 deletions

View File

@@ -0,0 +1,67 @@
/**
* ResizeHandle - Visual drag handle for resizable panels
*
* A thin vertical bar that can be dragged to resize panels.
* Shows visual feedback on hover and during drag.
*/
import { memo } from 'react';
interface ResizeHandleProps {
/** Mouse down handler to start dragging */
onMouseDown: (e: React.MouseEvent) => void;
/** Double click handler to reset size */
onDoubleClick?: () => void;
/** Whether panel is currently being dragged */
isDragging?: boolean;
/** Position of the handle ('left' or 'right' edge of the panel) */
position?: 'left' | 'right';
}
function ResizeHandleComponent({
onMouseDown,
onDoubleClick,
isDragging = false,
position = 'right',
}: ResizeHandleProps) {
return (
<div
className={`
absolute top-0 bottom-0 w-1 z-30
cursor-col-resize
transition-colors duration-150
${position === 'right' ? 'right-0' : 'left-0'}
${isDragging
? 'bg-primary-500'
: 'bg-transparent hover:bg-primary-500/50'
}
`}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
title="Drag to resize, double-click to reset"
>
{/* Wider hit area for easier grabbing */}
<div
className={`
absolute top-0 bottom-0 w-3
${position === 'right' ? '-left-1' : '-right-1'}
`}
/>
{/* Visual indicator dots (shown on hover via CSS) */}
<div className={`
absolute top-1/2 -translate-y-1/2
${position === 'right' ? '-left-0.5' : '-right-0.5'}
flex flex-col gap-1 opacity-0 hover:opacity-100 transition-opacity
${isDragging ? 'opacity-100' : ''}
`}>
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
</div>
</div>
);
}
export const ResizeHandle = memo(ResizeHandleComponent);
export default ResizeHandle;

View File

@@ -49,16 +49,23 @@ import { validateSpec, canRunOptimization } from '../../lib/validation/specValid
// Drag-Drop Helpers
// ============================================================================
/** Addable node types via drag-drop */
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
import { SINGLETON_TYPES } from './palette/NodePalette';
/** All node types that can be added via drag-drop */
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
function isAddableNodeType(type: string): type is AddableNodeType {
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
}
/** Check if a node type is a singleton (only one allowed) */
function isSingletonType(type: string): boolean {
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
}
/** Maps canvas NodeType to spec API type */
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' {
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
return type;
}
@@ -67,6 +74,22 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
const timestamp = Date.now();
switch (type) {
case 'model':
return {
name: 'Model',
sim: {
path: '',
solver: 'nastran',
},
canvas_position: position,
};
case 'solver':
return {
name: 'Solver',
engine: 'nxnastran',
solution_type: 'SOL101',
canvas_position: position,
};
case 'designVar':
return {
name: `variable_${timestamp}`,
@@ -130,6 +153,23 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
enabled: true,
canvas_position: position,
};
case 'algorithm':
return {
name: 'Algorithm',
type: 'TPE',
budget: {
max_trials: 100,
},
canvas_position: position,
};
case 'surrogate':
return {
name: 'Surrogate',
enabled: false,
model_type: 'MLP',
min_trials: 20,
canvas_position: position,
};
}
}
@@ -602,6 +642,18 @@ function SpecRendererInner({
return;
}
// Check if this is a singleton type that already exists
if (isSingletonType(type)) {
const existingNode = localNodes.find(n => n.type === type);
if (existingNode) {
// Select the existing node instead of creating a duplicate
selectNode(existingNode.id);
// Show a toast notification would be nice here
console.log(`${type} already exists - selected existing node`);
return;
}
}
// Convert screen position to flow position
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX,
@@ -612,8 +664,19 @@ function SpecRendererInner({
const nodeData = getDefaultNodeData(type, position);
const specType = mapNodeTypeToSpecType(type);
// For structural types (model, solver, algorithm, surrogate), these are
// part of the spec structure rather than array items. Handle differently.
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
if (structuralTypes.includes(type)) {
// These nodes are derived from spec structure - they shouldn't be "added"
// They already exist if the spec has that section configured
console.log(`${type} is a structural node - configure via spec directly`);
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
return;
}
try {
const nodeId = await addNode(specType, nodeData);
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
// Select the newly created node
selectNode(nodeId);
} catch (err) {
@@ -621,7 +684,7 @@ function SpecRendererInner({
setError(err instanceof Error ? err.message : 'Failed to add node');
}
},
[editable, addNode, selectNode, setError]
[editable, addNode, selectNode, setError, localNodes]
);
// Loading state

View File

@@ -1,14 +1,44 @@
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cpu } from 'lucide-react';
import { Cpu, Terminal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SolverNodeData } from '../../../lib/canvas/schema';
import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
// Human-readable engine names
const ENGINE_LABELS: Record<SolverEngine, string> = {
nxnastran: 'NX Nastran',
mscnastran: 'MSC Nastran',
python: 'Python Script',
abaqus: 'Abaqus',
ansys: 'ANSYS',
};
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
const { data } = props;
// Build display string: "Engine - SolutionType" or just one
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
const solverTypeLabel = data.solverType || null;
let displayText: string;
if (engineLabel && solverTypeLabel) {
displayText = `${engineLabel} (${solverTypeLabel})`;
} else if (engineLabel) {
displayText = engineLabel;
} else if (solverTypeLabel) {
displayText = solverTypeLabel;
} else {
displayText = 'Configure solver';
}
// Use Terminal icon for Python, Cpu for others
const icon = data.engine === 'python'
? <Terminal size={16} />
: <Cpu size={16} />;
return (
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
{data.solverType || 'Select solution'}
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
{displayText}
</BaseNode>
);
}

View File

@@ -54,6 +54,9 @@ export interface NodePaletteProps {
// Constants
// ============================================================================
/** Singleton node types - only one of each allowed on canvas */
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
export const PALETTE_ITEMS: PaletteItem[] = [
{
type: 'model',
@@ -61,15 +64,15 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: Box,
description: 'NX model file (.prt, .sim)',
color: 'text-blue-400',
canAdd: false, // Synthetic - derived from spec
canAdd: true, // Singleton - only one allowed
},
{
type: 'solver',
label: 'Solver',
icon: Cpu,
description: 'Nastran solution type',
description: 'Analysis solver config',
color: 'text-violet-400',
canAdd: false, // Synthetic - derived from model
canAdd: true, // Singleton - only one allowed
},
{
type: 'designVar',
@@ -109,7 +112,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: BrainCircuit,
description: 'Optimization method',
color: 'text-indigo-400',
canAdd: false, // Synthetic - derived from spec.optimization
canAdd: true, // Singleton - only one allowed
},
{
type: 'surrogate',
@@ -117,7 +120,7 @@ export const PALETTE_ITEMS: PaletteItem[] = [
icon: Rocket,
description: 'Neural acceleration',
color: 'text-pink-400',
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
canAdd: true, // Singleton - only one allowed
},
];

View File

@@ -315,18 +315,106 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
const { patchSpec } = useSpecStore();
const [isUpdating, setIsUpdating] = useState(false);
const engine = spec.model.sim?.engine || 'nxnastran';
const solutionType = spec.model.sim?.solution_type || 'SOL101';
const scriptPath = spec.model.sim?.script_path || '';
const isPython = engine === 'python';
const handleEngineChange = async (newEngine: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.engine', newEngine);
} catch (err) {
console.error('Failed to update engine:', err);
} finally {
setIsUpdating(false);
}
};
const handleSolutionTypeChange = async (newType: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.solution_type', newType);
} catch (err) {
console.error('Failed to update solution type:', err);
} finally {
setIsUpdating(false);
}
};
const handleScriptPathChange = async (newPath: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.script_path', newPath);
} catch (err) {
console.error('Failed to update script path:', err);
} finally {
setIsUpdating(false);
}
};
return (
<div>
<label className={labelClass}>Solution Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not configured'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
title="Solver type is determined by the model file."
/>
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
</div>
<>
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
<div>
<label className={labelClass}>Solver Engine</label>
<select
value={engine}
onChange={(e) => handleEngineChange(e.target.value)}
className={selectClass}
>
<option value="nxnastran">NX Nastran (built-in)</option>
<option value="mscnastran">MSC Nastran (external)</option>
<option value="python">Python Script</option>
<option value="abaqus" disabled>Abaqus (coming soon)</option>
<option value="ansys" disabled>ANSYS (coming soon)</option>
</select>
<p className="text-xs text-dark-500 mt-1">
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
</p>
</div>
{!isPython && (
<div>
<label className={labelClass}>Solution Type</label>
<select
value={solutionType}
onChange={(e) => handleSolutionTypeChange(e.target.value)}
className={selectClass}
>
<option value="SOL101">SOL101 - Linear Statics</option>
<option value="SOL103">SOL103 - Normal Modes</option>
<option value="SOL105">SOL105 - Buckling</option>
<option value="SOL106">SOL106 - Nonlinear Statics</option>
<option value="SOL111">SOL111 - Modal Frequency Response</option>
<option value="SOL112">SOL112 - Modal Transient Response</option>
<option value="SOL200">SOL200 - Design Optimization</option>
</select>
</div>
)}
{isPython && (
<div>
<label className={labelClass}>Script Path</label>
<input
type="text"
value={scriptPath}
onChange={(e) => handleScriptPathChange(e.target.value)}
placeholder="path/to/solver_script.py"
className={`${inputClass} font-mono text-sm`}
/>
<p className="text-xs text-dark-500 mt-1">
Python script must define solve(params) function
</p>
</div>
)}
</>
);
}