diff --git a/backend/__pycache__/database.cpython-39.pyc b/backend/__pycache__/database.cpython-39.pyc index d2c03ce..f338ff7 100644 Binary files a/backend/__pycache__/database.cpython-39.pyc and b/backend/__pycache__/database.cpython-39.pyc differ diff --git a/backend/__pycache__/gds_builder.cpython-39.pyc b/backend/__pycache__/gds_builder.cpython-39.pyc index e10d147..4b794e9 100644 Binary files a/backend/__pycache__/gds_builder.cpython-39.pyc and b/backend/__pycache__/gds_builder.cpython-39.pyc differ diff --git a/backend/__pycache__/layout_preview.cpython-39.pyc b/backend/__pycache__/layout_preview.cpython-39.pyc index 237a54b..17964fa 100644 Binary files a/backend/__pycache__/layout_preview.cpython-39.pyc and b/backend/__pycache__/layout_preview.cpython-39.pyc differ diff --git a/backend/__pycache__/pdk_access.cpython-39.pyc b/backend/__pycache__/pdk_access.cpython-39.pyc index 4e3d088..df11d38 100644 Binary files a/backend/__pycache__/pdk_access.cpython-39.pyc and b/backend/__pycache__/pdk_access.cpython-39.pyc differ diff --git a/backend/__pycache__/pdk_registry.cpython-39.pyc b/backend/__pycache__/pdk_registry.cpython-39.pyc index 83bcec1..cab17b8 100644 Binary files a/backend/__pycache__/pdk_registry.cpython-39.pyc and b/backend/__pycache__/pdk_registry.cpython-39.pyc differ diff --git a/backend/__pycache__/routed_layout_preview.cpython-39.pyc b/backend/__pycache__/routed_layout_preview.cpython-39.pyc index 9f2c8c7..e0a6574 100644 Binary files a/backend/__pycache__/routed_layout_preview.cpython-39.pyc and b/backend/__pycache__/routed_layout_preview.cpython-39.pyc differ diff --git a/backend/__pycache__/technology_manifest.cpython-39.pyc b/backend/__pycache__/technology_manifest.cpython-39.pyc index 4ac04b5..94103d2 100644 Binary files a/backend/__pycache__/technology_manifest.cpython-39.pyc and b/backend/__pycache__/technology_manifest.cpython-39.pyc differ diff --git a/backend/database.py b/backend/database.py index 0ecae25..1ca96f8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -10,21 +10,24 @@ import os from werkzeug.security import generate_password_hash from datetime import datetime -# Save the database in the backend folder +# Store application data in the shared database folder so all backend modules +# use the same SQLite file regardless of their import path. DB_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "database", "mxpic_data.db")) def connect_db(): + """Open a SQLite connection with row-style access for application data queries.""" conn = sqlite3.connect(DB_FILE, timeout=20) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=20000") return conn def init_db(): + """Create the user, profile, and audit-log tables required by the backend.""" os.makedirs(os.path.dirname(DB_FILE), exist_ok=True) conn = connect_db() cursor = conn.cursor() - # Create Users Table + # Core account table used by login, profile, and role-based PDK access. cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -33,6 +36,8 @@ def init_db(): ) ''') + # Audit log table used by dashboard activity history and backend action + # tracing. cursor.execute(''' CREATE TABLE IF NOT EXISTS user_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -48,6 +53,8 @@ def init_db(): ) ''') + # Lightweight migrations keep older local SQLite files compatible after + # profile, credit, occupation, and role fields were added. cursor.execute("PRAGMA table_info(users)") existing_columns = {row[1] for row in cursor.fetchall()} migrations = { @@ -66,7 +73,8 @@ def init_db(): cursor.execute("UPDATE users SET user_group = 'developers' WHERE username = 'engineer'") cursor.execute("UPDATE users SET user_group = 'user' WHERE user_group IS NULL OR user_group = ''") - # Insert default users for local multi-account development. + # Default local accounts let developers test manager/developer/user access + # without manually editing the database. cursor.execute("SELECT * FROM users WHERE username = 'admin'") if not cursor.fetchone(): test_hash = generate_password_hash("123456") @@ -89,6 +97,7 @@ def init_db(): conn.close() def get_user(username): + """Fetch login credentials and account status for a username.""" conn = connect_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash, user_group FROM users WHERE username = ?", (username,)) @@ -97,6 +106,7 @@ def get_user(username): return user def get_user_profile(user_id): + """Fetch editable profile details for an authenticated user.""" conn = connect_db() cursor = conn.cursor() cursor.execute( @@ -108,6 +118,7 @@ def get_user_profile(user_id): return user def get_user_auth_by_id(user_id): + """Fetch password and account metadata by user id.""" conn = connect_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash FROM users WHERE id = ?", (user_id,)) @@ -116,6 +127,7 @@ def get_user_auth_by_id(user_id): return user def update_user_occupation(user_id, occupation): + """Persist a user profile occupation update.""" conn = connect_db() cursor = conn.cursor() cursor.execute("UPDATE users SET occupation = ? WHERE id = ?", (occupation, user_id)) @@ -123,6 +135,7 @@ def update_user_occupation(user_id, occupation): conn.close() def update_user_password(user_id, password): + """Persist a newly hashed password for a user.""" conn = connect_db() cursor = conn.cursor() cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), user_id)) @@ -130,6 +143,7 @@ def update_user_password(user_id, password): conn.close() def add_user_log(user_id, username, action, project=None, cell=None, detail=None, ip_address=None): + """Record an auditable user action with optional project and cell context.""" conn = connect_db() cursor = conn.cursor() cursor.execute( @@ -152,6 +166,7 @@ def add_user_log(user_id, username, action, project=None, cell=None, detail=None conn.close() def list_user_logs(user_id, limit=200): + """Return recent audit-log entries for display in the account page.""" conn = connect_db() cursor = conn.cursor() cursor.execute( @@ -169,5 +184,7 @@ def list_user_logs(user_id, limit=200): return rows if __name__ == "__main__": + # Allow this module to be run directly when a fresh local database needs to + # be initialized outside the Flask server. init_db() print("Database initialized successfully.") diff --git a/backend/gds_builder.py b/backend/gds_builder.py index 86730e2..821510b 100644 --- a/backend/gds_builder.py +++ b/backend/gds_builder.py @@ -17,6 +17,7 @@ from pdk_registry import PdkRegistry @dataclass class BuildResult: + """Container for GDS build output paths, status details, and engine metadata.""" output_path: str engine: str cells_built: List[str] = field(default_factory=list) @@ -35,6 +36,8 @@ def build_project_gds( if not cells: raise ValueError("No saved cell YAML files found for this project") + # Prefer the routed builder whenever it is available because it understands + # bundle links, anchor connections, and PDK-aware routing rules. try: return _build_with_mxpic_router( project_dir, @@ -50,9 +53,13 @@ def build_project_gds( f"Router import failed: {router_error}" ) from router_error + # Placement-only projects can still be exported with local GDS engines when + # the routed builder is not installed. registry = PdkRegistry(pdk_public_root, prefer_full_gds=prefer_full_gds) os.makedirs(os.path.dirname(output_path), exist_ok=True) + # gdstk is the preferred fallback; Nazca remains a secondary fallback for + # environments where gdstk is not installed. try: return _build_with_gdstk(cells, output_path, registry) except ImportError as gdstk_error: @@ -72,6 +79,9 @@ def _build_with_mxpic_router( technology_manifest_path: str, prefer_full_gds: bool, ) -> BuildResult: + """Delegate routed project GDS generation to the external mxpic_router package.""" + # mxpic_router lives beside this repository during local development, so add + # that sibling checkout to sys.path only when it exists. router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router")) if os.path.isdir(router_root) and router_root not in sys.path: sys.path.insert(0, router_root) @@ -93,6 +103,7 @@ def _build_with_mxpic_router( def _load_project_cells(project_dir: str) -> Dict[str, dict]: + """Load saved cell YAML documents from a project directory.""" cells = {} for filename in sorted(os.listdir(project_dir)): if not filename.lower().endswith((".yml", ".yaml")): @@ -106,12 +117,14 @@ def _load_project_cells(project_dir: str) -> Dict[str, dict]: def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]: + """Order cells so dependencies are built before cells that reference them.""" composites = [name for name, data in cells.items() if data.get("type") != "project"] projects = [name for name, data in cells.items() if data.get("type") == "project"] return composites + projects def _cells_have_links(cells: Dict[str, dict]) -> bool: + """Detect whether any saved cell contains bundle links that require routed building.""" for data in cells.values(): for bundle in (data.get("bundles") or {}).values(): if bundle.get("links"): @@ -120,12 +133,15 @@ def _cells_have_links(cells: Dict[str, dict]) -> bool: def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: + """Assemble a project GDS with gdstk when Nazca or routed building is unavailable.""" import gdstk library = gdstk.Library() built_cells = {} warnings = [] + # Build composite cells before project cells so project-level references can + # reuse cells already inserted into the same GDS library. for cell_name in _ordered_cell_names(cells): data = cells[cell_name] gds_cell = library.new_cell(_safe_cell_name(cell_name, built_cells)) @@ -137,6 +153,8 @@ def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkReg rotation = math.radians(_number(instance.get("rotation"))) child = built_cells.get(component) if child is None: + # External components are resolved through the active PDK + # registry and imported as references. asset = registry.resolve(component) if not asset.gds_path: warnings.append(f"Missing GDS for {instance_name}: {component}") @@ -154,10 +172,13 @@ def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkReg def _import_public_gds(gdstk, library, gds_path: str): + """Import public PDK GDS geometry into the output library.""" source = gdstk.read_gds(gds_path) top_cells = source.top_level() if not top_cells: raise ValueError(f"No top-level cell found in {gds_path}") + # Avoid adding duplicate cell names when multiple instances reference the + # same imported PDK component. for source_cell in source.cells: if _library_cell_by_name(library, source_cell.name) is None: library.add(source_cell) @@ -165,11 +186,14 @@ def _import_public_gds(gdstk, library, gds_path: str): def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: + """Assemble a project GDS with Nazca cells and PDK component placements.""" import nazca as nd warnings = [] built_cells = {} ordered_names = _ordered_cell_names(cells) + # Nazca cells are built in dependency order and then the final project cell + # is exported as the top-level GDS. for cell_name in ordered_names: data = cells[cell_name] with nd.Cell(cell_name) as current_cell: @@ -195,6 +219,7 @@ def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkReg def _safe_cell_name(name: str, existing: dict) -> str: + """Generate a backend-safe unique cell name for GDS/Nazca libraries.""" base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "cell" candidate = base counter = 1 @@ -206,6 +231,7 @@ def _safe_cell_name(name: str, existing: dict) -> str: def _library_cell_by_name(library, name: str): + """Find a cell object in a loaded layout library by name.""" for cell in library.cells: if cell.name == name: return cell @@ -213,6 +239,7 @@ def _library_cell_by_name(library, name: str): def _number(value, default=0.0) -> float: + """Convert numeric YAML values to floats with a stable default.""" try: if value is None or value == "": return default diff --git a/backend/layout_preview.py b/backend/layout_preview.py index e624bea..5308cf3 100644 --- a/backend/layout_preview.py +++ b/backend/layout_preview.py @@ -13,6 +13,8 @@ import yaml def create_layout_svg_from_gds(yaml_content: str, output_path: str, pdk_registry, project_dir: str = None) -> str: """Create an SVG preview by placing real public _BB.gds cells from layout YAML.""" layout = yaml.safe_load(yaml_content) or {} + # Try gdstk first because it can write SVG directly; keep Nazca as a GDS + # placement fallback for environments where gdstk is unavailable. try: return _create_with_gdstk(layout, output_path, pdk_registry, project_dir) except ImportError as gdstk_error: @@ -26,6 +28,7 @@ def create_layout_svg_from_gds(yaml_content: str, output_path: str, pdk_registry def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str: + """Generate preview SVG geometry using gdstk import and placement APIs.""" import gdstk library = gdstk.Library() @@ -37,9 +40,12 @@ def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: Optional[str], cell_cache: Dict): + """Build or reuse a gdstk cell for a saved layout document.""" cell_name = _safe_cell_name(layout.get("name") or "layout", library) top = library.new_cell(cell_name) + # Each saved instance becomes a GDS reference to either another project cell + # or a resolved PDK asset. for instance_name, instance in (layout.get("instances") or {}).items(): component = str(instance.get("component") or "") x = _number(instance.get("x")) @@ -54,9 +60,12 @@ def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: O def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_dir: Optional[str], cell_cache: Dict): + """Resolve a placed child component from local cells or PDK assets.""" if component in cell_cache: return cell_cache[component] + # Project-local composite cells are resolved before external PDK components + # so nested user-created cells can appear in preview output. local_layout = _load_local_layout(component, project_dir) if local_layout is not None: child = _build_gdstk_cell(gdstk, library, local_layout, pdk_registry, project_dir, cell_cache) @@ -72,10 +81,12 @@ def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_di def _import_gds_cell(gdstk, library, gds_path: str): + """Import a GDS file and return the first usable cell for placement.""" source = gdstk.read_gds(gds_path) top_cells = source.top_level() if not top_cells: raise ValueError(f"No top-level cell found in {gds_path}") + # Reuse already-imported cells by name to keep the preview library compact. for source_cell in source.cells: if _library_cell_by_name(library, source_cell.name) is None: library.add(source_cell) @@ -83,9 +94,12 @@ def _import_gds_cell(gdstk, library, gds_path: str): def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str: + """Generate preview SVG geometry using Nazca when gdstk is unavailable.""" import nazca as nd png_path = os.path.splitext(output_path)[0] + ".gds" + # Nazca can place the same GDS references as the preview path, but this + # backend still requires gdstk for final SVG conversion. with nd.Cell(str(layout.get("name") or "layout")) as top: for instance_name, instance in (layout.get("instances") or {}).items(): component = str(instance.get("component") or "") @@ -101,6 +115,7 @@ def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[dict]: + """Load a project-local composite layout referenced by another cell.""" if not project_dir or "/" in component or "\\" in component or component == "generate_with_forge": return None for ext in (".yml", ".yaml"): @@ -112,6 +127,7 @@ def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[d def _safe_cell_name(name, library) -> str: + """Generate a backend-safe unique cell name for GDS/Nazca libraries.""" base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "layout" candidate = base counter = 1 @@ -122,6 +138,7 @@ def _safe_cell_name(name, library) -> str: def _library_cell_by_name(library, name: str): + """Find a cell object in a loaded layout library by name.""" for cell in library.cells: if cell.name == name: return cell @@ -129,6 +146,7 @@ def _library_cell_by_name(library, name: str): def _number(value, default=0.0) -> float: + """Convert numeric YAML values to floats with a stable default.""" try: if value is None or value == "": return default diff --git a/backend/pdk_access.py b/backend/pdk_access.py index 8fdc453..1707b65 100644 --- a/backend/pdk_access.py +++ b/backend/pdk_access.py @@ -10,6 +10,8 @@ import shutil import uuid +# Role names control which PDK tree the backend exposes for browsing, preview, +# and GDS export requests. MANAGER_GROUP = "manager" DEVELOPER_GROUP = "developers" USER_GROUP = "user" @@ -17,12 +19,16 @@ ALLOWED_GROUPS = {MANAGER_GROUP, DEVELOPER_GROUP, USER_GROUP} def normalize_user_group(user_group: str) -> str: + """Normalize a session user group into the supported PDK access scope.""" group = (user_group or USER_GROUP).strip().lower() return group if group in ALLOWED_GROUPS else USER_GROUP def pdk_root_for_group(user_group: str, repo_root: str) -> str: + """Resolve the PDK library root that belongs to a user group.""" group = normalize_user_group(user_group) + # Managers may access the private atlas PDK tree; all other roles stay on + # the public PDK tree used for normal project editing. if group == MANAGER_GROUP: return os.path.abspath(os.environ.get( "MXPIC_PDK_ATLAS_ROOT", @@ -35,14 +41,17 @@ def pdk_root_for_group(user_group: str, repo_root: str) -> str: def pdk_root_for_session(session_obj, repo_root: str) -> str: + """Resolve the active PDK root from the current Flask session.""" return pdk_root_for_group(session_obj.get("user_group"), repo_root) def prefer_full_gds_for_session(session_obj) -> bool: + """Decide whether resolved PDK assets should prefer full GDS files.""" return normalize_user_group(session_obj.get("user_group")) == MANAGER_GROUP def create_export_path(export_root: str, project_name: str) -> tuple[str, str, str]: + """Create a unique export directory and filename for generated downloads.""" export_id = uuid.uuid4().hex filename = f"{project_name}.gds" export_dir = os.path.abspath(os.path.join(export_root, export_id)) @@ -51,9 +60,12 @@ def create_export_path(export_root: str, project_name: str) -> tuple[str, str, s def cleanup_expired_exports(export_root: str, max_age_seconds: int = 86400) -> None: + """Remove old export folders so temporary download storage stays bounded.""" if not os.path.isdir(export_root): return now = time.time() + # Each export is stored in its own UUID directory, so old folders can be + # removed independently without touching active project data. for name in os.listdir(export_root): path = os.path.join(export_root, name) if not os.path.isdir(path): diff --git a/backend/pdk_registry.py b/backend/pdk_registry.py index a765821..085d6a8 100644 --- a/backend/pdk_registry.py +++ b/backend/pdk_registry.py @@ -13,6 +13,7 @@ import yaml @dataclass class PdkAsset: + """Container describing the YAML and GDS assets resolved for a PDK component.""" component: str yaml_path: Optional[str] = None gds_path: Optional[str] = None @@ -23,11 +24,13 @@ class PdkRegistry: """Resolve public PDK component names to metadata and public GDS assets.""" def __init__(self, public_root: str, prefer_full_gds: bool = False): + """Store the active PDK root and cache resolved component assets.""" self.public_root = os.path.abspath(public_root) self.prefer_full_gds = prefer_full_gds self._asset_cache = {} def resolve(self, component: str) -> PdkAsset: + """Resolve YAML and GDS assets for a requested component key.""" key = (component or "").strip().replace("\\", "/").strip("/") if not key: return PdkAsset(component=component) @@ -42,6 +45,9 @@ class PdkRegistry: return asset def _find_yaml(self, key: str) -> Optional[str]: + """Locate the component YAML description file in the active PDK tree.""" + # Try direct component paths first so saved YAML component references + # resolve without scanning the whole PDK tree. direct = os.path.join(self.public_root, *key.split("/")) candidates = [] if direct.lower().endswith((".yml", ".yaml")): @@ -56,6 +62,8 @@ class PdkRegistry: return os.path.abspath(candidate) name = key.split("/")[-1] + # Fall back to a tree scan for older saved references that only stored + # the component folder name. for root, dirs, files in os.walk(self.public_root): if os.path.basename(root) == name: for filename in files: @@ -65,8 +73,11 @@ class PdkRegistry: return None def _find_gds(self, key: str, yaml_path: Optional[str]) -> Optional[str]: + """Locate the best matching GDS asset for a component YAML or key.""" search_dir = os.path.dirname(yaml_path) if yaml_path else os.path.join(self.public_root, *key.split("/")) name = key.split("/")[-1] + # Normal users prefer black-box GDS for fast previews; manager sessions + # can prefer full layout geometry for complete export. if self.prefer_full_gds: candidates = [ os.path.join(search_dir, f"{name}.gds"), @@ -80,6 +91,8 @@ class PdkRegistry: for candidate in candidates: if self._inside_root(candidate) and os.path.exists(candidate): return os.path.abspath(candidate) + # If the expected filename is missing, choose the first available GDS in + # the component folder while respecting the full-vs-BB preference. if os.path.isdir(search_dir): gds_files = sorted(filename for filename in os.listdir(search_dir) if filename.lower().endswith(".gds")) full_files = [filename for filename in gds_files if not filename.lower().endswith("_bb.gds")] @@ -90,11 +103,13 @@ class PdkRegistry: return None def _load_yaml(self, yaml_path: Optional[str]) -> Optional[dict]: + """Read a YAML component metadata file into a dictionary.""" if not yaml_path: return None with open(yaml_path, "r", encoding="utf-8") as file: return yaml.safe_load(file) or {} def _inside_root(self, path: str) -> bool: + """Check that a candidate asset path remains inside the permitted PDK root.""" target = os.path.abspath(path) return target == self.public_root or target.startswith(self.public_root + os.sep) diff --git a/backend/routed_layout_preview.py b/backend/routed_layout_preview.py index 95ee5a1..2ee1a44 100644 --- a/backend/routed_layout_preview.py +++ b/backend/routed_layout_preview.py @@ -24,12 +24,16 @@ def create_routed_layout_svg( layout = yaml.safe_load(yaml_content) or {} cell_name = str(layout.get("name") or "layout") + # mxpic_router is kept as a sibling repository, so the backend adds it to + # sys.path only when the local checkout is available. router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router")) if os.path.isdir(router_root) and router_root not in sys.path: sys.path.insert(0, router_root) from mxpic_router import build_project_gds os.makedirs(os.path.dirname(output_path), exist_ok=True) + # Build into a temporary GDS first, then convert the generated top cell into + # the SVG preview consumed by the canvas. with tempfile.TemporaryDirectory(prefix="mxpic_routed_preview_") as temp_dir: temp_gds = os.path.join(temp_dir, f"{cell_name}.gds") build_project_gds( @@ -49,7 +53,10 @@ def create_routed_layout_svg( def layout_has_links(yaml_content: str) -> bool: + """Detect whether a layout YAML document contains routed bundle links.""" layout = yaml.safe_load(yaml_content) or {} + # Any bundle link means preview generation must use the routed builder + # instead of simple component placement. for bundle in (layout.get("bundles") or {}).values(): links = bundle.get("links") or [] if links: diff --git a/backend/server.py b/backend/server.py index 3d9cce5..b5a24a3 100644 --- a/backend/server.py +++ b/backend/server.py @@ -29,6 +29,8 @@ from routed_layout_preview import create_routed_layout_svg, layout_has_links from technology_manifest import TechnologyManifestError, read_technology_manifest # --- Path Configurations --- +# Centralize all filesystem roots used by Flask routes, previews, project +# storage, PDK discovery, icon serving, and temporary export downloads. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend') @@ -39,14 +41,17 @@ PDK_PUBLIC_ROOT = os.path.abspath(os.environ.get( )) EDA_PDK_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs')) YML_PATH = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra', 'directories.yaml') -# Define where your new icons folder is located (adjust if it's placed elsewhere) +# Component/category icons are served from backend/icons for the library panel. ICONS_DIR = os.path.join(BASE_DIR, 'icons') -#build layout save path +# Saved project YAML, generated previews, and temporary exports live under the +# database folder so each user can have isolated project storage. DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database')) EXPORT_ROOT = os.path.abspath(os.path.join(DATABASE_ROOT, '_exports')) +# Flask serves the HTML/CSS/JS frontend and exposes JSON APIs for persistence, +# PDK lookup, preview generation, and GDS export. app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) app.secret_key = os.environ.get('MXPIC_SECRET_KEY', 'change_me_for_intranet_deployment') app.config.update( @@ -56,6 +61,7 @@ app.config.update( ) app.json.sort_keys = False +# Initialize the lightweight local database when the server process starts. database.init_db() @@ -68,8 +74,10 @@ def no_cache_response(response): def login_required_json(view_func): + """Wrap API handlers so unauthenticated requests receive JSON errors.""" @wraps(view_func) def wrapper(*args, **kwargs): + """Run the wrapped route only when a user session is present.""" if 'user_id' not in session: return jsonify({"error": "Authentication required"}), 401 return view_func(*args, **kwargs) @@ -77,6 +85,7 @@ def login_required_json(view_func): def request_ip(): + """Extract the best available client IP address for audit logging.""" forwarded_for = request.headers.get('X-Forwarded-For', '') if forwarded_for: return forwarded_for.split(',')[0].strip() @@ -84,6 +93,7 @@ def request_ip(): def record_action(action, project=None, cell=None, detail=None): + """Write a user action into the audit log using current session context.""" if 'user_id' not in session: return if isinstance(detail, (dict, list)): @@ -112,29 +122,37 @@ def safe_name(value, fallback): def user_layout_root(): + """Return the current user layout directory under the database root.""" username = safe_name(session.get('username'), 'anonymous') return os.path.join(DATABASE_ROOT, username, 'layout') def project_root(project_name): + """Return the filesystem directory for a named project.""" return os.path.join(user_layout_root(), safe_name(project_name, 'project_1')) def cell_file_path(project_name, cell_name): + """Return the YAML file path for a project cell.""" return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.yml") def cell_svg_path(project_name, cell_name): + """Return the SVG preview path for a project cell.""" return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg") def cell_routes_path(project_name, cell_name): + """Return the route sidecar JSON path for a project cell.""" return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml") def write_route_points_sidecar(yaml_content, output_path): + """Extract route points from layout YAML and save them beside the cell.""" layout = yaml.safe_load(yaml_content) or {} routes = {} + # The sidecar preserves manually edited route control points separately from + # the main YAML file for tooling that wants route-only metadata. for bundle_name, bundle in (layout.get("bundles") or {}).items(): saved_links = [] for link in bundle.get("links") or []: @@ -159,34 +177,43 @@ def write_route_points_sidecar(yaml_content, output_path): def project_gds_path(project_name): + """Return the generated GDS path for a project.""" return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds") def technology_manifest_path_for_project(project_name): + """Return the stored technology manifest path for a project.""" technology_id = read_project_meta(project_name).get("technology") or "" if "/" not in technology_id: return None foundry, technology = technology_id.split("/", 1) path = os.path.abspath(os.path.join(EDA_PDK_ROOT, safe_name(foundry, ''), safe_name(technology, ''), "technology.yml")) + # Keep stored project metadata from escaping the local EDA PDK root. if path.startswith(EDA_PDK_ROOT + os.sep) and os.path.exists(path): return path return None def current_pdk_root(): + """Resolve the active PDK root for the current request session.""" return pdk_root_for_session(session, REPO_ROOT) def current_pdk_registry(): + """Create a PDK registry configured for the current session scope.""" return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session)) def scoped_pdk_root_for_project(project_name): + """Resolve the correct PDK root for an existing project and session.""" base_root = current_pdk_root() project = safe_name(project_name, '') if not project: return base_root + # When a project has a saved foundry/technology, library browsing is scoped + # to that technology folder; otherwise it falls back to the user's full PDK + # access root. technology_id = read_project_meta(project).get("technology") or "" if "/" not in technology_id: return base_root @@ -199,15 +226,18 @@ def scoped_pdk_root_for_project(project_name): def pdk_root_for_request_project(): + """Resolve the PDK root requested by a project-aware API call.""" project = request.args.get('project') return scoped_pdk_root_for_project(project) if project else current_pdk_root() def project_meta_path(project_name): + """Return the metadata JSON path for a project.""" return os.path.join(project_root(project_name), ".project.json") def read_project_meta(project_name): + """Read project metadata such as foundry and technology selections.""" path = project_meta_path(project_name) if not os.path.exists(path): return {} @@ -216,25 +246,32 @@ def read_project_meta(project_name): def write_project_meta(project_name, meta): + """Persist project metadata to disk.""" os.makedirs(project_root(project_name), exist_ok=True) with open(project_meta_path(project_name), 'w', encoding='utf-8') as f: json.dump(meta, f, indent=2) def ensure_project_path(project_name): + """Create and return the directory for a project.""" layout_root = os.path.abspath(user_layout_root()) target = os.path.abspath(project_root(project_name)) + # All project paths must remain under the authenticated user's layout root. if target != layout_root and not target.startswith(layout_root + os.sep): raise ValueError("Invalid project path") return target -# ... [Keep countSpaces and buildTree exactly as they are] ... +# --- PDK Library Scanning Helpers --- +# These helpers turn the active PDK folder structure into the nested component +# library tree shown in the canvas sidebar. def findComps(baseDir, path_root=None): """Scan component folders, return map of paths -> component info.""" compMap = {} refDir = baseDir path_root = os.path.abspath(path_root or baseDir) + # A folder containing a YAML file is treated as a component leaf; scanning + # stops below that leaf so nested assets do not pollute the library tree. for root, dirs, files in os.walk(baseDir): ymlFiles = [f for f in files if f.endswith('.yml')] if ymlFiles: @@ -260,6 +297,8 @@ def addCompsToTree(compMap): """Build a completely fresh tree from scratch and insert component nodes.""" fresh_tree = OrderedDict() + # Convert path tuples collected from disk into the nested object structure + # consumed by the frontend library panel. for mapKey, compItem in compMap.items(): pathSeg = mapKey[:-1] compName = compItem['folder'] @@ -280,13 +319,15 @@ def addCompsToTree(compMap): return fresh_tree -# ... [Keep readCompYaml and Page Routes exactly as they are] ... +# Component metadata lookup helpers used by library/detail API endpoints. # --- API ROUTES (Library, Components & Icons) --- @app.route('/api/icon/') def getIcon(category): """Serve the icon corresponding to the component category.""" + # Prefer exact category artwork, then fall back to a default icon or a + # transparent 1x1 image to keep the frontend layout stable. for ext in ('.png', '.svg', '.jpg'): icon_path = os.path.join(ICONS_DIR, f"{category}{ext}") if os.path.exists(icon_path): @@ -304,11 +345,14 @@ def getIcon(category): ) return Response(transparent_png, mimetype='image/png') -# ... [Keep existing API routes below] ... +# Component metadata helpers sit near the library routes because they share the +# same role-scoped PDK root resolution. def readCompYaml(compName, comps_root=None): """Load YAML from component folder.""" search_root = comps_root or current_pdk_root() + # Component names are resolved by folder basename for compatibility with + # saved canvas component references. for root, dirs, files in os.walk(search_root): if os.path.basename(root) == compName: dirs.clear() @@ -321,6 +365,7 @@ def readCompYaml(compName, comps_root=None): def find_component_dir(component_name, comps_root=None): + """Find the directory containing a named component in the PDK library.""" search_root = comps_root or current_pdk_root() for root, dirs, files in os.walk(search_root): if os.path.basename(root) == component_name: @@ -387,6 +432,7 @@ def logout(): @app.route('/api/health') def health_check(): + """Expose a small deployment health endpoint.""" return jsonify({"status": "ok", "service": "mxpic_eda"}) @@ -396,6 +442,8 @@ def list_technologies(): """List technology choices from mxpic/PDKs//.""" technologies = [] pdks_root = EDA_PDK_ROOT + # Technology choices are built from directory names because each technology + # folder owns its generated technology.yml manifest. if os.path.isdir(pdks_root): for foundry in sorted(os.listdir(pdks_root)): foundry_path = os.path.join(pdks_root, foundry) @@ -417,6 +465,7 @@ def list_technologies(): @app.route('/api/technologies///manifest', methods=['GET']) @login_required_json def get_technology_manifest(foundry, technology): + """Return the routing and layer manifest for a selected technology.""" try: manifest = read_technology_manifest( EDA_PDK_ROOT, @@ -431,6 +480,9 @@ def get_technology_manifest(foundry, technology): @app.route('/api/profile', methods=['GET', 'PATCH']) @login_required_json def account_profile(): + """Return or update profile details for the current user.""" + # Keep the frontend occupation selector constrained to known values that are + # meaningful for the account page. occupations = {'intern', 'senior engineer', 'junior engineer', 'principle engineer'} user_id = session.get('user_id') @@ -460,6 +512,7 @@ def account_profile(): @app.route('/api/profile/password', methods=['POST']) @login_required_json def change_password(): + """Validate and persist a password change for the current user.""" data = request.get_json(silent=True) or {} current_password = data.get('current_password') or '' new_password = data.get('new_password') or '' @@ -478,6 +531,7 @@ def change_password(): @app.route('/api/logs', methods=['GET', 'POST']) @login_required_json def user_logs(): + """Return recent account activity for the current user.""" if request.method == 'POST': data = request.get_json(silent=True) or {} action = safe_name(data.get('action'), '') @@ -520,6 +574,8 @@ def list_projects(): os.makedirs(root, exist_ok=True) projects = [] + # Each project is a folder and each YAML file inside that folder is treated + # as one saved cell/canvas. for name in sorted(os.listdir(root)): path = os.path.join(root, name) if not os.path.isdir(path): @@ -547,12 +603,15 @@ def list_projects(): @app.route('/api/projects', methods=['POST']) @login_required_json def create_project(): + """Create a new project folder and initial cell layout.""" data = request.get_json(silent=True) or {} requested_name = safe_name(data.get('name'), 'project_1') technology = data.get('technology') or '' root = user_layout_root() os.makedirs(root, exist_ok=True) + # Preserve the user's requested base name, adding a numeric suffix only when + # a project folder already exists. project_name = requested_name counter = 1 while os.path.exists(os.path.join(root, project_name)): @@ -618,6 +677,7 @@ def delete_project(project_name): @app.route('/api/projects//cells/', methods=['PATCH', 'DELETE']) @login_required_json def rename_cell(project_name, cell_name): + """Rename a project cell and its preview/route sidecar files.""" if request.method == 'DELETE': cell = safe_name(cell_name, 'canvas_1') target = os.path.abspath(cell_file_path(project_name, cell)) @@ -653,6 +713,7 @@ def rename_cell(project_name, cell_name): @app.route('/api/save-layout', methods=['POST']) @login_required_json def save_layout(): + """Persist a canvas layout YAML document and refresh its preview assets.""" try: data = request.get_json() project = safe_name(data.get('project'), 'project_1') @@ -670,6 +731,8 @@ def save_layout(): svg_path = None if create_preview: svg_path = cell_svg_path(project, cell) + # Routed layouts need the router backend; placement-only layouts can + # use the simpler GDS/SVG preview path. if layout_has_links(content): create_routed_layout_svg( content, @@ -699,6 +762,7 @@ def save_layout(): @app.route('/api/projects//cells//layout.svg') @login_required_json def get_layout_svg(project_name, cell_name): + """Serve a saved SVG layout preview for a project cell.""" try: project_dir = ensure_project_path(project_name) svg_path = os.path.abspath(cell_svg_path(project_name, cell_name)) @@ -714,9 +778,11 @@ def get_layout_svg(project_name, cell_name): @app.route('/api/build-gds', methods=['POST']) @login_required_json def build_gds(): + """Build project GDS output and return a downloadable export URL.""" data = request.get_json(silent=True) or {} project = safe_name(data.get('project'), 'project_1') try: + # Expire old exports before creating a new temporary download folder. cleanup_expired_exports(EXPORT_ROOT) project_dir = ensure_project_path(project) if not os.path.isdir(project_dir): @@ -751,9 +817,12 @@ def build_gds(): @app.route('/api/exports//') @login_required_json def download_export(export_id, filename): + """Serve a temporary exported GDS file by export id.""" export_id = safe_name(export_id, '') safe_filename = safe_name(filename, 'layout.gds') export_dir = os.path.abspath(os.path.join(EXPORT_ROOT, export_id)) + # Export downloads are confined to the temporary export root and removed + # after the response closes. if not export_id or not export_dir.startswith(EXPORT_ROOT + os.sep): return jsonify({"error": "Invalid export path"}), 400 if not safe_filename.lower().endswith('.gds'): @@ -769,6 +838,7 @@ def download_export(export_id, filename): @app.route('/api/projects//gds/') @login_required_json def get_project_gds(project_name, filename): + """Serve the latest generated GDS file for a project.""" try: project_dir = ensure_project_path(project_name) safe_filename = safe_name(filename, f"{safe_name(project_name, 'project_1')}.gds") @@ -792,6 +862,8 @@ def getLib(): """Get library structure.""" comps_root = pdk_root_for_request_project() fresh_tree = {} + # The library tree is rebuilt on request so project technology changes and + # role-scoped PDK roots are reflected immediately. if os.path.isdir(comps_root): compMap = findComps(comps_root, current_pdk_root()) fresh_tree = addCompsToTree(compMap) @@ -814,6 +886,8 @@ def getCompImg(component_name): """Return first image in component folder.""" root, files = find_component_dir(component_name, pdk_root_for_request_project()) if root: + # Use the first common image asset in the component folder as its + # preview thumbnail. for ext in ('.png', '.jpg', '.jpeg', '.svg'): for f in files: if f.lower().endswith(ext): @@ -821,6 +895,8 @@ def getCompImg(component_name): return jsonify({"error": "No image found"}), 404 if __name__ == '__main__': + # Allow deployment scripts to choose host, port, and debug behavior through + # environment variables. host = os.environ.get('MXPIC_HOST', '0.0.0.0') port = int(os.environ.get('MXPIC_PORT', '3000')) debug = os.environ.get('MXPIC_DEBUG', '0').lower() in {'1', 'true', 'yes'} diff --git a/backend/technology_manifest.py b/backend/technology_manifest.py index 5a4b745..1704d91 100644 --- a/backend/technology_manifest.py +++ b/backend/technology_manifest.py @@ -10,23 +10,30 @@ import yaml class TechnologyManifestError(Exception): + """Exception raised when a technology manifest cannot be found or parsed.""" pass def technology_manifest_path(pdks_root: str, foundry: str, technology: str) -> str: + """Build the expected path to a foundry/technology manifest YAML file.""" base = os.path.abspath(pdks_root) path = os.path.abspath(os.path.join(base, foundry, technology, "technology.yml")) + # Keep user-provided foundry/technology names from escaping the configured + # PDK root. if not path.startswith(base + os.sep): raise TechnologyManifestError("Invalid technology path") return path def read_technology_manifest(pdks_root: str, foundry: str, technology: str) -> dict: + """Load and validate the active technology manifest for the frontend.""" path = technology_manifest_path(pdks_root, foundry, technology) if not os.path.exists(path): raise TechnologyManifestError("technology manifest not generated; run mxpic_forge technology export workflow") with open(path, "r", encoding="utf-8") as file: manifest = yaml.safe_load(file) or {} + # The frontend route editor depends on both xsection definitions and global + # defaults being present before it can safely style or build links. if not isinstance(manifest.get("xsections"), dict): raise TechnologyManifestError("technology manifest is missing xsections") if not isinstance(manifest.get("defaults"), dict): diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg index 518873c..710dfc1 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg @@ -12,373 +12,373 @@ .l90d0 {stroke: #F6A600; fill: #F6A600; fill-opacity: 0.5;} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/database/mxpic_data.db b/database/mxpic_data.db index 8788761..44917c5 100644 Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ diff --git a/frontend/canvas-helpers.js b/frontend/canvas-helpers.js index 8914425..cf5d1b7 100644 --- a/frontend/canvas-helpers.js +++ b/frontend/canvas-helpers.js @@ -5,19 +5,28 @@ * Organization : OptiHK Limited */ (function (root, factory) { + // Build the helper API once, then expose it to both browser and Node test environments. const helpers = factory(); if (typeof module === 'object' && module.exports) { module.exports = helpers; } root.MxpicCanvasHelpers = helpers; })(typeof window !== 'undefined' ? window : globalThis, function () { + // Label used by the canvas to represent generated mxpic_forge components. const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge'; + // Serialized component type used when saving mxpic_forge-generated components. const FORGE_COMPONENT_TYPE = 'generate_with_forge'; + // Fallback visual size for PDK components without explicit metadata. const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 }; + // Default editable canvas dimensions in micrometers. const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 }; + // Base visual diameter and hit area used for port and anchor handles. const PORT_NODE_SIZE = 30; + // Narrow anchor body width used in the canvas visual representation. const ANCHOR_NODE_WIDTH = 8; + // Default spacing between repeated anchor or port pins. const DEFAULT_ELEMENT_PITCH = 10; + // Defines built-in port and anchor element metadata before per-node expansion. const ELEMENT_COMPONENTS = { Port: { name: 'Port', @@ -35,6 +44,7 @@ } } }; + // Defines local primitive components that do not require PDK lookup. const BASIC_COMPONENTS = { waveguide: { name: 'waveguide', @@ -69,6 +79,7 @@ } }; + // Default parameters sent when creating a component through mxpic_forge. const DEFAULT_FORGE_ARGUMENTS = { function_name: 'straight', component_name: '', @@ -87,6 +98,7 @@ notes: '' }; + // Fallback routing technology data used when the backend manifest is unavailable. const FALLBACK_TECHNOLOGY_MANIFEST = { routing_types: ['euler_bend', 'standard_bend'], defaults: { @@ -104,18 +116,22 @@ } }; + // Merge user edits with default mxpic_forge arguments for saving and generation. const createForgeArguments = (overrides) => ({ ...DEFAULT_FORGE_ARGUMENTS, ...(overrides || {}) }); + // Return a manifest object, falling back to bundled defaults when needed. const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST; + // Look up width, radius, and family defaults for a routing cross-section. const getXsectionInfo = (xsection, manifest) => { const technology = getTechnologyManifest(manifest); return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {}; }; + // Normalize route settings so every edge has xsection, family, width, radius, and bend type. const createRouteSettings = (manifest, overrides) => { const technology = getTechnologyManifest(manifest); const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults; @@ -132,6 +148,7 @@ }; }; + // Apply a single route-field edit while preserving route defaults and width override state. const updateRouteField = (route, key, value, manifest) => { const current = createRouteSettings(manifest, route); const numericFields = new Set(['width', 'radius']); @@ -143,6 +160,7 @@ }; }; + // Switch an edge cross-section and refresh dependent routing defaults. const updateRouteXsection = (route, xsection, manifest) => { const technology = getTechnologyManifest(manifest); const current = createRouteSettings(technology, route); @@ -159,6 +177,7 @@ return next; }; + // Convert route settings into React Flow edge styling for canvas display. const routeStyleForSettings = (route, selected) => { const settings = createRouteSettings(null, route); const palette = { @@ -180,14 +199,18 @@ }; }; + // Check whether a component name refers to the mxpic_forge generator placeholder. const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE; + // Check whether a component is one of the built-in primitive canvas elements. const isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]); + // Merge primitive component defaults with user-entered values. const createBasicSettings = (componentName, overrides) => ({ ...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}), ...(overrides || {}) }); + // Normalize angles into the -180 to 180 degree range used by port logic. const normalizeAngle = (angle) => { const value = Number(angle); if (!Number.isFinite(value)) return 0; @@ -196,6 +219,7 @@ return Object.is(normalized, -0) ? 0 : normalized; }; + // Map a port angle to the canvas side where its handle should appear. const portSideFromAngle = (angle) => { const normalized = normalizeAngle(angle); if (normalized === 0) return 'right'; @@ -205,18 +229,22 @@ return Math.abs(normalized) < 90 ? 'right' : 'left'; }; + // Round handle percentages so saved and rendered positions stay stable. const roundPercent = (value) => Number(value.toFixed(3)); + // Generate even fallback spacing for handles when no exact position is available. const fallbackPercent = (index, count) => { if (count <= 1) return 50; return roundPercent(15 + (index / (count - 1)) * 70); }; + // Accept only finite positive numeric values from metadata or user input. const positiveNumber = (value) => { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; }; + // Resolve component visual dimensions from metadata with a safe fallback. const normalizeBoxSize = (metadata, fallback) => { const fallbackSize = fallback || DEFAULT_COMPONENT_BOX_SIZE; const raw = metadata && (metadata.box_size || metadata.box_sz || metadata.boxSize); @@ -235,6 +263,7 @@ }; }; + // Select the physical component to place when a library category is dragged. const chooseCategoryComponent = (dragName, availableComponents, categoryName) => { const available = Array.isArray(availableComponents) ? availableComponents.filter(Boolean) @@ -244,11 +273,13 @@ return physicalComponent || dragName || available[0] || categoryName; }; + // Resolve valid canvas dimensions from saved project metadata. const normalizeCanvasSize = (size) => ({ width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width, height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height }); + // Keep dragged or pasted nodes inside the active canvas bounds. const clampPositionToCanvas = (position, canvasSize, boxSize) => { const size = normalizeCanvasSize(canvasSize); const box = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); @@ -260,6 +291,7 @@ }; }; + // Rotate or mirror a component-box corner for layout bounds calculation. const transformBoxCorner = (corner, transform) => { const options = transform || {}; let x = Number(corner && corner.x) || 0; @@ -277,8 +309,10 @@ }; }; + // Round layout-bound values for stable preview and export metadata. const roundBoundsValue = (value) => Number(value.toFixed(6)); + // Calculate the bounding rectangle containing all visible canvas nodes. const calculateLayoutBounds = (pageOrNodes) => { const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {}); const nodes = Array.isArray(page.nodes) ? page.nodes : []; @@ -326,8 +360,10 @@ }; }; + // Round ruler measurements for compact display. const roundMeasureValue = (value) => Number(value.toFixed(3)); + // Convert pointer coordinates into valid ruler measurement points. const normalizeMeasurePoint = (point) => { const x = Number(point && point.x); const y = Number(point && point.y); @@ -335,6 +371,7 @@ return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; }; + // Build distance, delta, and midpoint values for the ruler overlay. const createRulerMeasurement = (startPoint, endPoint) => { const start = normalizeMeasurePoint(startPoint); const end = normalizeMeasurePoint(endPoint); @@ -357,6 +394,7 @@ }; }; + // Derive compact symbol dimensions for drawing component previews. const createComponentSymbolMetrics = (boxSize) => { const size = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); const widthRatio = size.width >= 400 ? 0.95 : 0.9; @@ -366,6 +404,7 @@ }; }; + // Apply node rotation and mirror transforms to a component port definition. const transformPortInfo = (info, transform) => { const source = info || {}; const options = transform || {}; @@ -402,6 +441,7 @@ }; }; + // Create ordered React Flow handles for all ports on a single visual side. const buildSideHandles = (ports, side) => { const vertical = side === 'left' || side === 'right'; @@ -421,6 +461,7 @@ }); }; + // Group transformed ports into canvas handles with side and position styling. const buildPortHandles = (ports, transform) => { const grouped = { left: [], right: [], top: [], bottom: [] }; Object.entries(ports || {}).forEach(([name, info]) => { @@ -447,6 +488,7 @@ ]; }; + // Serialize primitive JavaScript values into YAML-friendly scalar text. const toYamlScalar = (value) => { if (value === null || value === undefined) return '""'; if (typeof value === 'number' && Number.isFinite(value)) return String(value); @@ -458,9 +500,12 @@ return JSON.stringify(String(value)); }; + // Convert canvas Y coordinates into layout Y coordinates. const canvasToLayoutY = (value) => -Number(value || 0); + // Convert layout Y coordinates back into canvas Y coordinates. const layoutToCanvasY = (value) => -Number(value || 0); + // Serialize nested component settings into YAML blocks. const buildSettingsYaml = (settings, indent) => { const pad = ' '.repeat(indent); const entries = Object.entries(settings || {}); @@ -468,6 +513,7 @@ return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n'); }; + // Serialize one component instance into saved layout YAML. const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, flip, flop, forgeArguments, basicArguments }) => { const forge = isForgeComponent(componentName); const basic = isBasicComponent(componentName); @@ -489,6 +535,7 @@ mirror: ${flip ? 'true' : 'false'}${settingsYaml}`; }; + // Serialize all component nodes on a page into the instances YAML section. const buildInstancesYaml = ({ nodes, resolveComponentPath }) => { return (nodes || []) .filter(node => node.data && node.data.componentName && !node.data.elementType) @@ -514,26 +561,33 @@ .join('\n\n'); }; + // Resolve the display/export name for a port-like node. const getNodePortName = (node) => { const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label); return name || (node && node.id) || 'port'; }; + // Detect standalone port nodes that become top-level layout ports. const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode'); + // Detect built-in port or anchor nodes for element YAML export. const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor'); + // Clamp repeated port counts to a supported positive integer. const normalizePortNumber = (value) => { const number = Math.floor(Number(value)); return Number.isFinite(number) ? Math.max(1, number) : 1; }; + // Resolve repeated port spacing with the default pitch fallback. const normalizePitch = (value) => { const number = Number(value); return Number.isFinite(number) ? Math.max(0, number) : DEFAULT_ELEMENT_PITCH; }; + // Calculate the centered offset for one repeated port index. const elementPortOffset = (index, count, pitch) => ((count - 1) / 2 - index) * pitch; + // Grow port and anchor visual bodies so repeated port circles do not overlap. const buildElementBoxSize = (data) => { const portNumber = normalizePortNumber(data && data.portNumber); const pitch = normalizePitch(data && data.pitch); @@ -544,6 +598,7 @@ }; }; + // Expand port and anchor definitions into one or more named physical ports. const buildElementPorts = (elementType, data) => { const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port']; if (!element) return {}; @@ -583,6 +638,7 @@ return JSON.parse(JSON.stringify(element.ports)); }; + // Generate port metadata for built-in primitive components. const buildBasicComponentPorts = (componentName, settings) => { const values = createBasicSettings(componentName, settings); const length = Number(values.length || 0); @@ -622,6 +678,7 @@ return {}; }; + // Generate visual metadata and port definitions for primitive components. const getBasicComponentMetadata = (componentName, settings) => { if (!isBasicComponent(componentName)) return null; const values = createBasicSettings(componentName, settings); @@ -646,6 +703,7 @@ }; }; + // Convert standalone port nodes into page-level layout ports. const buildPageComponentPorts = (port, nodes) => { const portNodes = (nodes || []).filter(isPortElementNode); if (portNodes.length > 0) { @@ -683,6 +741,7 @@ }; }; + // Serialize standalone canvas ports into a layout ports YAML section. const buildCanvasPortsYaml = (nodes, fallbackPort) => { const ports = buildPageComponentPorts(fallbackPort, nodes); const entries = Object.entries(ports); @@ -701,8 +760,10 @@ return `ports:\n${lines.join('\n')}`; }; + // Maintain legacy single-port YAML export behavior for older callers. const buildPortsYaml = (port) => buildCanvasPortsYaml([], port); + // Serialize built-in port and anchor nodes into layout element metadata. const buildElementsYaml = (nodes) => { const elementNodes = (nodes || []).filter(isElementNode); if (elementNodes.length === 0) return 'elements: {}'; @@ -726,6 +787,7 @@ return `elements:\n${lines.join('\n')}`; }; + // Serialize canvas links into routed bundle YAML including route settings and bend points. const buildBundlesYaml = (page, manifest) => { const { nodes = [], edges = [] } = page || {}; const nodeMap = {}; @@ -774,6 +836,7 @@ bundles: ${linksYaml}`; }; + // Return the center point of a node when a more precise port point is unavailable. const getNodeCenter = (node) => { if (!node) return null; return { @@ -782,6 +845,7 @@ ${linksYaml}`; }; }; + // Resolve the exact canvas coordinate of a named node port or anchor pin. const getNodePortCanvasPoint = (node, portName) => { if (!node) return null; const x = Number((node.position && node.position.x) || 0); @@ -824,12 +888,14 @@ ${linksYaml}`; }; }; + // Convert handle percent strings into numeric ratios for endpoint lookup. const percentValue = (value, fallback = 50) => { if (typeof value !== 'string') return fallback; const number = Number(value.replace('%', '')); return Number.isFinite(number) ? number : fallback; }; + // Resolve a route endpoint from an edge handle and its connected node. const getEdgeEndpointPoint = (edge, nodeMap, endpoint) => { const nodeId = endpoint === 'source' ? edge.source : edge.target; const handleId = endpoint === 'source' ? edge.sourceHandle : edge.targetHandle; @@ -871,6 +937,7 @@ ${linksYaml}`; return null; }; + // Return the editable route polyline points for crossing checks and YAML export. const getEdgeRoutePoints = (edge, nodeMap) => { const explicitPoints = edge && edge.data && Array.isArray(edge.data.points) ? edge.data.points : []; if (explicitPoints.length >= 2) { @@ -891,6 +958,7 @@ ${linksYaml}`; return [getNodeCenter(nodeMap[edge.source]), getNodeCenter(nodeMap[edge.target])].filter(Boolean); }; + // Check two routed polylines for any segment crossing. const routeSegmentsIntersect = (pointsA, pointsB) => { for (let i = 0; i < pointsA.length - 1; i += 1) { for (let j = 0; j < pointsB.length - 1; j += 1) { @@ -902,12 +970,14 @@ ${linksYaml}`; return false; }; + // Classify point ordering for the line-segment intersection test. const orientation = (a, b, c) => { const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y); if (Math.abs(value) < 1e-9) return 0; return value > 0 ? 1 : 2; }; + // Detect whether two line segments cross each other. const segmentsIntersect = (p1, q1, p2, q2) => { if (!p1 || !q1 || !p2 || !q2) return false; const o1 = orientation(p1, q1, p2); @@ -917,6 +987,7 @@ ${linksYaml}`; return o1 !== o2 && o3 !== o4; }; + // Normalize equivalent route xsection names before same-type crossing checks. const routeTypeKey = (route) => { const xsection = String((route && route.xsection) || '').trim().toLowerCase(); if (xsection === 'metal1') return 'metal_1'; @@ -925,6 +996,7 @@ ${linksYaml}`; return xsection; }; + // Find an existing same-type route that would cross a candidate edge. const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => { const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route); const candidateType = routeTypeKey(candidateRoute); @@ -942,8 +1014,10 @@ ${linksYaml}`; return null; }; + // Backward-compatible alias for same-type route crossing validation. const findSameFamilyRouteCrossing = findSameTypeRouteCrossing; + // Expose the helper functions consumed by canvas.html and the Node-based tests. return { FORGE_COMPONENT_LABEL, FORGE_COMPONENT_TYPE, diff --git a/frontend/canvas.html b/frontend/canvas.html index a216c82..886928d 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -1477,6 +1477,7 @@ Organization : OptiHK Limited const iconPromiseCache = {}; + // Loads and caches category icons so repeated library renders do not refetch the same image. function fetchIcon(category) { if (!iconPromiseCache[category]) { let resolveFn; @@ -1506,6 +1507,7 @@ Organization : OptiHK Limited } + // Displays a category icon with cached loading and graceful failure behavior. const IconImg = memo(({ category, containerStyle }) => { const [src, setSrc] = useState(() => { if (!category) return undefined; @@ -1569,6 +1571,7 @@ Organization : OptiHK Limited + // Renders PDK and primitive component instances with transformed ports and selection styling. const RotatableNode = memo(({ id, data, selected }) => { const updateNodeInternals = useUpdateNodeInternals(); const prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`); @@ -1710,6 +1713,7 @@ Organization : OptiHK Limited + // Renders standalone exported port elements with repeated port handles. const PortNode = ({ id, data, selected }) => { const angle = data.angle ?? 0; const ports = buildElementPorts('port', data); @@ -1754,6 +1758,7 @@ Organization : OptiHK Limited ); }; + // Renders anchor elements with split visual handles while keeping paired layout ports connected. const AnchorNode = memo(({ id, data, selected }) => { const updateNodeInternals = useUpdateNodeInternals(); const anchorRotation = data.rotation || 0; @@ -1855,10 +1860,12 @@ Organization : OptiHK Limited ); }); + // Draws the non-interactive canvas extent marker used by React Flow. const CanvasBoundaryNode = memo(({ data }) => (
)); + // Draws invisible connection handles for ruler measurement endpoints. const RulerPointNode = memo(({ data }) => { const hiddenHandleStyle = { width: 1, @@ -1877,12 +1884,14 @@ Organization : OptiHK Limited ); }); + // Displays the ruler measurement label at the measured midpoint. const RulerMeasurementNode = memo(({ data }) => (
{data.label}
)); + // Maps visual route directions to x/y vectors for edge geometry calculations. const routeDirectionVector = (direction) => { if (direction === 'left') return { x: -1, y: 0 }; if (direction === 'right') return { x: 1, y: 0 }; @@ -1890,6 +1899,7 @@ Organization : OptiHK Limited if (direction === 'bottom') return { x: 0, y: 1 }; return null; }; + // Converts a route direction string into the matching React Flow handle position. const directionToReactFlowPosition = (direction) => { if (direction === 'left') return Position.Left; if (direction === 'right') return Position.Right; @@ -1898,6 +1908,7 @@ Organization : OptiHK Limited return undefined; }; + // Draws editable routed links, including parallel offsets and draggable bend control points. const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => { const offset = Number(data?.parallelOffset || 0); const hasExplicitPoints = Array.isArray(data?.points) && data.points.length >= 2; @@ -1971,6 +1982,7 @@ Organization : OptiHK Limited ); }); + // Displays generated layout SVG previews with zoom and pan controls. const LayoutSvgPreview = ({ page }) => { const [layoutScale, setLayoutScale] = useState(100); const previewBounds = useMemo( @@ -2039,6 +2051,7 @@ Organization : OptiHK Limited ); }; + // Allows a canvas tab title to be renamed in place. const EditableCanvasTabName = ({ page, active, onRename }) => { const [value, setValue] = useState(page.name); @@ -2077,6 +2090,7 @@ Organization : OptiHK Limited ); }; + // Allows project-tree canvas names to be renamed from the navigation panel. const EditableTreeCanvasName = ({ pageId, name, canRename, onRename, onOpen }) => { const [value, setValue] = useState(name); @@ -2119,8 +2133,10 @@ Organization : OptiHK Limited ); }; + // Checks whether a tree node represents a draggable component entry. const isLibraryComponentLeaf = (node) => node && node.__type__ === 'component'; + // Collects all component names under a library category for drag/drop selection. const getCategoryComponents = (categoryNode) => { return Object.entries(categoryNode || {}) .filter(([, childData]) => isLibraryComponentLeaf(childData)) @@ -2130,6 +2146,7 @@ Organization : OptiHK Limited })); }; + // Renders a top-level draggable category entry in the component library. const CategoryCard = ({ name, components = [] }) => { const componentNames = components.map(component => component.name).filter(Boolean); const selectableComponents = Array.from(new Set([FORGE_COMPONENT_LABEL, ...componentNames])); @@ -2159,6 +2176,7 @@ Organization : OptiHK Limited ); }; + // Renders recursive component library nodes with drag behavior for leaves. const TreeNode = ({ name, children }) => { if (children && children.__type__ === 'component') { const componentName = children.__name__; @@ -2305,6 +2323,7 @@ Organization : OptiHK Limited ); }; + // Renders recursive project/cell/instance navigation with open, drag, rename, and delete actions. const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas }) => { if (children && children.__type__ === 'project') { const projectName = children.__name__ || name; @@ -2428,6 +2447,7 @@ Organization : OptiHK Limited ); }; + // Renders the nested contents of a composite cell inside the project tree. const CompositeComponentTree = ({ name, children, canvasName, onSelectInstance }) => { if (children && children.__type__ === 'component') { const displayText = children.__instance__ || name; @@ -2465,6 +2485,7 @@ Organization : OptiHK Limited return null; }; + // Renders project actions, canvas sizing controls, and the component library navigation. const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => { const [projectPanelHeight, setProjectPanelHeight] = useState(270); const [resizingProjectPanel, setResizingProjectPanel] = useState(false); @@ -2488,6 +2509,7 @@ Organization : OptiHK Limited }; }, [resizingProjectPanel]); + // Toggle the expanded state of the project tree panel. const handleProjectToggle = () => { onProjectToggle(); }; @@ -2585,6 +2607,7 @@ Organization : OptiHK Limited ); }; + // Renders editable properties for selected nodes, ports, anchors, and routes. const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { const [componentData, setComponentData] = useState(null); const [loading, setLoading] = useState(false); @@ -3342,6 +3365,7 @@ Organization : OptiHK Limited ); }; + // Provides a draggable divider for resizing side panels. const ResizeHandle = ({ onMouseDown }) => (
); + // Finds the library path for a component name so it can be serialized into YAML. function findComponentPath(lib, compName) { function walk(obj, currentPath) { if (obj && obj.__type__ === 'component' && obj.__name__ === compName) { @@ -3372,6 +3397,7 @@ Organization : OptiHK Limited } return walk(lib, []) || []; } + // Builds a compact project-tree structure from placed component instances. function buildCompInstanceTree(compNodes, library) { const tree = {}; compNodes.forEach(node => { @@ -3388,6 +3414,7 @@ Organization : OptiHK Limited } + // Builds a category tree of components used by the current canvas. function buildCompTree(compNodes, library) { const tree = {}; compNodes.forEach(node => { @@ -3409,6 +3436,7 @@ Organization : OptiHK Limited return tree; } + // Coordinates editor state, project loading, naming, routing, save/load, and build actions. function App() { const currentProjectName = useMemo(() => { const params = new URLSearchParams(window.location.search); @@ -3485,6 +3513,7 @@ Organization : OptiHK Limited [rulerStartPoint, rulerActiveEndPoint] ); const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null; + // Normalizes free-route control points and removes adjacent duplicates before storage. const compactRoutePoints = useCallback((points) => { return (points || []) .map(point => ({ @@ -3494,7 +3523,9 @@ Organization : OptiHK Limited .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) .filter((point, index, list) => index === 0 || point.x !== list[index - 1].x || point.y !== list[index - 1].y); }, []); + // Builds stable hidden endpoint node ids for free-route edges. const routeEndpointNodeId = useCallback((edgeId, endpoint) => `__free_route_${edgeId}_${endpoint}__`, []); + // Creates a React Flow edge object for stored free-route polylines. const makeFreeRouteEdge = useCallback((edgeId, points, route, selected = false) => { const view = routeStyleForSettings(route, selected); return { @@ -3509,6 +3540,7 @@ Organization : OptiHK Limited data: { route, points: compactRoutePoints(points), freeRoute: true }, }; }, [compactRoutePoints, routeEndpointNodeId]); + // Builds temporary ruler endpoint and label nodes while measuring distance. const rulerNodes = useMemo(() => { if (!activePage || activePage.type === 'layoutPreview' || !rulerStartPoint) return []; const nodes = [{ @@ -3551,6 +3583,7 @@ Organization : OptiHK Limited } return nodes; }, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]); + // Builds temporary ruler edges between measurement endpoints. const rulerEdges = useMemo(() => { if (!rulerMeasurement) return []; return [{ @@ -3568,6 +3601,7 @@ Organization : OptiHK Limited } }]; }, [rulerMeasurement, rulerPreviewMeasurement]); + // Creates hidden nodes that let free-route edge endpoints participate in React Flow. const freeRouteEndpointNodes = useMemo(() => { if (!activePage || activePage.type === 'layoutPreview') return []; return currentEdges.flatMap(edge => { @@ -3601,6 +3635,7 @@ Organization : OptiHK Limited ]; }); }, [activePage, currentEdges, compactRoutePoints, routeEndpointNodeId]); + // Combines real nodes with boundary, ruler, and hidden route helper nodes for display. const renderNodes = useMemo(() => { if (!activePage || activePage.type === 'layoutPreview') return currentNodes; return [{ @@ -3615,6 +3650,7 @@ Organization : OptiHK Limited style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' } }, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes]; }, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]); + // Resolves rotated anchor handle direction so connected canvas links exit the correct side. const getAnchorHandleRouteDirection = useCallback((node, handleId) => { if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null; const handles = buildPortHandles(buildElementPorts('anchor', node.data), { @@ -3624,6 +3660,7 @@ Organization : OptiHK Limited }); return handles.find(handle => handle.name === handleId)?.position || null; }, []); + // Applies parallel offsets, anchor handle directions, and ruler overlays before rendering edges. const renderEdges = useMemo(() => { const groups = new Map(); const nodeMap = Object.fromEntries(currentNodes.map(node => [node.id, node])); @@ -3674,6 +3711,7 @@ Organization : OptiHK Limited localStorage.setItem('mxpic-theme', themeMode); }, [themeMode]); + // Append a short status message to the activity log. const addLog = useCallback((message) => { setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]); }, []); @@ -3689,10 +3727,12 @@ Organization : OptiHK Limited return () => window.clearInterval(timer); }, [buildProgress.active, buildProgress.value]); + // Start the build progress indicator for layout or GDS operations. const startBuildProgress = useCallback((label) => { setBuildProgress({ active: true, label, value: 8 }); }, []); + // Finish and auto-hide the build progress indicator. const completeBuildProgress = useCallback((label) => { setBuildProgress({ active: true, label, value: 100 }); window.setTimeout(() => { @@ -3700,14 +3740,17 @@ Organization : OptiHK Limited }, 900); }, []); + // Clear the build progress indicator after a failure or cancellation. const stopBuildProgress = useCallback(() => { setBuildProgress({ active: false, label: '', value: 0 }); }, []); + // Normalize YAML boolean-like values when loading saved projects. const toBooleanFlag = useCallback((value) => ( value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true' ), []); + // Normalize stored route points and convert layout Y coordinates when needed. const normalizeRoutePoints = useCallback((points, usesGdsYUp = false) => ( (Array.isArray(points) ? points : []) .map(point => ({ @@ -3717,6 +3760,7 @@ Organization : OptiHK Limited .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) ), []); + // Load routing defaults and cross-section data for the project technology. const loadTechnologyManifest = useCallback(async (technologyId) => { if (!technologyId || !technologyId.includes('/')) { setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); @@ -3743,6 +3787,7 @@ Organization : OptiHK Limited const componentDataCacheRef = useRef(new Map()); + // Fetch metadata for a component before creating a loaded or dropped node. const loadComponentMetadata = useCallback(async (componentName) => { if (!componentName || isForgeComponent(componentName)) return null; if (componentDataCacheRef.current.has(componentName)) { @@ -3755,6 +3800,7 @@ Organization : OptiHK Limited return data; }, [currentProjectName]); + // Send an auditable user action to the backend log endpoint. const recordUserAction = useCallback((action, payload = {}) => { fetch('/api/logs', { method: 'POST', @@ -3763,6 +3809,7 @@ Organization : OptiHK Limited }).catch(() => {}); }, []); + // Keep project/composite ownership maps in step when cells are placed or removed. const syncCompositePlacement = useCallback((projectName, compositeName, mode = 'add') => { setStandaloneComposites(prev => { if (mode === 'add') return prev.filter(name => name !== compositeName); @@ -3791,6 +3838,7 @@ Organization : OptiHK Limited }); }, []); + // Rebuild composite trees from all canvas pages after project load or cell edits. const syncAllCompositeTrees = useCallback((pagesToScan, libraryData) => { if (!libraryData) return; const nextTrees = {}; @@ -3805,6 +3853,7 @@ Organization : OptiHK Limited })); }, []); + // Apply React Flow node changes while preserving canvas-only helper nodes. const onNodesChange = useCallback((changes) => { if (!activePageId) return; const relevantChanges = changes.filter(change => change.id !== '__canvas-boundary__'); @@ -3836,6 +3885,7 @@ Organization : OptiHK Limited })); }, [activePageId, activePage, activeCanvasSize]); + // Apply React Flow edge changes while preserving route style and selection state. const onEdgesChange = useCallback((changes) => { if (!activePageId) return; setPages(prev => prev.map(p => { @@ -3850,6 +3900,7 @@ Organization : OptiHK Limited })); }, [activePageId, technologyManifest]); + // Apply property-panel edits to a selected node. const handleUpdateNode = useCallback((nodeId, update) => { if (!activePageId) return; setPages(prev => prev.map(p => { @@ -3880,6 +3931,7 @@ Organization : OptiHK Limited })); }, [activePageId, activeCanvasSize]); + // Update active canvas dimensions and clamp existing node positions inside the new bounds. const handleCanvasSizeChange = useCallback((axis, value) => { if (!activePageId) return; const numericValue = Number(value); @@ -3904,6 +3956,7 @@ Organization : OptiHK Limited })); }, [activePageId]); + // Rotate selected components, ports, and anchors in 90 degree steps from keyboard input. const rotateComponentByNinety = useCallback((nodeId) => { if (!activePageId || !nodeId) return; setPages(prev => prev.map(p => { @@ -3927,6 +3980,7 @@ Organization : OptiHK Limited })); }, [activePageId]); + // Resolve which selected or hovered node should rotate when Space is pressed. const getSpaceRotationTarget = useCallback(() => { if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current; const selectedSpaceNode = selectedNode; @@ -3935,16 +3989,19 @@ Organization : OptiHK Limited return selectedSpaceNode.id; }, [selectedNode]); + // Remember the node under the pointer so Space rotation can target it. const onNodeMouseDown = useCallback((event, node) => { if (event.button !== 0) return; if (node.type !== 'rotatableNode' && node.type !== 'portNode' && node.type !== 'anchorNode') return; spaceRotateNodeIdRef.current = node.id; }, []); + // Clear the temporary Space-rotation target when the mouse is released. const clearSpaceRotateNode = useCallback(() => { spaceRotateNodeIdRef.current = null; }, []); + // Apply route setting edits and reject same-type route crossings. const handleUpdateEdgeRoute = useCallback((edgeIds, routeUpdate) => { if (!activePageId) return; const targetEdgeIds = new Set(Array.isArray(edgeIds) ? edgeIds : [edgeIds]); @@ -3976,6 +4033,7 @@ Organization : OptiHK Limited })); }, [activePageId, technologyManifest, addLog]); + // Copy selected nodes into the local editor clipboard. const handleCopy = useCallback(() => { if (!activePage) return; const selectedNodes = activePage.nodes.filter(n => n.selected); @@ -3984,6 +4042,7 @@ Organization : OptiHK Limited } }, [activePage]); + // Copy and remove selected nodes while releasing their reserved display-name indexes. const handleCut = useCallback(() => { if (!activePage) return; const selectedNodes = activePage.nodes.filter(n => n.selected); @@ -3997,6 +4056,7 @@ Organization : OptiHK Limited } }, [activePage, setPages]); + // Paste copied nodes with new display names and offset positions. const handlePaste = useCallback(() => { if (!activePage || clipboard.nodes.length === 0) return; const newNodes = clipboard.nodes.map(node => { @@ -4031,6 +4091,7 @@ Organization : OptiHK Limited setClipboard({ nodes: newNodes }); }, [activePage, clipboard, generateComponentDisplayName]); + // Delete selected nodes and attached edges while freeing their name indexes. const handleDelete = useCallback(() => { if (!activePage) return; const selectedNodes = activePage.nodes.filter(n => n.selected); @@ -4115,6 +4176,7 @@ Organization : OptiHK Limited terminations: 'TERM' }; + // Split a generated display name into prefix and numeric index. function parseComponentDisplayName(displayName) { const match = String(displayName || '').match(/^(.+)_(\d+)$/); if (!match) return null; @@ -4123,6 +4185,7 @@ Organization : OptiHK Limited return { prefix: match[1], index }; } + // Mark a generated name index as used for its prefix. function reserveComponentDisplayName(displayName) { const parsed = parseComponentDisplayName(displayName); if (!parsed) return; @@ -4131,6 +4194,7 @@ Organization : OptiHK Limited componentIndexesByPrefixRef.current[parsed.prefix] = usedIndexes; } + // Release a generated name index so future components can reuse it. function releaseComponentDisplayName(displayName) { const parsed = parseComponentDisplayName(displayName); if (!parsed) return; @@ -4142,16 +4206,19 @@ Organization : OptiHK Limited } } + // Release generated name indexes for a group of deleted or cut nodes. function releaseComponentDisplayNames(nodes = []) { nodes.forEach(node => releaseComponentDisplayName(node?.data?.componentDisplayName)); } + // Rebuild the used-name index table from all currently loaded pages. function reserveComponentDisplayNamesFromPages() { pages.forEach(page => { (page.nodes || []).forEach(node => reserveComponentDisplayName(node?.data?.componentDisplayName)); }); } + // Convert a component category into the saved display-name prefix or abbreviation. const normalizeComponentDisplayNamePrefix = useCallback((prefixSource, options = {}) => { const cleanedPrefix = String(prefixSource || 'element') .trim() @@ -4181,6 +4248,7 @@ Organization : OptiHK Limited return singularPrefix; }, []); + // Create the next available prefix-specific component display name. const generateComponentDisplayName = useCallback((prefixSource = 'element', options = {}) => { const prefix = normalizeComponentDisplayNamePrefix(prefixSource, options); reserveComponentDisplayNamesFromPages(); @@ -4193,6 +4261,7 @@ Organization : OptiHK Limited return name; }, [normalizeComponentDisplayNamePrefix, pages]); + // Rename a component node and update the name-index reservation table. const renameComponent = useCallback((nodeId, newComponentDisplayName) => { if (!activePageId) return; const oldDisplayName = activePage?.nodes.find(node => node.id === nodeId)?.data?.componentDisplayName; @@ -4209,6 +4278,7 @@ Organization : OptiHK Limited })); }, [activePageId, activePage]); + // Load the current project-scoped PDK/component library from the backend. const fetchLibrary = useCallback(async () => { try { const res = await fetch(`/api/library?project=${encodeURIComponent(currentProjectName)}`); @@ -4220,6 +4290,7 @@ Organization : OptiHK Limited }, [currentProjectName]); useEffect(() => { fetchLibrary(); }, [fetchLibrary]); + // Flatten the library tree into component/category pairs. const collectComponentNames = useCallback((lib) => { const names = []; const walk = (obj) => { @@ -4234,6 +4305,7 @@ Organization : OptiHK Limited return names; }, []); + // Restore PDK-selection options for components loaded from saved YAML. const getAvailableComponentsForLoadedComponent = useCallback((componentName) => { if (!library || !componentName || isForgeComponent(componentName) || isBasicComponent(componentName)) return undefined; const componentEntries = collectComponentNames(library); @@ -4246,6 +4318,7 @@ Organization : OptiHK Limited return Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents, componentName])); }, [library, collectComponentNames]); + // Recreate saved port and anchor nodes when a project YAML document is loaded. const buildElementNodesFromYaml = useCallback((doc, usesGdsYUp, nodeNameMap = {}) => { const nodes = []; Object.entries(doc.elements || {}).forEach(([elementName, element]) => { @@ -4788,6 +4861,7 @@ Organization : OptiHK Limited const openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]); + // Open a page and select a named instance from the project tree. const selectInstanceInPage = useCallback((pageName, instanceName) => { if (!pageName || !instanceName) return; const targetPage = pages.find(p => p.name === pageName); @@ -4810,6 +4884,7 @@ Organization : OptiHK Limited })); }, [pages]); + // Open an existing project page by name. const openProject = useCallback((name) => { setPages(prev => { const existing = prev.find(p => p.name === name && p.type === 'project'); @@ -4832,6 +4907,7 @@ Organization : OptiHK Limited }); }, []); + // Open a canvas tab and make it active. const openPage = useCallback((name) => { const belongsToProject = Object.values(projectCompositeMap).some(comps => comps.includes(name)); if (!belongsToProject && !standaloneComposites.includes(name)) { @@ -4868,6 +4944,7 @@ Organization : OptiHK Limited }); }, [projectCompositeMap, standaloneComposites]); + // Rename a canvas cell and synchronize backend files when needed. const renameCanvas = useCallback((pageId, requestedName) => { const normalizedName = requestedName.trim().replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^[._]+|[._]+$/g, ''); if (!normalizedName) return; @@ -4924,6 +5001,7 @@ Organization : OptiHK Limited }).catch(() => addLog(`Renamed canvas locally; saved file rename did not complete.`)); }, [pages, currentProjectName, addLog]); + // Create a new composite canvas with a unique cell name. const createCell = useCallback(() => { const existingNames = new Set(pages.filter(p => p.type === 'composite').map(p => p.name)); let index = existingNames.size + 1; @@ -4963,6 +5041,7 @@ Organization : OptiHK Limited recordUserAction('canvas.create', { project: currentProjectName, cell: cellName }); }, [pages, currentProjectName, recordUserAction]); + // Close a canvas tab without deleting its saved content. const closePage = useCallback((pageId) => { setPages(prev => { const closed = prev.map(p => p.id === pageId ? { ...p, isClosed: true } : p); @@ -4976,6 +5055,7 @@ Organization : OptiHK Limited }); }, [activePageId]); + // Delete a saved canvas cell and update project/composite references. const deleteCanvas = useCallback((cellName) => { if (!cellName) return; if (!window.confirm(`Delete canvas "${cellName}" from this project?`)) return; @@ -5021,10 +5101,12 @@ Organization : OptiHK Limited }).catch(() => addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`)); }, [pages, activePageId, currentProjectName, addLog]); + // Switch the active editor tab. const switchPage = useCallback((pageId) => { setActivePageId(pageId); }, []); + // Update legacy page-level port settings for a canvas. const handlePortChange = useCallback((pageId, newPort) => { setPages(prev => prev.map(p => { if (p.id !== pageId) return p; @@ -5050,11 +5132,13 @@ Organization : OptiHK Limited })); }, []); + // Allow library and project-tree entries to be dropped onto the canvas. const onDragOver = useCallback((event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }, []); + // Create component, port, anchor, or composite nodes from dropped library entries. const onDrop = useCallback((event) => { event.preventDefault(); const rawData = event.dataTransfer.getData('application/reactflow'); @@ -5322,28 +5406,35 @@ Organization : OptiHK Limited }); }, [activePageId, activePage, activeCanvasSize, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName, toBooleanFlag]); + // Expand all library tree nodes. const expandAll = useCallback(() => { if (treeContainerRef.current) { treeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true); } }, []); + // Collapse all library tree nodes. const collapseAll = useCallback(() => setTreeKey(k => k + 1), []); + // Toggle the expanded state of the component library panel. const handleToggle = useCallback(() => { if (expanded) { collapseAll(); setExpanded(false); } else { expandAll(); setExpanded(true); } }, [expanded, expandAll, collapseAll]); + // Expand all project tree nodes. const expandProjectAll = useCallback(() => { if (projectTreeContainerRef.current) { projectTreeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true); } }, []); + // Collapse all project tree nodes. const collapseProjectAll = useCallback(() => setProjectTreeKey(k => k + 1), []); + // Toggle the expanded state of the project tree panel. const handleProjectToggle = useCallback(() => { if (projectExpanded) { collapseProjectAll(); setProjectExpanded(false); } else { expandProjectAll(); setProjectExpanded(true); } }, [projectExpanded, expandProjectAll, collapseProjectAll]); + // Begin side-panel resize tracking. const handleResizeStart = useCallback((side) => (e) => { e.preventDefault(); setDragging(side); @@ -5367,10 +5458,12 @@ Organization : OptiHK Limited }; }, [dragging]); + // Toggle snap-to-grid movement in the editor. const toggleGridSnap = useCallback(() => { setGridSnap(prev => !prev); }, []); + // Toggle the measurement ruler and clear partial measurements. const toggleRulerMode = useCallback(() => { setRulerMode(prev => { const next = !prev; @@ -5383,6 +5476,7 @@ Organization : OptiHK Limited }); }, []); + // Convert a pane click or pointer event into canvas ruler coordinates. const eventToRulerPoint = useCallback((event) => { const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }); return { @@ -5391,6 +5485,7 @@ Organization : OptiHK Limited }; }, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]); + // Set ruler start/end points from canvas clicks. const handleRulerPaneClick = useCallback((event) => { if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return; event.preventDefault(); @@ -5411,12 +5506,14 @@ Organization : OptiHK Limited } }, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]); + // Update the live ruler preview point while measuring. const handleRulerMouseMove = useCallback((event) => { if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return; if (!rulerStartPoint || rulerEndPoint) return; setRulerPreviewPoint(eventToRulerPoint(event)); }, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]); + // Select a route edge by id with optional additive selection. const selectEdgeById = useCallback((edgeId, additive = false) => { if (!activePageId || !edgeId) return; setPages(prev => prev.map(p => { @@ -5435,6 +5532,7 @@ Organization : OptiHK Limited })); }, [activePageId, technologyManifest]); + // Create a new routed connection and reject same-type crossings. const handleBasicConnection = useCallback((connection) => { if (!activePageId || !activePage || activePage.type === 'layoutPreview' || rulerMode) return; if (!connection?.source || !connection?.target || !connection?.sourceHandle || !connection?.targetHandle) return; @@ -5469,6 +5567,7 @@ Organization : OptiHK Limited addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`); }, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog]); + // Select custom route edges from their SVG hit target. const handleRouteEdgeMouseDown = useCallback((event) => { if (rulerMode) return false; const target = event.target?.closest?.('[data-route-edge-id]'); @@ -5481,6 +5580,7 @@ Organization : OptiHK Limited return true; }, [rulerMode, selectEdgeById]); + // Select standard React Flow edges while ignoring helper/ruler edges. const handleReactFlowEdgeMouseDown = useCallback((event, edge) => { if (rulerMode || !edge || edge.data?.draft || edge.data?.ruler) return; event.preventDefault(); @@ -5488,10 +5588,12 @@ Organization : OptiHK Limited selectEdgeById(edge.id, event.shiftKey); }, [rulerMode, selectEdgeById]); + // Forward canvas mouse-down events to route-edge selection logic. const handleCanvasMouseDown = useCallback((event) => { handleRouteEdgeMouseDown(event); }, [handleRouteEdgeMouseDown]); + // Build the left-panel project tree from project pages, composites, and instances. const projectTreeItems = useMemo(() => { const items = []; const projectPagesByName = new Map(); @@ -5560,6 +5662,7 @@ Organization : OptiHK Limited return items; }, [pages, library, projectCompositeMap, standaloneComposites, compositeTrees, activePageId]); + // Merge saved composite cells, built-in elements, primitives, and PDK entries for dragging. const libraryWithCells = useMemo(() => { const cellEntries = {}; pages @@ -5610,10 +5713,12 @@ Organization : OptiHK Limited }; }, [pages, library]); + // Serialize current page edges into bundle YAML with route metadata. const buildBundlesYaml = useCallback((page) => { return buildRouteBundlesYaml(page, technologyManifest); }, [technologyManifest]); + // Block layout or GDS builds when same-type route crossings are present. const validateRouteCrossings = useCallback((page) => { if (!page || !Array.isArray(page.edges)) return true; const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node])); @@ -5632,6 +5737,7 @@ Organization : OptiHK Limited return true; }, [technologyManifest, addLog]); + // Serialize a canvas page into the mxPIC YAML file format. const buildYamlForPage = useCallback((page) => { if (!page) return ''; const header = `# ============================================= @@ -5673,6 +5779,7 @@ ${elementsBlock} ${bundlesBlock}`; }, [currentProjectName, library, buildBundlesYaml]); + // Open or refresh a tab showing the generated SVG layout preview. const openLayoutPreview = useCallback((cellName, svgUrl, layoutBounds) => { if (!cellName || !svgUrl) return; const layoutTabId = `layout-${currentProjectName}-${cellName}`; @@ -5698,6 +5805,7 @@ ${bundlesBlock}`; setActivePageId(layoutTabId); }, [currentProjectName]); + // Save the active page, generate layout preview assets, and show the preview tab. const handleBuildLayout = useCallback(async () => { if (!activePage) return; if (buildLayoutBusy) return; @@ -5741,6 +5849,7 @@ ${bundlesBlock}`; } }, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); + // Save YAML for every editable project/composite page without opening previews. const handleSaveProjectLayouts = useCallback(async () => { if (saveProjectBusy) return; const savePages = pages.filter(page => page.type !== 'layoutPreview'); @@ -5774,6 +5883,7 @@ ${bundlesBlock}`; } }, [saveProjectBusy, pages, currentProjectName, buildYamlForPage, addLog]); + // Build project GDS output through the backend and open the download when ready. const handleBuildGds = useCallback(async () => { if (buildGdsBusy) return; const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page)); @@ -5815,6 +5925,7 @@ ${bundlesBlock}`; } }, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); + // Open composite cells when their placed instances are double-clicked. const onNodeDoubleClick = useCallback((event, node) => { if (node.data?.type === 'composite') { openPage(node.data.componentName); diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 23d73fd..d75ab7c 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -711,6 +711,7 @@ Organization : OptiHK Limited const logTerminal = document.getElementById('log-terminal'); let technologies = []; + // Append a dashboard status message with a timestamp. function addLog(message) { const line = document.createElement('div'); line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; @@ -718,6 +719,7 @@ Organization : OptiHK Limited logTerminal.scrollTop = logTerminal.scrollHeight; } + // Apply the selected dashboard theme class and persist it for later sessions. function applyTheme(mode) { document.body.classList.toggle('light-mode', mode === 'light'); themeToggle.textContent = mode === 'light' ? 'Dark Mode' : 'Bright Mode'; @@ -729,10 +731,12 @@ Organization : OptiHK Limited applyTheme(document.body.classList.contains('light-mode') ? 'dark' : 'light'); }); + // Navigate from the dashboard into the canvas editor for a saved project. function openProject(name) { window.location.href = `/canvas?project=${encodeURIComponent(name)}`; } + // Load account profile details and available occupation choices. async function loadProfile() { try { const response = await fetch('/api/profile'); @@ -809,6 +813,7 @@ Organization : OptiHK Limited addLog('Password updated.'); }); + // Fetch saved projects and render the dashboard project list. async function loadProjects() { try { const response = await fetch('/api/projects'); @@ -889,6 +894,7 @@ Organization : OptiHK Limited openProject(project.name); }); + // Fetch available foundry/technology choices for new project creation. async function loadTechnologies() { const response = await fetch('/api/technologies'); const data = await response.json(); diff --git a/frontend/login.html b/frontend/login.html index 6c35d89..d209cdf 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -416,6 +416,7 @@ Organization : OptiHK Limited