feat: Multi-file introspection for FEM/SIM/PRT with PyNastran parsing
This commit is contained in:
@@ -138,15 +138,28 @@ interface BaselineRunResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Part info from /nx/parts endpoint
|
||||
interface PartInfo {
|
||||
// File info from /nx/parts endpoint
|
||||
interface ModelFileInfo {
|
||||
name: string;
|
||||
stem: string;
|
||||
is_idealized: boolean;
|
||||
type: string; // 'sim', 'afm', 'fem', 'idealized', 'prt'
|
||||
description?: string;
|
||||
size_kb: number;
|
||||
has_cache: boolean;
|
||||
}
|
||||
|
||||
// Grouped files response
|
||||
interface ModelFilesResponse {
|
||||
files: {
|
||||
sim: ModelFileInfo[];
|
||||
afm: ModelFileInfo[];
|
||||
fem: ModelFileInfo[];
|
||||
idealized: ModelFileInfo[];
|
||||
prt: ModelFileInfo[];
|
||||
};
|
||||
all_files: ModelFileInfo[];
|
||||
}
|
||||
|
||||
export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
|
||||
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -156,10 +169,10 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Part selection state
|
||||
const [availableParts, setAvailableParts] = useState<PartInfo[]>([]);
|
||||
const [selectedPart, setSelectedPart] = useState<string>(''); // empty = default/assembly
|
||||
const [isLoadingParts, setIsLoadingParts] = useState(false);
|
||||
// File selection state
|
||||
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string>(''); // empty = default/assembly
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
// Baseline run state
|
||||
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
||||
@@ -167,25 +180,25 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
|
||||
const { addNode, nodes } = useCanvasStore();
|
||||
|
||||
// Fetch available parts when studyId changes
|
||||
const fetchAvailableParts = useCallback(async () => {
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!studyId) return;
|
||||
|
||||
setIsLoadingParts(true);
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/nx/parts`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAvailableParts(data.parts || []);
|
||||
setModelFiles(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch parts:', e);
|
||||
console.error('Failed to fetch model files:', e);
|
||||
} finally {
|
||||
setIsLoadingParts(false);
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [studyId]);
|
||||
|
||||
const runIntrospection = useCallback(async (partName?: string) => {
|
||||
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||
if (!filePath && !studyId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -195,9 +208,9 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
|
||||
// If we have a studyId, use the study-aware introspection endpoint
|
||||
if (studyId) {
|
||||
// Use specific part endpoint if a part is selected
|
||||
const endpoint = partName
|
||||
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(partName)}`
|
||||
// Use specific file endpoint if a file is selected
|
||||
const endpoint = fileName
|
||||
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||
: `/api/optimization/studies/${studyId}/nx/introspect`;
|
||||
res = await fetch(endpoint);
|
||||
} else {
|
||||
@@ -226,20 +239,20 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
}
|
||||
}, [filePath, studyId]);
|
||||
|
||||
// Fetch parts list on mount
|
||||
// Fetch files list on mount
|
||||
useEffect(() => {
|
||||
fetchAvailableParts();
|
||||
}, [fetchAvailableParts]);
|
||||
fetchAvailableFiles();
|
||||
}, [fetchAvailableFiles]);
|
||||
|
||||
// Run introspection when component mounts or selected part changes
|
||||
// Run introspection when component mounts or selected file changes
|
||||
useEffect(() => {
|
||||
runIntrospection(selectedPart || undefined);
|
||||
}, [selectedPart]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
runIntrospection(selectedFile || undefined);
|
||||
}, [selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle part selection change
|
||||
const handlePartChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newPart = e.target.value;
|
||||
setSelectedPart(newPart);
|
||||
// Handle file selection change
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFile = e.target.value;
|
||||
setSelectedFile(newFile);
|
||||
};
|
||||
|
||||
// Run baseline FEA simulation
|
||||
@@ -350,12 +363,12 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="font-medium text-white text-sm">
|
||||
Model Introspection
|
||||
{selectedPart && <span className="text-primary-400 ml-1">({selectedPart})</span>}
|
||||
{selectedFile && <span className="text-primary-400 ml-1">({selectedFile})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => runIntrospection(selectedPart || undefined)}
|
||||
onClick={() => runIntrospection(selectedFile || undefined)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
@@ -371,31 +384,78 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Part Selector + Search */}
|
||||
{/* File Selector + Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||
{/* Part dropdown */}
|
||||
{studyId && availableParts.length > 0 && (
|
||||
{/* File dropdown - grouped by type */}
|
||||
{studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">Part:</label>
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
|
||||
<select
|
||||
value={selectedPart}
|
||||
onChange={handlePartChange}
|
||||
disabled={isLoading || isLoadingParts}
|
||||
value={selectedFile}
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isLoadingFiles}
|
||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Default (Assembly)</option>
|
||||
{availableParts
|
||||
.filter(p => !p.is_idealized) // Hide idealized parts
|
||||
.map(part => (
|
||||
<option key={part.name} value={part.stem}>
|
||||
{part.stem} ({part.size_kb > 1000 ? `${(part.size_kb/1024).toFixed(1)}MB` : `${part.size_kb}KB`})
|
||||
</option>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Simulation files */}
|
||||
{modelFiles.files.sim.length > 0 && (
|
||||
<optgroup label="Simulation (.sim)">
|
||||
{modelFiles.files.sim.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Assembly FEM files */}
|
||||
{modelFiles.files.afm.length > 0 && (
|
||||
<optgroup label="Assembly FEM (.afm)">
|
||||
{modelFiles.files.afm.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* FEM files */}
|
||||
{modelFiles.files.fem.length > 0 && (
|
||||
<optgroup label="FEM (.fem)">
|
||||
{modelFiles.files.fem.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Geometry parts */}
|
||||
{modelFiles.files.prt.length > 0 && (
|
||||
<optgroup label="Geometry (.prt)">
|
||||
{modelFiles.files.prt.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Idealized parts */}
|
||||
{modelFiles.files.idealized.length > 0 && (
|
||||
<optgroup label="Idealized (_i.prt)">
|
||||
{modelFiles.files.idealized.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{isLoadingParts && (
|
||||
{isLoadingFiles && (
|
||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user