fix(canvas): Add Save/Reload buttons and expand IntrospectionPanel to show all model data

CanvasView:
- Fix Save button visibility - now shows when spec is loaded (grayed if no changes)
- Separate logic for spec mode vs legacy mode save buttons
- Fix Reload button visibility

IntrospectionPanel:
- Add Mass Properties section (mass, volume, surface area, CoG, body count)
- Add Linked Parts section showing file dependencies
- Add Bodies section (solid/sheet body counts)
- Add Units section showing unit system
- Type-safe access to all nested properties
This commit is contained in:
2026-01-20 14:47:09 -05:00
parent ced79b8d39
commit 91cf9ca1fd
2 changed files with 201 additions and 6 deletions

View File

@@ -15,6 +15,10 @@ import {
FlaskConical, FlaskConical,
SlidersHorizontal, SlidersHorizontal,
AlertTriangle, AlertTriangle,
Scale,
Link,
Box,
Settings2,
} from 'lucide-react'; } from 'lucide-react';
import { useCanvasStore } from '../../../hooks/useCanvasStore'; import { useCanvasStore } from '../../../hooks/useCanvasStore';
@@ -316,6 +320,180 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
)} )}
</div> </div>
{/* Mass Properties Section */}
{result.mass_properties && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('mass')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Scale size={14} className="text-blue-400" />
<span className="text-sm font-medium text-white">Mass Properties</span>
</div>
{expandedSections.has('mass') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('mass') && (
<div className="p-2 space-y-1">
{result.mass_properties.mass_kg !== undefined && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Mass</span>
<span className="text-white font-mono">
{(result.mass_properties.mass_kg as number).toFixed(4)} kg
</span>
</div>
)}
{result.mass_properties.volume_mm3 !== undefined && (result.mass_properties.volume_mm3 as number) > 0 && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Volume</span>
<span className="text-white font-mono">
{((result.mass_properties.volume_mm3 as number) / 1e9).toFixed(6)} m³
</span>
</div>
)}
{result.mass_properties.surface_area_mm2 !== undefined && (result.mass_properties.surface_area_mm2 as number) > 0 && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Surface Area</span>
<span className="text-white font-mono">
{((result.mass_properties.surface_area_mm2 as number) / 1e6).toFixed(4)} m²
</span>
</div>
)}
{Array.isArray(result.mass_properties.center_of_gravity_mm) && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">CoG (mm)</span>
<span className="text-white font-mono text-right">
[{(result.mass_properties.center_of_gravity_mm as number[]).map((v: number) => v.toFixed(1)).join(', ')}]
</span>
</div>
)}
{typeof result.mass_properties.num_bodies === 'number' && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Bodies</span>
<span className="text-white font-mono">{result.mass_properties.num_bodies}</span>
</div>
)}
</div>
)}
</div>
)}
{/* Linked Parts / File Dependencies Section */}
{result.linked_parts && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('linked')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Link size={14} className="text-purple-400" />
<span className="text-sm font-medium text-white">
Linked Parts ({(result.linked_parts.loaded_parts as Array<{name: string}>)?.length || 0})
</span>
</div>
{expandedSections.has('linked') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('linked') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{((result.linked_parts.loaded_parts as Array<{name: string, path: string, leaf_name: string}>) || []).map((part) => (
<div
key={part.path}
className="p-2 bg-dark-850 rounded"
>
<p className="text-sm text-white">{part.name}</p>
<p className="text-xs text-dark-500 truncate" title={part.path}>
{part.leaf_name || part.path.split(/[/\\]/).pop()}
</p>
</div>
))}
{((result.linked_parts.loaded_parts as Array<unknown>) || []).length === 0 && (
<p className="text-xs text-dark-500 text-center py-2">No linked parts found</p>
)}
</div>
)}
</div>
)}
{/* Bodies Section */}
{result.bodies && (result.bodies.counts as {total: number})?.total > 0 && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('bodies')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Box size={14} className="text-orange-400" />
<span className="text-sm font-medium text-white">
Bodies ({(result.bodies.counts as {total: number})?.total || 0})
</span>
</div>
{expandedSections.has('bodies') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('bodies') && (
<div className="p-2 space-y-1">
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Solid Bodies</span>
<span className="text-white font-mono">{(result.bodies.counts as {solid: number})?.solid || 0}</span>
</div>
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Sheet Bodies</span>
<span className="text-white font-mono">{(result.bodies.counts as {sheet: number})?.sheet || 0}</span>
</div>
</div>
)}
</div>
)}
{/* Units Section */}
{result.units && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('units')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-gray-400" />
<span className="text-sm font-medium text-white">
Units ({(result.units as {system?: string})?.system || 'Unknown'})
</span>
</div>
{expandedSections.has('units') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('units') && (
<div className="p-2 space-y-1">
{(result.units as {base_units?: Record<string, string>})?.base_units &&
Object.entries((result.units as {base_units: Record<string, string>}).base_units).map(([key, value]) => (
<div key={key} className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">{key}</span>
<span className="text-white font-mono">{value}</span>
</div>
))
}
</div>
)}
</div>
)}
{/* Extractors Section - only show if available */} {/* Extractors Section - only show if available */}
{(result.extractors_available?.length ?? 0) > 0 && ( {(result.extractors_available?.length ?? 0) > 0 && (
<div className="border border-dark-700 rounded-lg overflow-hidden"> <div className="border border-dark-700 rounded-lg overflow-hidden">

View File

@@ -296,17 +296,34 @@ export function CanvasView() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Save Button - only show when there's a study and changes */} {/* Save Button - always show in spec mode with study, grayed when no changes */}
{activeStudyId && ( {useSpecMode && spec && (
<button <button
onClick={saveToConfig} onClick={saveToConfig}
disabled={isSaving || (useSpecMode ? !specIsDirty : !hasUnsavedChanges)} disabled={isSaving || !specIsDirty}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${ className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
(useSpecMode ? specIsDirty : hasUnsavedChanges) specIsDirty
? 'bg-green-600 hover:bg-green-500 text-white' ? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600' : 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`} }`}
title={(useSpecMode ? specIsDirty : hasUnsavedChanges) ? `Save changes to ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}` : 'No changes to save'} title={specIsDirty ? 'Save changes to atomizer_spec.json' : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Legacy Save Button */}
{!useSpecMode && activeStudyId && (
<button
onClick={saveToConfig}
disabled={isSaving || !hasUnsavedChanges}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
hasUnsavedChanges
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={hasUnsavedChanges ? 'Save changes to optimization_config.json' : 'No changes to save'}
> >
<Save size={14} /> <Save size={14} />
{isSaving ? 'Saving...' : 'Save'} {isSaving ? 'Saving...' : 'Save'}
@@ -314,7 +331,7 @@ export function CanvasView() {
)} )}
{/* Reload Button */} {/* Reload Button */}
{activeStudyId && ( {(useSpecMode ? spec : activeStudyId) && (
<button <button
onClick={handleReload} onClick={handleReload}
disabled={isLoading || specLoading} disabled={isLoading || specLoading}