feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure

Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)

Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions

Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
2026-01-20 11:53:26 -05:00
parent ea437d360e
commit c4a3cff91a
16 changed files with 4067 additions and 239 deletions

View File

@@ -19,23 +19,26 @@ router = APIRouter()
class ImportRequest(BaseModel):
"""Request to import a file from a Windows path"""
source_path: str
study_name: str
copy_related: bool = True
# Path to studies root (go up 5 levels from this file)
_file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(_file_path))
)))))
ATOMIZER_ROOT = Path(
os.path.normpath(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_file_path))))
)
)
)
STUDIES_ROOT = ATOMIZER_ROOT / "studies"
@router.get("/list")
async def list_files(
path: str = "",
types: str = ".sim,.prt,.fem,.afem"
):
async def list_files(path: str = "", types: str = ".sim,.prt,.fem,.afem"):
"""
List files in a directory, filtered by type.
@@ -46,7 +49,7 @@ async def list_files(
Returns:
List of files and directories with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
@@ -58,26 +61,30 @@ async def list_files(
try:
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
# Skip hidden files and directories
if entry.name.startswith('.'):
if entry.name.startswith("."):
continue
if entry.is_dir():
# Include directories
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": True,
})
files.append(
{
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": True,
}
)
else:
# Include files matching type filter
suffix = entry.suffix.lower()
if suffix in allowed_types:
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
files.append(
{
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
}
)
except PermissionError:
return {"files": [], "path": path, "error": "Permission denied"}
except Exception as e:
@@ -87,11 +94,7 @@ async def list_files(
@router.get("/search")
async def search_files(
query: str,
types: str = ".sim,.prt,.fem,.afem",
max_results: int = 50
):
async def search_files(query: str, types: str = ".sim,.prt,.fem,.afem", max_results: int = 50):
"""
Search for files by name pattern.
@@ -103,7 +106,7 @@ async def search_files(
Returns:
List of matching files with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
query_lower = query.lower()
files = []
@@ -118,19 +121,21 @@ async def search_files(
if len(files) >= max_results:
return
if entry.name.startswith('.'):
if entry.name.startswith("."):
continue
if entry.is_dir():
search_recursive(entry, depth + 1)
elif entry.suffix.lower() in allowed_types:
if query_lower in entry.name.lower():
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
files.append(
{
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
}
)
except (PermissionError, OSError):
pass
@@ -190,18 +195,18 @@ def find_related_nx_files(source_path: Path) -> List[Path]:
# Extract base name by removing _sim1, _fem1, _i suffixes
base_name = stem
base_name = re.sub(r'_sim\d*$', '', base_name)
base_name = re.sub(r'_fem\d*$', '', base_name)
base_name = re.sub(r'_i$', '', base_name)
base_name = re.sub(r"_sim\d*$", "", base_name)
base_name = re.sub(r"_fem\d*$", "", base_name)
base_name = re.sub(r"_i$", "", base_name)
# Define patterns to search for
patterns = [
f"{base_name}.prt", # Main geometry
f"{base_name}_i.prt", # Idealized part
f"{base_name}_fem*.fem", # FEM files
f"{base_name}_fem*_i.prt", # Idealized FEM parts
f"{base_name}_sim*.sim", # Simulation files
f"{base_name}.afem", # Assembled FEM
f"{base_name}.prt", # Main geometry
f"{base_name}_i.prt", # Idealized part
f"{base_name}_fem*.fem", # FEM files
f"{base_name}_fem*_i.prt", # Idealized FEM parts
f"{base_name}_sim*.sim", # Simulation files
f"{base_name}.afem", # Assembled FEM
]
# Search for matching files
@@ -244,7 +249,7 @@ async def validate_external_path(path: str):
}
# Check if it's a valid NX file type
valid_extensions = ['.prt', '.sim', '.fem', '.afem']
valid_extensions = [".prt", ".sim", ".fem", ".afem"]
if source_path.suffix.lower() not in valid_extensions:
return {
"valid": False,
@@ -297,7 +302,9 @@ async def import_from_path(request: ImportRequest):
source_path = Path(request.source_path)
if not source_path.exists():
raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}")
raise HTTPException(
status_code=404, detail=f"Source file not found: {request.source_path}"
)
# Create study folder structure
study_dir = STUDIES_ROOT / request.study_name
@@ -316,22 +323,26 @@ async def import_from_path(request: ImportRequest):
# Skip if already exists (avoid overwrite)
if dest_file.exists():
imported.append({
"name": src_file.name,
"status": "skipped",
"reason": "Already exists",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
})
imported.append(
{
"name": src_file.name,
"status": "skipped",
"reason": "Already exists",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
}
)
continue
# Copy file
shutil.copy2(src_file, dest_file)
imported.append({
"name": src_file.name,
"status": "imported",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": dest_file.stat().st_size,
})
imported.append(
{
"name": src_file.name,
"status": "imported",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": dest_file.stat().st_size,
}
)
return {
"success": True,
@@ -371,27 +382,31 @@ async def upload_files(
for file in files:
# Validate file type
suffix = Path(file.filename).suffix.lower()
if suffix not in ['.prt', '.sim', '.fem', '.afem']:
uploaded.append({
"name": file.filename,
"status": "rejected",
"reason": f"Invalid file type: {suffix}",
})
if suffix not in [".prt", ".sim", ".fem", ".afem"]:
uploaded.append(
{
"name": file.filename,
"status": "rejected",
"reason": f"Invalid file type: {suffix}",
}
)
continue
dest_file = model_dir / file.filename
# Save file
content = await file.read()
with open(dest_file, 'wb') as f:
with open(dest_file, "wb") as f:
f.write(content)
uploaded.append({
"name": file.filename,
"status": "uploaded",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": len(content),
})
uploaded.append(
{
"name": file.filename,
"status": "uploaded",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": len(content),
}
)
return {
"success": True,
@@ -402,3 +417,96 @@ async def upload_files(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/structure/{study_id:path}")
async def get_study_structure(study_id: str):
"""
Get the file structure tree for a study.
Args:
study_id: Study ID (can include path separators like M1_Mirror/m1_mirror_flatback)
Returns:
Hierarchical file tree with type information
"""
# Resolve study path
study_path = STUDIES_ROOT / study_id
if not study_path.exists():
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
if not study_path.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {study_id}")
# File extensions to highlight as model files
model_extensions = {".prt", ".sim", ".fem", ".afem"}
result_extensions = {".op2", ".f06", ".dat", ".bdf", ".csv", ".json"}
def build_tree(directory: Path, depth: int = 0) -> List[dict]:
"""Recursively build file tree."""
if depth > 5: # Limit depth to prevent infinite recursion
return []
entries = []
try:
items = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
for item in items:
# Skip hidden files/dirs and __pycache__
if item.name.startswith(".") or item.name == "__pycache__":
continue
# Skip very large directories (e.g., trial folders with many iterations)
if item.is_dir() and item.name.startswith("trial_"):
# Just count trials, don't recurse into each
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "directory",
"children": [], # Empty children for trial folders
}
)
continue
if item.is_dir():
children = build_tree(item, depth + 1)
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "directory",
"children": children,
}
)
else:
ext = item.suffix.lower()
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "file",
"extension": ext,
"size": item.stat().st_size,
"isModelFile": ext in model_extensions,
"isResultFile": ext in result_extensions,
}
)
except PermissionError:
pass
except Exception as e:
print(f"Error reading directory {directory}: {e}")
return entries
# Build the tree starting from study root
files = build_tree(study_path)
return {
"study_id": study_id,
"path": str(study_path),
"files": files,
}