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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user