feat: Multi-file introspection for FEM/SIM/PRT with PyNastran parsing
This commit is contained in:
@@ -5282,21 +5282,126 @@ async def get_nx_expressions(study_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to get expressions: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to get expressions: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/studies/{study_id}/nx/introspect/{part_name}")
|
def introspect_fem_file(fem_path: Path) -> dict:
|
||||||
async def introspect_specific_part(study_id: str, part_name: str, force: bool = False):
|
|
||||||
"""
|
"""
|
||||||
Introspect a specific .prt file in the model directory.
|
Introspect a .fem or .afm file using PyNastran.
|
||||||
|
|
||||||
Use this to get expressions from component parts (e.g., M1_Blank.prt)
|
Extracts: element counts, material info, property info, node count, etc.
|
||||||
rather than just the main assembly.
|
"""
|
||||||
|
result = {
|
||||||
|
"file_type": "fem",
|
||||||
|
"file_name": fem_path.name,
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyNastran.bdf.bdf import BDF
|
||||||
|
|
||||||
|
# Read BDF/FEM file
|
||||||
|
bdf = BDF()
|
||||||
|
bdf.read_bdf(str(fem_path), xref=False)
|
||||||
|
|
||||||
|
# Extract info
|
||||||
|
result["success"] = True
|
||||||
|
result["nodes"] = {
|
||||||
|
"count": len(bdf.nodes),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Element counts by type
|
||||||
|
element_counts = {}
|
||||||
|
for eid, elem in bdf.elements.items():
|
||||||
|
elem_type = elem.type
|
||||||
|
element_counts[elem_type] = element_counts.get(elem_type, 0) + 1
|
||||||
|
result["elements"] = {
|
||||||
|
"total": len(bdf.elements),
|
||||||
|
"by_type": element_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Materials
|
||||||
|
materials = []
|
||||||
|
for mid, mat in bdf.materials.items():
|
||||||
|
mat_info = {"id": mid, "type": mat.type}
|
||||||
|
if hasattr(mat, "e"):
|
||||||
|
mat_info["E"] = mat.e
|
||||||
|
if hasattr(mat, "nu"):
|
||||||
|
mat_info["nu"] = mat.nu
|
||||||
|
if hasattr(mat, "rho"):
|
||||||
|
mat_info["rho"] = mat.rho
|
||||||
|
materials.append(mat_info)
|
||||||
|
result["materials"] = materials
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
properties = []
|
||||||
|
for pid, prop in bdf.properties.items():
|
||||||
|
prop_info = {"id": pid, "type": prop.type}
|
||||||
|
properties.append(prop_info)
|
||||||
|
result["properties"] = properties
|
||||||
|
|
||||||
|
# Coordinate systems
|
||||||
|
result["coord_systems"] = len(bdf.coords)
|
||||||
|
|
||||||
|
# Loads and constraints summary
|
||||||
|
result["loads"] = {
|
||||||
|
"load_cases": len(bdf.loads),
|
||||||
|
}
|
||||||
|
result["constraints"] = {
|
||||||
|
"spcs": len(bdf.spcs),
|
||||||
|
"mpcs": len(bdf.mpcs),
|
||||||
|
}
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
result["error"] = "PyNastran not available"
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def introspect_sim_file(sim_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Introspect a .sim file - extract solution info.
|
||||||
|
|
||||||
|
Note: .sim is a binary NX format, so we extract what we can from associated files.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"file_type": "sim",
|
||||||
|
"file_name": sim_path.name,
|
||||||
|
"success": True,
|
||||||
|
"note": "SIM files are binary NX format. Use NX introspection for full details.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for associated .dat file that might have been exported
|
||||||
|
dat_file = sim_path.parent / (sim_path.stem + ".dat")
|
||||||
|
if not dat_file.exists():
|
||||||
|
# Try common naming patterns
|
||||||
|
for f in sim_path.parent.glob("*solution*.dat"):
|
||||||
|
dat_file = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if dat_file.exists():
|
||||||
|
result["associated_dat"] = dat_file.name
|
||||||
|
# Could parse the DAT file for solution info if needed
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/nx/introspect/{file_name}")
|
||||||
|
async def introspect_specific_file(study_id: str, file_name: str, force: bool = False):
|
||||||
|
"""
|
||||||
|
Introspect a specific file in the model directory.
|
||||||
|
|
||||||
|
Supports ALL NX file types:
|
||||||
|
- .prt / _i.prt - Uses NX journal to extract expressions, mass, etc.
|
||||||
|
- .fem / .afm - Uses PyNastran to extract mesh info, materials, properties
|
||||||
|
- .sim - Extracts simulation/solution info
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
study_id: Study identifier
|
study_id: Study identifier
|
||||||
part_name: Name of the part file (e.g., "M1_Blank.prt" or "M1_Blank")
|
file_name: Name of the file (e.g., "M1_Blank.prt", "M1_Blank_fem1.fem")
|
||||||
force: Force re-introspection even if cached
|
force: Force re-introspection even if cached
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with introspection results for the specific part
|
JSON with introspection results appropriate for the file type
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
study_dir = resolve_study_path(study_id)
|
study_dir = resolve_study_path(study_id)
|
||||||
@@ -5318,74 +5423,124 @@ async def introspect_specific_part(study_id: str, part_name: str, force: bool =
|
|||||||
if model_dir is None:
|
if model_dir is None:
|
||||||
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
||||||
|
|
||||||
# Normalize part name
|
# Determine file type and find the file
|
||||||
if not part_name.lower().endswith(".prt"):
|
file_name_lower = file_name.lower()
|
||||||
part_name = part_name + ".prt"
|
target_file = None
|
||||||
|
file_type = None
|
||||||
|
|
||||||
# Find the part file (case-insensitive)
|
# Add extension if missing
|
||||||
prt_file = None
|
if "." not in file_name:
|
||||||
for f in model_dir.glob("*.prt"):
|
# Try common extensions in order
|
||||||
if f.name.lower() == part_name.lower():
|
for ext in [".prt", ".fem", ".afm", ".sim"]:
|
||||||
prt_file = f
|
candidate = model_dir / (file_name + ext)
|
||||||
|
if candidate.exists():
|
||||||
|
target_file = candidate
|
||||||
|
file_type = ext[1:] # Remove leading dot
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Has extension, find exact match (case-insensitive)
|
||||||
|
for f in model_dir.iterdir():
|
||||||
|
if f.name.lower() == file_name_lower:
|
||||||
|
target_file = f
|
||||||
|
if "_i.prt" in f.name.lower():
|
||||||
|
file_type = "idealized"
|
||||||
|
elif f.suffix.lower() == ".prt":
|
||||||
|
file_type = "prt"
|
||||||
|
elif f.suffix.lower() == ".fem":
|
||||||
|
file_type = "fem"
|
||||||
|
elif f.suffix.lower() == ".afm":
|
||||||
|
file_type = "afm"
|
||||||
|
elif f.suffix.lower() == ".sim":
|
||||||
|
file_type = "sim"
|
||||||
break
|
break
|
||||||
|
|
||||||
if prt_file is None:
|
if target_file is None:
|
||||||
# List available parts for helpful error
|
# List available files for helpful error
|
||||||
available = [f.name for f in model_dir.glob("*.prt")]
|
available = [
|
||||||
|
f.name
|
||||||
|
for f in model_dir.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in [".prt", ".fem", ".afm", ".sim"]
|
||||||
|
]
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Part '{part_name}' not found. Available: {available}"
|
status_code=404, detail=f"File '{file_name}' not found. Available: {available}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check cache
|
# Check cache
|
||||||
cache_file = model_dir / f"_introspection_{prt_file.stem}.json"
|
cache_file = model_dir / f"_introspection_{target_file.stem}.json"
|
||||||
if cache_file.exists() and not force:
|
if cache_file.exists() and not force:
|
||||||
try:
|
try:
|
||||||
with open(cache_file, "r") as f:
|
with open(cache_file, "r") as f:
|
||||||
cached = json.load(f)
|
cached = json.load(f)
|
||||||
return {
|
return {
|
||||||
"study_id": study_id,
|
"study_id": study_id,
|
||||||
"part_name": prt_file.name,
|
"file_name": target_file.name,
|
||||||
|
"file_type": file_type,
|
||||||
"cached": True,
|
"cached": True,
|
||||||
"introspection": cached,
|
"introspection": cached,
|
||||||
}
|
}
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Run introspection
|
# Run appropriate introspection based on file type
|
||||||
|
result = None
|
||||||
|
|
||||||
|
if file_type in ["prt", "idealized"]:
|
||||||
|
# Use NX journal introspection for .prt files
|
||||||
try:
|
try:
|
||||||
from optimization_engine.extractors.introspect_part import introspect_part
|
from optimization_engine.extractors.introspect_part import introspect_part
|
||||||
|
|
||||||
result = introspect_part(str(prt_file), str(model_dir), verbose=False)
|
result = introspect_part(str(target_file), str(model_dir), verbose=False)
|
||||||
|
except ImportError:
|
||||||
|
result = {"success": False, "error": "introspect_part module not available"}
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
elif file_type in ["fem", "afm"]:
|
||||||
|
# Use PyNastran for FEM files
|
||||||
|
result = introspect_fem_file(target_file)
|
||||||
|
|
||||||
|
elif file_type == "sim":
|
||||||
|
# Extract what we can from SIM file
|
||||||
|
result = introspect_sim_file(target_file)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
result = {"success": False, "error": f"Unknown file type: {file_type}"}
|
||||||
|
|
||||||
# Cache results
|
# Cache results
|
||||||
|
try:
|
||||||
with open(cache_file, "w") as f:
|
with open(cache_file, "w") as f:
|
||||||
json.dump(result, f, indent=2)
|
json.dump(result, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass # Caching is optional
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"study_id": study_id,
|
"study_id": study_id,
|
||||||
"part_name": prt_file.name,
|
"file_name": target_file.name,
|
||||||
|
"file_type": file_type,
|
||||||
"cached": False,
|
"cached": False,
|
||||||
"introspection": result,
|
"introspection": result,
|
||||||
}
|
}
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
raise HTTPException(status_code=500, detail="introspect_part module not available")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Introspection failed: {str(e)}")
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to introspect part: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to introspect file: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/studies/{study_id}/nx/parts")
|
@router.get("/studies/{study_id}/nx/parts")
|
||||||
async def list_model_parts(study_id: str):
|
async def list_model_files(study_id: str):
|
||||||
"""
|
"""
|
||||||
List all .prt files in the study's model directory.
|
List all NX model files in the study's model directory.
|
||||||
|
|
||||||
|
Includes ALL file types in the NX dependency chain:
|
||||||
|
- .sim (Simulation) - loads, BCs, solution settings
|
||||||
|
- .afm (Assembly FEM) - assembly of component FEMs
|
||||||
|
- .fem (FEM) - mesh, materials, properties
|
||||||
|
- _i.prt (Idealized) - simplified geometry for meshing
|
||||||
|
- .prt (Geometry) - full CAD geometry with expressions
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with list of available parts that can be introspected
|
JSON with categorized list of all model files
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
study_dir = resolve_study_path(study_id)
|
study_dir = resolve_study_path(study_id)
|
||||||
@@ -5407,28 +5562,62 @@ async def list_model_parts(study_id: str):
|
|||||||
if model_dir is None:
|
if model_dir is None:
|
||||||
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
raise HTTPException(status_code=404, detail=f"No model directory found for {study_id}")
|
||||||
|
|
||||||
# Collect all part files
|
# Collect all NX files by type
|
||||||
parts = []
|
files = {
|
||||||
for f in sorted(model_dir.glob("*.prt")):
|
"sim": [], # Simulation files
|
||||||
is_idealized = "_i.prt" in f.name.lower()
|
"afm": [], # Assembly FEM files
|
||||||
parts.append(
|
"fem": [], # FEM files
|
||||||
{
|
"idealized": [], # Idealized parts (*_i.prt)
|
||||||
|
"prt": [], # Geometry parts
|
||||||
|
}
|
||||||
|
|
||||||
|
for f in sorted(model_dir.iterdir()):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
name_lower = f.name.lower()
|
||||||
|
file_info = {
|
||||||
"name": f.name,
|
"name": f.name,
|
||||||
"stem": f.stem,
|
"stem": f.stem,
|
||||||
"is_idealized": is_idealized,
|
|
||||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||||
"has_cache": (model_dir / f"_introspection_{f.stem}.json").exists(),
|
"has_cache": (model_dir / f"_introspection_{f.stem}.json").exists(),
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if name_lower.endswith(".sim"):
|
||||||
|
file_info["type"] = "sim"
|
||||||
|
file_info["description"] = "Simulation (loads, BCs, solutions)"
|
||||||
|
files["sim"].append(file_info)
|
||||||
|
elif name_lower.endswith(".afm"):
|
||||||
|
file_info["type"] = "afm"
|
||||||
|
file_info["description"] = "Assembly FEM"
|
||||||
|
files["afm"].append(file_info)
|
||||||
|
elif name_lower.endswith(".fem"):
|
||||||
|
file_info["type"] = "fem"
|
||||||
|
file_info["description"] = "FEM (mesh, materials, properties)"
|
||||||
|
files["fem"].append(file_info)
|
||||||
|
elif "_i.prt" in name_lower:
|
||||||
|
file_info["type"] = "idealized"
|
||||||
|
file_info["description"] = "Idealized geometry for meshing"
|
||||||
|
files["idealized"].append(file_info)
|
||||||
|
elif name_lower.endswith(".prt"):
|
||||||
|
file_info["type"] = "prt"
|
||||||
|
file_info["description"] = "CAD geometry with expressions"
|
||||||
|
files["prt"].append(file_info)
|
||||||
|
|
||||||
|
# Build flat list for dropdown (all introspectable files)
|
||||||
|
all_files = []
|
||||||
|
for category in ["sim", "afm", "fem", "idealized", "prt"]:
|
||||||
|
all_files.extend(files[category])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"study_id": study_id,
|
"study_id": study_id,
|
||||||
"model_dir": str(model_dir),
|
"model_dir": str(model_dir),
|
||||||
"parts": parts,
|
"files": files,
|
||||||
"count": len(parts),
|
"all_files": all_files,
|
||||||
|
"count": len(all_files),
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list parts: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to list model files: {str(e)}")
|
||||||
|
|||||||
@@ -138,15 +138,28 @@ interface BaselineRunResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Part info from /nx/parts endpoint
|
// File info from /nx/parts endpoint
|
||||||
interface PartInfo {
|
interface ModelFileInfo {
|
||||||
name: string;
|
name: string;
|
||||||
stem: string;
|
stem: string;
|
||||||
is_idealized: boolean;
|
type: string; // 'sim', 'afm', 'fem', 'idealized', 'prt'
|
||||||
|
description?: string;
|
||||||
size_kb: number;
|
size_kb: number;
|
||||||
has_cache: boolean;
|
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) {
|
export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
|
||||||
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
const [result, setResult] = useState<IntrospectionResult | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -156,10 +169,10 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
);
|
);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
// Part selection state
|
// File selection state
|
||||||
const [availableParts, setAvailableParts] = useState<PartInfo[]>([]);
|
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||||
const [selectedPart, setSelectedPart] = useState<string>(''); // empty = default/assembly
|
const [selectedFile, setSelectedFile] = useState<string>(''); // empty = default/assembly
|
||||||
const [isLoadingParts, setIsLoadingParts] = useState(false);
|
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||||
|
|
||||||
// Baseline run state
|
// Baseline run state
|
||||||
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
const [isRunningBaseline, setIsRunningBaseline] = useState(false);
|
||||||
@@ -167,25 +180,25 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
|
|
||||||
const { addNode, nodes } = useCanvasStore();
|
const { addNode, nodes } = useCanvasStore();
|
||||||
|
|
||||||
// Fetch available parts when studyId changes
|
// Fetch available files when studyId changes
|
||||||
const fetchAvailableParts = useCallback(async () => {
|
const fetchAvailableFiles = useCallback(async () => {
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
|
|
||||||
setIsLoadingParts(true);
|
setIsLoadingFiles(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/optimization/studies/${studyId}/nx/parts`);
|
const res = await fetch(`/api/optimization/studies/${studyId}/nx/parts`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAvailableParts(data.parts || []);
|
setModelFiles(data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch parts:', e);
|
console.error('Failed to fetch model files:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingParts(false);
|
setIsLoadingFiles(false);
|
||||||
}
|
}
|
||||||
}, [studyId]);
|
}, [studyId]);
|
||||||
|
|
||||||
const runIntrospection = useCallback(async (partName?: string) => {
|
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||||
if (!filePath && !studyId) return;
|
if (!filePath && !studyId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
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 we have a studyId, use the study-aware introspection endpoint
|
||||||
if (studyId) {
|
if (studyId) {
|
||||||
// Use specific part endpoint if a part is selected
|
// Use specific file endpoint if a file is selected
|
||||||
const endpoint = partName
|
const endpoint = fileName
|
||||||
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(partName)}`
|
? `/api/optimization/studies/${studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||||
: `/api/optimization/studies/${studyId}/nx/introspect`;
|
: `/api/optimization/studies/${studyId}/nx/introspect`;
|
||||||
res = await fetch(endpoint);
|
res = await fetch(endpoint);
|
||||||
} else {
|
} else {
|
||||||
@@ -226,20 +239,20 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
}
|
}
|
||||||
}, [filePath, studyId]);
|
}, [filePath, studyId]);
|
||||||
|
|
||||||
// Fetch parts list on mount
|
// Fetch files list on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAvailableParts();
|
fetchAvailableFiles();
|
||||||
}, [fetchAvailableParts]);
|
}, [fetchAvailableFiles]);
|
||||||
|
|
||||||
// Run introspection when component mounts or selected part changes
|
// Run introspection when component mounts or selected file changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runIntrospection(selectedPart || undefined);
|
runIntrospection(selectedFile || undefined);
|
||||||
}, [selectedPart]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Handle part selection change
|
// Handle file selection change
|
||||||
const handlePartChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newPart = e.target.value;
|
const newFile = e.target.value;
|
||||||
setSelectedPart(newPart);
|
setSelectedFile(newFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run baseline FEA simulation
|
// Run baseline FEA simulation
|
||||||
@@ -350,12 +363,12 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
<Search size={16} className="text-primary-400" />
|
<Search size={16} className="text-primary-400" />
|
||||||
<span className="font-medium text-white text-sm">
|
<span className="font-medium text-white text-sm">
|
||||||
Model Introspection
|
Model Introspection
|
||||||
{selectedPart && <span className="text-primary-400 ml-1">({selectedPart})</span>}
|
{selectedFile && <span className="text-primary-400 ml-1">({selectedFile})</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => runIntrospection(selectedPart || undefined)}
|
onClick={() => runIntrospection(selectedFile || undefined)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@@ -371,31 +384,78 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Part Selector + Search */}
|
{/* File Selector + Search */}
|
||||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||||
{/* Part dropdown */}
|
{/* File dropdown - grouped by type */}
|
||||||
{studyId && availableParts.length > 0 && (
|
{studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<select
|
||||||
value={selectedPart}
|
value={selectedFile}
|
||||||
onChange={handlePartChange}
|
onChange={handleFileChange}
|
||||||
disabled={isLoading || isLoadingParts}
|
disabled={isLoading || isLoadingFiles}
|
||||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
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
|
text-sm text-white focus:outline-none focus:border-primary-500
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">Default (Assembly)</option>
|
<option value="">Default (Assembly)</option>
|
||||||
{availableParts
|
|
||||||
.filter(p => !p.is_idealized) // Hide idealized parts
|
{/* Simulation files */}
|
||||||
.map(part => (
|
{modelFiles.files.sim.length > 0 && (
|
||||||
<option key={part.name} value={part.stem}>
|
<optgroup label="Simulation (.sim)">
|
||||||
{part.stem} ({part.size_kb > 1000 ? `${(part.size_kb/1024).toFixed(1)}MB` : `${part.size_kb}KB`})
|
{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>
|
</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>
|
</select>
|
||||||
{isLoadingParts && (
|
{isLoadingFiles && (
|
||||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user