diff --git a/atomizer-dashboard/backend/api/routes/optimization.py b/atomizer-dashboard/backend/api/routes/optimization.py index 1b51fc75..d0e3280c 100644 --- a/atomizer-dashboard/backend/api/routes/optimization.py +++ b/atomizer-dashboard/backend/api/routes/optimization.py @@ -4485,6 +4485,120 @@ async def export_study_data(study_id: str, format: str): # ============================================================================ +def scan_nx_file_dependencies(model_dir: Path) -> dict: + """ + Scan a model directory for NX files and build a dependency tree. + + This uses file naming conventions to determine relationships: + - .sim files reference .afm (assembly FEM) or .fem files + - .afm files reference multiple .fem files + - .fem files reference _i.prt (idealized) files + - _i.prt files are derived from .prt (geometry) files + + Returns: + Dictionary with file lists and dependency tree + """ + result = { + "files": { + "sim": [], # Simulation files + "afm": [], # Assembly FEM files + "fem": [], # FEM files + "prt": [], # Part/geometry files + "idealized": [], # Idealized parts (*_i.prt) + }, + "dependencies": [], # List of {source, target, type} edges + "root_sim": None, + } + + # Collect all files by type + for f in model_dir.iterdir(): + if not f.is_file(): + continue + name = f.name.lower() + + if name.endswith(".sim"): + result["files"]["sim"].append(f.name) + elif name.endswith(".afm"): + result["files"]["afm"].append(f.name) + elif name.endswith(".fem"): + result["files"]["fem"].append(f.name) + elif name.endswith("_i.prt"): + result["files"]["idealized"].append(f.name) + elif name.endswith(".prt"): + result["files"]["prt"].append(f.name) + + # Build dependency edges based on NX naming conventions + # SIM -> AFM: SIM file basename matches AFM (e.g., ASSY_M1_assyfem1_sim1.sim -> ASSY_M1_assyfem1.afm) + for sim in result["files"]["sim"]: + sim_base = sim.lower().replace(".sim", "") + # Remove _sim1, _sim2 suffix to get AFM name + afm_base = sim_base.rsplit("_sim", 1)[0] if "_sim" in sim_base else sim_base + + # Check for matching AFM + for afm in result["files"]["afm"]: + if afm.lower().replace(".afm", "") == afm_base: + result["dependencies"].append({"source": sim, "target": afm, "type": "sim_to_afm"}) + break + else: + # No AFM, check for direct FEM reference + for fem in result["files"]["fem"]: + fem_base = fem.lower().replace(".fem", "") + if afm_base.endswith(fem_base) or fem_base in afm_base: + result["dependencies"].append( + {"source": sim, "target": fem, "type": "sim_to_fem"} + ) + break + + # Set root sim (first one found) + if result["root_sim"] is None: + result["root_sim"] = sim + + # AFM -> FEM: Assembly FEM references component FEMs + # Convention: AFM basename often contains assembly name, FEMs have component names + for afm in result["files"]["afm"]: + afm_base = afm.lower().replace(".afm", "") + # Get the assembly prefix (e.g., ASSY_M1_assyfem1 -> ASSY_M1) + parts = afm_base.split("_") + + for fem in result["files"]["fem"]: + fem_base = fem.lower().replace(".fem", "") + # FEM is likely a component if it's not the assembly FEM itself + if fem_base != afm_base: + result["dependencies"].append({"source": afm, "target": fem, "type": "afm_to_fem"}) + + # FEM -> Idealized PRT: FEM references idealized geometry + # Convention: Model_fem1.fem -> Model_fem1_i.prt + for fem in result["files"]["fem"]: + fem_base = fem.lower().replace(".fem", "") + + for idealized in result["files"]["idealized"]: + idealized_base = idealized.lower().replace("_i.prt", "") + if idealized_base == fem_base: + result["dependencies"].append( + {"source": fem, "target": idealized, "type": "fem_to_idealized"} + ) + break + + # Idealized PRT -> PRT: Idealized part derives from geometry + # Convention: Model_fem1_i.prt -> Model.prt + for idealized in result["files"]["idealized"]: + idealized_base = idealized.lower().replace("_i.prt", "") + # Remove fem suffix to get geometry name (Model_fem1_i -> Model) + geom_base = ( + idealized_base.rsplit("_fem", 1)[0] if "_fem" in idealized_base else idealized_base + ) + + for prt in result["files"]["prt"]: + prt_base = prt.lower().replace(".prt", "") + if prt_base == geom_base: + result["dependencies"].append( + {"source": idealized, "target": prt, "type": "idealized_to_prt"} + ) + break + + return result + + @router.get("/studies/{study_id}/nx/introspect") async def introspect_nx_model(study_id: str, force: bool = False): """ @@ -4554,12 +4668,18 @@ async def introspect_nx_model(study_id: str, force: bool = False): except: pass # Invalid cache, re-run + # Scan file dependencies (fast, doesn't need NX) + file_deps = scan_nx_file_dependencies(model_dir) + # Run introspection try: from optimization_engine.extractors.introspect_part import introspect_part result = introspect_part(str(prt_file), str(model_dir), verbose=False) + # Add file dependencies to result + result["file_dependencies"] = file_deps + # Cache results with open(cache_file, "w") as f: json.dump(result, f, indent=2) @@ -4567,7 +4687,16 @@ async def introspect_nx_model(study_id: str, force: bool = False): return {"study_id": study_id, "cached": False, "introspection": result} except ImportError: - raise HTTPException(status_code=500, detail="introspect_part module not available") + # If introspect_part not available, return just file dependencies + return { + "study_id": study_id, + "cached": False, + "introspection": { + "success": False, + "error": "introspect_part module not available", + "file_dependencies": file_deps, + }, + } except Exception as e: raise HTTPException(status_code=500, detail=f"Introspection failed: {str(e)}") diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx index 471177f9..1b44eaa9 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx @@ -19,6 +19,8 @@ import { Link, Box, Settings2, + GitBranch, + File, } from 'lucide-react'; import { useCanvasStore } from '../../../hooks/useCanvasStore'; @@ -53,6 +55,23 @@ interface DependentFile { name: string; } +// File dependency structure from backend +interface FileDependencies { + files: { + sim: string[]; + afm: string[]; + fem: string[]; + prt: string[]; + idealized: string[]; + }; + dependencies: Array<{ + source: string; + target: string; + type: string; + }>; + root_sim: string | null; +} + // The API returns expressions in a nested structure interface ExpressionsResult { user: Expression[]; @@ -81,6 +100,7 @@ interface IntrospectionResult { attributes?: Array<{ title: string; value: string }>; units?: Record; linked_parts?: Record; + file_dependencies?: FileDependencies; } // Baseline run result interface @@ -627,6 +647,142 @@ export function IntrospectionPanel({ filePath, studyId, onClose }: Introspection )} + {/* File Dependencies Section (NX file chain) */} + {result.file_dependencies && ( +
+ + + {expandedSections.has('file_deps') && ( +
+ {/* Show file tree structure */} + {result.file_dependencies.root_sim && ( +
+ {/* Simulation files */} + {result.file_dependencies.files.sim.map((sim) => ( +
+
+ + {sim} + (.sim) +
+ + {/* AFM files connected to this SIM */} + {result.file_dependencies!.dependencies + .filter(d => d.source === sim && d.type === 'sim_to_afm') + .map(d => ( +
+
+ + {d.target} + (.afm) +
+ + {/* FEM files connected to this AFM */} + {result.file_dependencies!.dependencies + .filter(d2 => d2.source === d.target && d2.type === 'afm_to_fem') + .map(d2 => ( +
+
+ + {d2.target} + (.fem) +
+ + {/* Idealized parts connected to this FEM */} + {result.file_dependencies!.dependencies + .filter(d3 => d3.source === d2.target && d3.type === 'fem_to_idealized') + .map(d3 => ( +
+
+ + {d3.target} + (_i.prt) +
+ + {/* Geometry parts */} + {result.file_dependencies!.dependencies + .filter(d4 => d4.source === d3.target && d4.type === 'idealized_to_prt') + .map(d4 => ( +
+
+ + {d4.target} + (.prt) +
+
+ )) + } +
+ )) + } +
+ )) + } +
+ )) + } + + {/* Direct FEM connections (no AFM) */} + {result.file_dependencies!.dependencies + .filter(d => d.source === sim && d.type === 'sim_to_fem') + .map(d => ( +
+
+ + {d.target} + (.fem) +
+
+ )) + } +
+ ))} + + {/* Show any orphan PRT files not in the tree */} + {result.file_dependencies.files.prt.filter(prt => + !result.file_dependencies!.dependencies.some(d => d.target === prt) + ).length > 0 && ( +
+ Additional geometry files: + {result.file_dependencies.files.prt + .filter(prt => !result.file_dependencies!.dependencies.some(d => d.target === prt)) + .map(prt => ( +
+
+ + {prt} +
+
+ )) + } +
+ )} +
+ )} + + {!result.file_dependencies.root_sim && ( +

No simulation file found

+ )} +
+ )} +
+ )} + {/* Extractors Section - only show if available */} {(result.extractors_available?.length ?? 0) > 0 && (