diff --git a/backend/__pycache__/database.cpython-39.pyc b/backend/__pycache__/database.cpython-39.pyc index 9b21f2c..43fd283 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 41a07ae..7559ede 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__/pdk_access.cpython-39.pyc b/backend/__pycache__/pdk_access.cpython-39.pyc new file mode 100644 index 0000000..b16ac8f Binary files /dev/null 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 cd7c90e..ff29709 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 new file mode 100644 index 0000000..4d0ad2f Binary files /dev/null and b/backend/__pycache__/routed_layout_preview.cpython-39.pyc differ diff --git a/backend/database.py b/backend/database.py index df3d7fd..8396404 100644 --- a/backend/database.py +++ b/backend/database.py @@ -48,6 +48,7 @@ def init_db(): "created_at": "ALTER TABLE users ADD COLUMN created_at TEXT", "credits": "ALTER TABLE users ADD COLUMN credits INTEGER NOT NULL DEFAULT 0", "occupation": "ALTER TABLE users ADD COLUMN occupation TEXT NOT NULL DEFAULT 'intern'", + "user_group": "ALTER TABLE users ADD COLUMN user_group TEXT NOT NULL DEFAULT 'user'", } for column, statement in migrations.items(): if column not in existing_columns: @@ -55,14 +56,17 @@ def init_db(): now = datetime.utcnow().strftime("%Y-%m-%d") cursor.execute("UPDATE users SET created_at = ? WHERE created_at IS NULL OR created_at = ''", (now,)) + cursor.execute("UPDATE users SET user_group = 'manager' WHERE username = 'admin'") + 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. cursor.execute("SELECT * FROM users WHERE username = 'admin'") if not cursor.fetchone(): test_hash = generate_password_hash("123456") cursor.execute( - "INSERT INTO users (username, password_hash, created_at, credits, occupation) VALUES (?, ?, ?, ?, ?)", - ("admin", test_hash, now, 0, "principle engineer") + "INSERT INTO users (username, password_hash, created_at, credits, occupation, user_group) VALUES (?, ?, ?, ?, ?, ?)", + ("admin", test_hash, now, 0, "principle engineer", "manager") ) print("Test user created. Username: admin | Password: 123456") @@ -70,8 +74,8 @@ def init_db(): if not cursor.fetchone(): test_hash = generate_password_hash("123456") cursor.execute( - "INSERT INTO users (username, password_hash, created_at, credits, occupation) VALUES (?, ?, ?, ?, ?)", - ("engineer", test_hash, now, 0, "junior engineer") + "INSERT INTO users (username, password_hash, created_at, credits, occupation, user_group) VALUES (?, ?, ?, ?, ?, ?)", + ("engineer", test_hash, now, 0, "junior engineer", "developers") ) print("Second test user created. Username: engineer | Password: 123456") @@ -81,7 +85,7 @@ def init_db(): def get_user(username): conn = connect_db() cursor = conn.cursor() - cursor.execute("SELECT id, username, password_hash FROM users WHERE username = ?", (username,)) + cursor.execute("SELECT id, username, password_hash, user_group FROM users WHERE username = ?", (username,)) user = cursor.fetchone() conn.close() return user @@ -90,7 +94,7 @@ def get_user_profile(user_id): conn = connect_db() cursor = conn.cursor() cursor.execute( - "SELECT id, username, created_at, credits, occupation FROM users WHERE id = ?", + "SELECT id, username, created_at, credits, occupation, user_group FROM users WHERE id = ?", (user_id,) ) user = cursor.fetchone() diff --git a/backend/gds_builder.py b/backend/gds_builder.py index e47dd13..a61b65c 100644 --- a/backend/gds_builder.py +++ b/backend/gds_builder.py @@ -1,5 +1,6 @@ import math import os +import sys from dataclasses import dataclass, field from typing import Dict, List @@ -16,13 +17,34 @@ class BuildResult: warnings: List[str] = field(default_factory=list) -def build_project_gds(project_dir: str, output_path: str, pdk_public_root: str) -> BuildResult: +def build_project_gds( + project_dir: str, + output_path: str, + pdk_public_root: str, + technology_manifest_path: str = None, + prefer_full_gds: bool = False, +) -> BuildResult: """Build a hierarchical project GDS from saved cell YAML files.""" cells = _load_project_cells(project_dir) if not cells: raise ValueError("No saved cell YAML files found for this project") - registry = PdkRegistry(pdk_public_root) + try: + return _build_with_mxpic_router( + project_dir, + output_path, + pdk_public_root, + technology_manifest_path, + prefer_full_gds, + ) + except ImportError as router_error: + if _cells_have_links(cells): + raise RuntimeError( + "Routed Build GDS requires mxpic_router, nazca, and mxpic_forge when layout links are present. " + f"Router import failed: {router_error}" + ) from router_error + + registry = PdkRegistry(pdk_public_root, prefer_full_gds=prefer_full_gds) os.makedirs(os.path.dirname(output_path), exist_ok=True) try: @@ -37,6 +59,33 @@ def build_project_gds(project_dir: str, output_path: str, pdk_public_root: str) ) from nazca_error +def _build_with_mxpic_router( + project_dir: str, + output_path: str, + pdk_root: str, + technology_manifest_path: str, + prefer_full_gds: bool, +) -> BuildResult: + 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 as build_routed_project_gds + + result = build_routed_project_gds( + project_dir=project_dir, + output_path=output_path, + pdk_root=pdk_root, + technology_manifest_path=technology_manifest_path, + prefer_full_gds=prefer_full_gds, + ) + return BuildResult( + output_path=result.get("output_path", output_path), + engine=result.get("engine", "mxpic_router"), + cells_built=result.get("cells_built", []), + warnings=result.get("warnings", []), + ) + + def _load_project_cells(project_dir: str) -> Dict[str, dict]: cells = {} for filename in sorted(os.listdir(project_dir)): @@ -56,6 +105,14 @@ def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]: return composites + projects +def _cells_have_links(cells: Dict[str, dict]) -> bool: + for data in cells.values(): + for bundle in (data.get("bundles") or {}).values(): + if bundle.get("links"): + return True + return False + + def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: import gdstk diff --git a/backend/pdk_access.py b/backend/pdk_access.py new file mode 100644 index 0000000..3dd4be3 --- /dev/null +++ b/backend/pdk_access.py @@ -0,0 +1,60 @@ +import os +import time +import shutil +import uuid + + +MANAGER_GROUP = "manager" +DEVELOPER_GROUP = "developers" +USER_GROUP = "user" +ALLOWED_GROUPS = {MANAGER_GROUP, DEVELOPER_GROUP, USER_GROUP} + + +def normalize_user_group(user_group: str) -> str: + 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: + group = normalize_user_group(user_group) + if group == MANAGER_GROUP: + return os.path.abspath(os.environ.get( + "MXPIC_PDK_ATLAS_ROOT", + os.path.join(repo_root, "opt_pdk_atlas", "foundries"), + )) + return os.path.abspath(os.environ.get( + "MXPIC_PDK_PUBLIC_ROOT", + os.path.join(repo_root, "opt_pdk_public", "foundries"), + )) + + +def pdk_root_for_session(session_obj, repo_root: str) -> str: + return pdk_root_for_group(session_obj.get("user_group"), repo_root) + + +def prefer_full_gds_for_session(session_obj) -> bool: + 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]: + export_id = uuid.uuid4().hex + filename = f"{project_name}.gds" + export_dir = os.path.abspath(os.path.join(export_root, export_id)) + os.makedirs(export_dir, exist_ok=True) + return export_id, filename, os.path.join(export_dir, filename) + + +def cleanup_expired_exports(export_root: str, max_age_seconds: int = 86400) -> None: + if not os.path.isdir(export_root): + return + now = time.time() + for name in os.listdir(export_root): + path = os.path.join(export_root, name) + if not os.path.isdir(path): + continue + try: + age = now - os.path.getmtime(path) + if age > max_age_seconds: + shutil.rmtree(path, ignore_errors=True) + except OSError: + continue diff --git a/backend/pdk_registry.py b/backend/pdk_registry.py index 3c634c6..fb16d3d 100644 --- a/backend/pdk_registry.py +++ b/backend/pdk_registry.py @@ -16,8 +16,9 @@ class PdkAsset: class PdkRegistry: """Resolve public PDK component names to metadata and public GDS assets.""" - def __init__(self, public_root: str): + def __init__(self, public_root: str, prefer_full_gds: bool = False): self.public_root = os.path.abspath(public_root) + self.prefer_full_gds = prefer_full_gds self._asset_cache = {} def resolve(self, component: str) -> PdkAsset: @@ -60,20 +61,26 @@ class PdkRegistry: def _find_gds(self, key: str, yaml_path: Optional[str]) -> Optional[str]: search_dir = os.path.dirname(yaml_path) if yaml_path else os.path.join(self.public_root, *key.split("/")) name = key.split("/")[-1] - candidates = [ - os.path.join(search_dir, f"{name}_BB.gds"), - os.path.join(search_dir, f"{name}.gds"), - ] + if self.prefer_full_gds: + candidates = [ + os.path.join(search_dir, f"{name}.gds"), + os.path.join(search_dir, f"{name}_BB.gds"), + ] + else: + candidates = [ + os.path.join(search_dir, f"{name}_BB.gds"), + os.path.join(search_dir, f"{name}.gds"), + ] for candidate in candidates: if self._inside_root(candidate) and os.path.exists(candidate): return os.path.abspath(candidate) if os.path.isdir(search_dir): - for filename in sorted(os.listdir(search_dir)): - if filename.lower().endswith("_bb.gds"): - return os.path.join(search_dir, filename) - for filename in sorted(os.listdir(search_dir)): - if filename.lower().endswith(".gds"): - return os.path.join(search_dir, filename) + 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")] + bb_files = [filename for filename in gds_files if filename.lower().endswith("_bb.gds")] + ordered = (full_files + bb_files) if self.prefer_full_gds else (bb_files + full_files) + if ordered: + return os.path.join(search_dir, ordered[0]) return None def _load_yaml(self, yaml_path: Optional[str]) -> Optional[dict]: diff --git a/backend/routed_layout_preview.py b/backend/routed_layout_preview.py new file mode 100644 index 0000000..3ad7249 --- /dev/null +++ b/backend/routed_layout_preview.py @@ -0,0 +1,51 @@ +import os +import sys +import tempfile + +import yaml + + +def create_routed_layout_svg( + yaml_content: str, + output_path: str, + pdk_root: str, + project_dir: str, + technology_manifest_path: str = None, + prefer_full_gds: bool = False, +) -> str: + """Create an SVG preview from routed GDS geometry generated by mxpic_router.""" + import gdstk + + layout = yaml.safe_load(yaml_content) or {} + cell_name = str(layout.get("name") or "layout") + 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) + with tempfile.TemporaryDirectory(prefix="mxpic_routed_preview_") as temp_dir: + temp_gds = os.path.join(temp_dir, f"{cell_name}.gds") + build_project_gds( + project_dir=project_dir, + output_path=temp_gds, + pdk_root=pdk_root, + technology_manifest_path=technology_manifest_path, + prefer_full_gds=prefer_full_gds, + target_cell_name=cell_name, + ) + library = gdstk.read_gds(temp_gds) + top_cells = library.top_level() + if not top_cells: + raise RuntimeError("Routed preview GDS has no top-level cell") + top_cells[0].write_svg(output_path) + return output_path + + +def layout_has_links(yaml_content: str) -> bool: + layout = yaml.safe_load(yaml_content) or {} + for bundle in (layout.get("bundles") or {}).values(): + links = bundle.get("links") or [] + if links: + return True + return False diff --git a/backend/server.py b/backend/server.py index d430cea..c529b71 100644 --- a/backend/server.py +++ b/backend/server.py @@ -13,6 +13,13 @@ from flask import Response from gds_builder import build_project_gds from layout_preview import create_layout_svg_from_gds from pdk_registry import PdkRegistry +from pdk_access import ( + cleanup_expired_exports, + create_export_path, + pdk_root_for_session, + prefer_full_gds_for_session, +) +from routed_layout_preview import create_routed_layout_svg, layout_has_links from technology_manifest import TechnologyManifestError, read_technology_manifest # --- Path Configurations --- @@ -26,13 +33,12 @@ 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') -COMPS_ROOT = os.environ.get('MXPIC_COMPONENT_ROOT', PDK_PUBLIC_ROOT) # Define where your new icons folder is located (adjust if it's placed elsewhere) ICONS_DIR = os.path.join(BASE_DIR, 'icons') #build layout save path DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database')) -PDK_REGISTRY = PdkRegistry(PDK_PUBLIC_ROOT) +EXPORT_ROOT = os.path.abspath(os.path.join(DATABASE_ROOT, '_exports')) app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) @@ -120,6 +126,25 @@ def project_gds_path(project_name): return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds") +def technology_manifest_path_for_project(project_name): + 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")) + if path.startswith(EDA_PDK_ROOT + os.sep) and os.path.exists(path): + return path + return None + + +def current_pdk_root(): + return pdk_root_for_session(session, REPO_ROOT) + + +def current_pdk_registry(): + return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session)) + + def project_meta_path(project_name): return os.path.join(project_root(project_name), ".project.json") @@ -220,9 +245,10 @@ def getIcon(category): # ... [Keep existing API routes below] ... -def readCompYaml(compName): +def readCompYaml(compName, comps_root=None): """Load YAML from component folder.""" - for root, dirs, files in os.walk(COMPS_ROOT): + search_root = comps_root or current_pdk_root() + for root, dirs, files in os.walk(search_root): if os.path.basename(root) == compName: dirs.clear() ymlFiles = [f for f in files if f.endswith('.yml')] @@ -252,6 +278,7 @@ def login(): if user and check_password_hash(user[2], password): session['user_id'] = user[0] session['username'] = user[1] + session['user_group'] = user[3] or 'user' record_action('login') return redirect(url_for('dashboard')) else: @@ -355,6 +382,7 @@ def account_profile(): "created_at": profile[2], "credits": profile[3] or 0, "occupation": profile[4] or "intern", + "user_group": profile[5] or "user", "occupations": sorted(occupations) }) @@ -568,7 +596,17 @@ def save_layout(): f.write(content) svg_path = cell_svg_path(project, cell) - create_layout_svg_from_gds(content, svg_path, pdk_registry=PDK_REGISTRY, project_dir=project_root(project)) + if layout_has_links(content): + create_routed_layout_svg( + content, + svg_path, + pdk_root=current_pdk_root(), + project_dir=project_root(project), + technology_manifest_path=technology_manifest_path_for_project(project), + prefer_full_gds=prefer_full_gds_for_session(session), + ) + else: + create_layout_svg_from_gds(content, svg_path, pdk_registry=current_pdk_registry(), project_dir=project_root(project)) record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path}) return jsonify({ @@ -605,11 +643,18 @@ def build_gds(): data = request.get_json(silent=True) or {} project = safe_name(data.get('project'), 'project_1') try: + cleanup_expired_exports(EXPORT_ROOT) project_dir = ensure_project_path(project) if not os.path.isdir(project_dir): return jsonify({"error": "Project not found"}), 404 - output_path = project_gds_path(project) - result = build_project_gds(project_dir, output_path, PDK_PUBLIC_ROOT) + export_id, filename, output_path = create_export_path(EXPORT_ROOT, safe_name(project, 'project_1')) + result = build_project_gds( + project_dir, + output_path, + current_pdk_root(), + technology_manifest_path=technology_manifest_path_for_project(project), + prefer_full_gds=prefer_full_gds_for_session(session), + ) record_action('gds.build', project=project, detail={ "path": result.output_path, "engine": result.engine, @@ -619,7 +664,8 @@ def build_gds(): "message": "GDS built", "project": project, "path": result.output_path, - "gds_url": url_for('get_project_gds', project_name=project, filename=os.path.basename(result.output_path)), + "filename": filename, + "download_url": url_for('download_export', export_id=export_id, filename=filename), "engine": result.engine, "cells_built": result.cells_built, "warnings": result.warnings @@ -628,6 +674,24 @@ def build_gds(): return jsonify({"error": str(e)}), 500 +@app.route('/api/exports//') +@login_required_json +def download_export(export_id, filename): + 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)) + 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'): + return jsonify({"error": "Invalid GDS filename"}), 400 + gds_path = os.path.abspath(os.path.join(export_dir, safe_filename)) + if not gds_path.startswith(export_dir + os.sep) or not os.path.exists(gds_path): + return jsonify({"error": "GDS export not found"}), 404 + response = send_from_directory(export_dir, safe_filename, as_attachment=True) + response.call_on_close(lambda: shutil.rmtree(export_dir, ignore_errors=True)) + return response + + @app.route('/api/projects//gds/') @login_required_json def get_project_gds(project_name, filename): @@ -649,28 +713,32 @@ def get_project_gds(project_name, filename): # --- API ROUTES (Library & Components) --- @app.route('/api/library') +@login_required_json def getLib(): """Get library structure.""" - # tree = buildTree(YML_PATH) - if os.path.isdir(COMPS_ROOT): - compMap = findComps(COMPS_ROOT) + comps_root = current_pdk_root() + fresh_tree = {} + if os.path.isdir(comps_root): + compMap = findComps(comps_root) fresh_tree = addCompsToTree(compMap) return jsonify(fresh_tree) @app.route('/api/component/') +@login_required_json def getComp(component_name): """Return component YAML data.""" - data = readCompYaml(component_name) + data = readCompYaml(component_name, current_pdk_root()) if data is None: return jsonify({"error": "Component not found"}), 404 return jsonify(data) @app.route('/api/component//image') +@login_required_json def getCompImg(component_name): """Return first image in component folder.""" - for root, dirs, files in os.walk(COMPS_ROOT): + for root, dirs, files in os.walk(current_pdk_root()): if os.path.basename(root) == component_name: dirs.clear() for ext in ('.png', '.jpg', '.jpeg', '.svg'): diff --git a/database/_exports/5ef2a587264f41f6a8b76bdefec48d62/mxpic_project_1.gds b/database/_exports/5ef2a587264f41f6a8b76bdefec48d62/mxpic_project_1.gds new file mode 100644 index 0000000..9262106 Binary files /dev/null and b/database/_exports/5ef2a587264f41f6a8b76bdefec48d62/mxpic_project_1.gds differ diff --git a/database/admin/layout/mxpic_project_1/canvas_1.gds b/database/admin/layout/mxpic_project_1/canvas_1.gds deleted file mode 100644 index 4b649bf..0000000 Binary files a/database/admin/layout/mxpic_project_1/canvas_1.gds and /dev/null differ diff --git a/database/admin/layout/mxpic_project_1/canvas_1.svg b/database/admin/layout/mxpic_project_1/canvas_1.svg deleted file mode 100644 index de15775..0000000 --- a/database/admin/layout/mxpic_project_1/canvas_1.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - -a0 -b0 -a1 -b1 - - - - - - - - - -a1 -a2 -b1 -b2 -a0 -b0 - - - - - - - - \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/canvas_1.yml b/database/admin/layout/mxpic_project_1/canvas_1.yml deleted file mode 100644 index 53a2753..0000000 --- a/database/admin/layout/mxpic_project_1/canvas_1.yml +++ /dev/null @@ -1,61 +0,0 @@ -# ============================================= -# mxPIC Cell/Project Definition File -# ============================================= -schema_version: "2.0.0" -kind: cell -project: mxpic_project_1 -name: canvas_1 -type: composite -version: "1.0.0" - -# 1. External Ports (How this cell connects to the outside world) -ports: -- name: port - layer: WG_CORE - x: 0.0 - y: 0.0 - angle: 0.0 - width: 0.5 - -# 2. Instances (The sub-components dropped onto this canvas) -instances: - component_5: - component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/bendings/Si_EUB_1310_H220_w2000_L50_QY_202604 - x: 570.0 - y: 320.0 - rotation: 0.0 - mirror: false - settings: - length: - - component_6: - component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY - x: 244.0 - y: 257.0 - rotation: 0.0 - mirror: false - settings: - length: - -elements: - port: - type: port - x: 0.0 - y: 0.0 - angle: 0.0 - layer: WG_CORE - width: 0.5 - description: "" - -# 3. Bundles (Grouped links for multi-bus/parallel routing) -bundles: - output_bus: - routing_type: euler_bend - links: - - from: component_5:a1 - to: component_6:b2 - xsection: strip - family: optical - width: 0.45 - radius: 10 - routing_type: euler_bend \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.gds b/database/admin/layout/mxpic_project_1/mxpic_project_1.gds deleted file mode 100644 index 126b13a..0000000 Binary files a/database/admin/layout/mxpic_project_1/mxpic_project_1.gds and /dev/null differ diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg deleted file mode 100644 index 35c69f8..0000000 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - -a0 -b0 -a1 -b1 - - - - - - - - - -a1 -a2 -b1 -b2 -a0 -b0 - - - - - - - - \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml index 086024c..da29e37 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml @@ -9,34 +9,69 @@ type: project version: "1.0.0" # 1. External Ports (How this cell connects to the outside world) -ports: [] +ports: +- name: port + layer: WG_CORE + x: 50.0 + y: 150.0 + angle: 0.0 + width: 0.5 # 2. Instances (The sub-components dropped onto this canvas) instances: - canvas_1: - component: canvas_1 - x: 390.0 - y: 290.0 - rotation: 0.0 - mirror: false - settings: - length: - - component_7: + component_1: component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY - x: 840.0 - y: 290.0 + x: 300.0 + y: 440.0 rotation: 0.0 mirror: false settings: length: -elements: {} + component_2: + component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303 + x: 820.0 + y: 250.0 + rotation: 0.0 + mirror: false + settings: + length: + + component_3: + component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303 + x: 820.0 + y: 660.0 + rotation: 0.0 + mirror: false + settings: + length: + +elements: + port: + type: port + x: 50.0 + y: 150.0 + angle: 0.0 + layer: WG_CORE + width: 0.5 + description: "" # 3. Bundles (Grouped links for multi-bus/parallel routing) bundles: output_bus: routing_type: euler_bend links: - - from: component_7:a1 - to: canvas_1:port \ No newline at end of file + - from: component_2:g2b + to: component_1:b1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: component_1:b2 + to: component_3:g2b + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend \ No newline at end of file diff --git a/database/mxpic_data.db b/database/mxpic_data.db index 1c0ede1..37b007d 100644 Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ diff --git a/frontend/canvas.html b/frontend/canvas.html index eb8ecde..cd49e5d 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -3948,7 +3948,16 @@ ${bundlesBlock}`; const warningText = result.warnings && result.warnings.length > 0 ? ` (${result.warnings.length} warnings)` : ''; - addLog(`GDS built with ${result.engine}: ${result.path}${warningText}`); + if (result.download_url) { + const link = document.createElement('a'); + link.href = result.download_url; + link.download = result.filename || `${currentProjectName}.gds`; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + } + addLog(`GDS built with ${result.engine}: ${result.filename || result.path}${warningText}`); } catch (err) { addLog(`Build GDS network error: ${err.message}. Check that the Flask server is running from the same host and Python environment.`); } finally { diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 00e7da4..a16fa7e 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -608,6 +608,10 @@
0
+
+ +
-
+
@@ -694,6 +698,7 @@ const profileUsername = document.getElementById('profile-username'); const profileCreated = document.getElementById('profile-created'); const profileCredits = document.getElementById('profile-credits'); + const profileGroup = document.getElementById('profile-group'); const profileOccupation = document.getElementById('profile-occupation'); const themeToggle = document.getElementById('theme-toggle'); const logTerminal = document.getElementById('log-terminal'); @@ -733,6 +738,7 @@ profileUsername.textContent = profile.username; profileCreated.textContent = profile.created_at || '-'; profileCredits.textContent = profile.credits ?? 0; + profileGroup.textContent = profile.user_group || 'user'; profileOccupation.innerHTML = ''; (profile.occupations || []).forEach(occupation => { const option = document.createElement('option'); diff --git a/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml b/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml index a05fcd7..444aa66 100644 --- a/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml +++ b/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml @@ -13,6 +13,7 @@ layers: WG_HM: {layer: 275, datatype: 0} WG_STRIP: {layer: 101, datatype: 251} WG_LOWRIB: {layer: 100, datatype: 90} + WG_SRIB: {layer: 100, datatype: 90} WG_HIGHRIB: {layer: 232, datatype: 0} HEATER: {layer: 29, datatype: 30} CT_SI: {layer: 268, datatype: 0} diff --git a/tests/account-pdk-access-static.test.js b/tests/account-pdk-access-static.test.js new file mode 100644 index 0000000..bd78c8e --- /dev/null +++ b/tests/account-pdk-access-static.test.js @@ -0,0 +1,46 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const backend = path.join(root, 'backend'); +const databasePy = fs.readFileSync(path.join(backend, 'database.py'), 'utf8'); +const serverPy = fs.readFileSync(path.join(backend, 'server.py'), 'utf8'); +const dashboardHtml = fs.readFileSync(path.join(root, 'frontend', 'dashboard.html'), 'utf8'); + +assert( + databasePy.includes('user_group'), + 'database migration should add users.user_group' +); +assert( + databasePy.includes("'admin'") && databasePy.includes("'manager'"), + 'admin should be migrated/seeded as manager' +); +assert( + databasePy.includes("'engineer'") && databasePy.includes("'developers'"), + 'engineer should be migrated/seeded as developers' +); +assert( + serverPy.includes("session['user_group']") || serverPy.includes('session["user_group"]'), + 'login should store user_group in the session' +); +assert( + serverPy.includes('"user_group"'), + '/api/profile should return user_group' +); +assert( + fs.existsSync(path.join(backend, 'pdk_access.py')), + 'backend/pdk_access.py should resolve role-based PDK roots' +); +assert( + serverPy.includes('pdk_root_for_session'), + 'server should resolve PDK root per logged-in user group' +); +assert( + dashboardHtml.includes('profile-group'), + 'dashboard should show read-only profile group information' +); +assert( + !dashboardHtml.includes('profile-group">/'"), + 'server should expose an authenticated export download route' +); +assert( + serverPy.includes('download_url'), + 'Build GDS response should include download_url' +); +assert( + serverPy.includes('cleanup_expired_exports'), + 'server should clean exports older than the retention period' +); +assert( + canvasHtml.includes('download_url'), + 'frontend should read download_url from Build GDS response' +); +assert( + canvasHtml.includes('document.createElement') && canvasHtml.includes('.download'), + 'frontend should trigger a browser download for generated GDS' +); diff --git a/tests/layout-backend-static.test.js b/tests/layout-backend-static.test.js index 96d6d4e..a898efa 100644 --- a/tests/layout-backend-static.test.js +++ b/tests/layout-backend-static.test.js @@ -18,10 +18,18 @@ assert( fs.existsSync(path.join(backendDir, 'gds_builder.py')), 'backend/gds_builder.py should build hierarchical GDS from saved project YAML' ); +assert( + fs.existsSync(path.join(backendDir, 'routed_layout_preview.py')), + 'backend/routed_layout_preview.py should create routed SVG previews through mxpic_router' +); assert( serverPy.includes('create_layout_svg_from_gds'), 'save-layout route should create a GDS-derived layout SVG preview' ); +assert( + serverPy.includes('create_routed_layout_svg'), + 'save-layout route should use routed preview generation when links exist' +); assert( serverPy.includes('svg_url'), 'save-layout response should include an svg_url for the new layout tab' @@ -64,3 +72,9 @@ assert( layoutPreviewPy.includes('_BB.gds') || layoutPreviewPy.includes('gds_path'), 'layout_preview.py should resolve public GDS assets for placed components' ); + +const gdsBuilderPy = fs.readFileSync(path.join(backendDir, 'gds_builder.py'), 'utf8'); +assert( + gdsBuilderPy.includes('_cells_have_links') && gdsBuilderPy.includes('Routed Build GDS requires mxpic_router'), + 'Build GDS should not silently fall back to unrouted gdstk when links are present' +);