CODEX revised with following function: 1. GDS building, 2. different user group with different authority.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
-6
@@ -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()
|
||||
|
||||
+59
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+18
-11
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
+81
-13
@@ -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/<export_id>/<filename>')
|
||||
@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/<project_name>/gds/<filename>')
|
||||
@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/<component_name>')
|
||||
@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/<component_name>/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'):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="5048.835" height="1611.555" viewBox="1290.0075 -3803.8125 5048.835 1611.555">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
.l1200d0 {stroke: #F38400; fill: #F38400; fill-opacity: 0.5;}
|
||||
.l1205d0 {stroke: #008856; fill: #008856; fill-opacity: 0.5;}
|
||||
.l1001t0 {stroke: none; fill: #A1CAF1;}
|
||||
</style>
|
||||
<g id="Si_EUB_1310_H220_w2000_L50_QY_202604">
|
||||
<polygon id="0000028EA5D10720" class="l1200d0" points="0,-47.54 409.35,-47.54 409.35,361.82 0,361.82"/>
|
||||
<polygon id="0000028EA5D102C0" class="l1205d0" points="12.5,12.5 -12.5,12.5 -12.5,-12.5 12.5,-12.5"/>
|
||||
<polygon id="0000028EA5D103A0" class="l1205d0" points="374.31,349.32 374.31,374.32 349.31,374.32 349.31,349.32"/>
|
||||
<polygon id="0000028EA5D10410" class="l1205d0" points="12.5,12.5 -12.5,12.5 -12.5,-12.5 12.5,-12.5"/>
|
||||
<polygon id="0000028EA81CD1D0" class="l1205d0" points="374.31,349.32 374.31,374.32 349.31,374.32 349.31,349.32"/>
|
||||
<text id="0000028EA5D7B970" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a0</text>
|
||||
<text id="0000028EA5D7BBB0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(361.81 361.82) scale(1 -1)">b0</text>
|
||||
<text id="0000028EA5D7BE80" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a1</text>
|
||||
<text id="0000028EA5D7D590" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(361.81 361.82) scale(1 -1)">b1</text>
|
||||
</g>
|
||||
<g id="2x2MMI_1310nm_TE_Silterra_202603_ZKY">
|
||||
<polygon id="0000028EA81CD4E0" class="l1200d0" points="-917,-148.25 917,-148.25 917,148.25 -917,148.25"/>
|
||||
<polygon id="0000028EA81CD940" class="l1205d0" points="-913.5,48.25 -920.5,48.25 -920.5,41.25 -913.5,41.25"/>
|
||||
<polygon id="0000028EA81CCDE0" class="l1205d0" points="-913.5,-41.25 -920.5,-41.25 -920.5,-48.25 -913.5,-48.25"/>
|
||||
<polygon id="0000028EA81CD320" class="l1205d0" points="913.5,41.25 920.5,41.25 920.5,48.25 913.5,48.25"/>
|
||||
<polygon id="0000028EA81CCEC0" class="l1205d0" points="913.5,-48.25 920.5,-48.25 920.5,-41.25 913.5,-41.25"/>
|
||||
<polygon id="0000028EA81CDC50" class="l1205d0" points="0,0 0,0 0,0 0,0"/>
|
||||
<polygon id="0000028EA81CD080" class="l1205d0" points="0,0 0,0 0,0 0,0"/>
|
||||
<text id="0000028EA5D7B7C0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(-917 44.75) scale(1 -1)">a1</text>
|
||||
<text id="0000028EA5D7CED0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(-917 -44.75) scale(1 -1)">a2</text>
|
||||
<text id="0000028EA5D7BC40" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(917 44.75) scale(1 -1)">b1</text>
|
||||
<text id="0000028EA5D7C4B0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(917 -44.75) scale(1 -1)">b2</text>
|
||||
<text id="0000028EA5D7C420" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a0</text>
|
||||
<text id="0000028EA5D7C390" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">b0</text>
|
||||
</g>
|
||||
</defs>
|
||||
<rect x="1290.0075" y="-3803.8125" width="5048.835" height="1611.555" fill="#222222" stroke="none"/>
|
||||
<g id="canvas_1" transform="scale(1 -1)">
|
||||
<use transform="translate(5700 3200)" xlink:href="#Si_EUB_1310_H220_w2000_L50_QY_202604"/>
|
||||
<use transform="translate(2440 2570)" xlink:href="#2x2MMI_1310nm_TE_Silterra_202603_ZKY"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -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
|
||||
Binary file not shown.
@@ -1,45 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="4982.835" height="3175.555" viewBox="6753.0075 -5700.8125 4982.835 3175.555">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
.l1200d0 {stroke: #F38400; fill: #F38400; fill-opacity: 0.5;}
|
||||
.l1205d0 {stroke: #008856; fill: #008856; fill-opacity: 0.5;}
|
||||
.l1001t0 {stroke: none; fill: #A1CAF1;}
|
||||
</style>
|
||||
<g id="canvas_1">
|
||||
<use transform="translate(7200 2200)" xlink:href="#Si_EUB_1310_H220_w2000_L50_QY_202604"/>
|
||||
<use transform="translate(4000 1900)" xlink:href="#2x2MMI_1310nm_TE_Silterra_202603_ZKY"/>
|
||||
</g>
|
||||
<g id="Si_EUB_1310_H220_w2000_L50_QY_202604">
|
||||
<polygon id="0000019DC7BDA230" class="l1200d0" points="0,-47.54 409.35,-47.54 409.35,361.82 0,361.82"/>
|
||||
<polygon id="0000019DC7BDA310" class="l1205d0" points="12.5,12.5 -12.5,12.5 -12.5,-12.5 12.5,-12.5"/>
|
||||
<polygon id="0000019DC7BDA460" class="l1205d0" points="374.31,349.32 374.31,374.32 349.31,374.32 349.31,349.32"/>
|
||||
<polygon id="0000019DC7BDA4D0" class="l1205d0" points="12.5,12.5 -12.5,12.5 -12.5,-12.5 12.5,-12.5"/>
|
||||
<polygon id="0000019DCA15A120" class="l1205d0" points="374.31,349.32 374.31,374.32 349.31,374.32 349.31,349.32"/>
|
||||
<text id="0000019DC9DB67A0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a0</text>
|
||||
<text id="0000019DC9DB5990" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(361.81 361.82) scale(1 -1)">b0</text>
|
||||
<text id="0000019DC9DB64D0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a1</text>
|
||||
<text id="0000019DC9DB5A20" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(361.81 361.82) scale(1 -1)">b1</text>
|
||||
</g>
|
||||
<g id="2x2MMI_1310nm_TE_Silterra_202603_ZKY">
|
||||
<polygon id="0000019DCA15A270" class="l1200d0" points="-917,-148.25 917,-148.25 917,148.25 -917,148.25"/>
|
||||
<polygon id="0000019DCA159DA0" class="l1205d0" points="-913.5,48.25 -920.5,48.25 -920.5,41.25 -913.5,41.25"/>
|
||||
<polygon id="0000019DCA159F60" class="l1205d0" points="-913.5,-41.25 -920.5,-41.25 -920.5,-48.25 -913.5,-48.25"/>
|
||||
<polygon id="0000019DCA15A200" class="l1205d0" points="913.5,41.25 920.5,41.25 920.5,48.25 913.5,48.25"/>
|
||||
<polygon id="0000019DCA15A2E0" class="l1205d0" points="913.5,-48.25 920.5,-48.25 920.5,-41.25 913.5,-41.25"/>
|
||||
<polygon id="0000019DCA15A350" class="l1205d0" points="0,0 0,0 0,0 0,0"/>
|
||||
<polygon id="0000019DCA15A4A0" class="l1205d0" points="0,0 0,0 0,0 0,0"/>
|
||||
<text id="0000019DC9DB6050" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(-917 44.75) scale(1 -1)">a1</text>
|
||||
<text id="0000019DC9DB6C20" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(-917 -44.75) scale(1 -1)">a2</text>
|
||||
<text id="0000019DC9DB7130" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(917 44.75) scale(1 -1)">b1</text>
|
||||
<text id="0000019DC9DB5EA0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(917 -44.75) scale(1 -1)">b2</text>
|
||||
<text id="0000019DC9DB71C0" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">a0</text>
|
||||
<text id="0000019DC9DB7400" class="l1001t0" text-anchor="middle" dominant-baseline="central" transform="translate(0 0) scale(1 -1)">b0</text>
|
||||
</g>
|
||||
</defs>
|
||||
<rect x="6753.0075" y="-5700.8125" width="4982.835" height="3175.555" fill="#222222" stroke="none"/>
|
||||
<g id="mxpic_project_1" transform="scale(1 -1)">
|
||||
<use transform="translate(3900 2900)" xlink:href="#canvas_1"/>
|
||||
<use transform="translate(8400 2900)" xlink:href="#2x2MMI_1310nm_TE_Silterra_202603_ZKY"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -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
|
||||
- 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
|
||||
Binary file not shown.
+10
-1
@@ -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 {
|
||||
|
||||
@@ -608,6 +608,10 @@
|
||||
<label>Credits</label>
|
||||
<div class="profile-value" id="profile-credits">0</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<label>User Group</label>
|
||||
<div class="profile-value" id="profile-group">-</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<label>Occupation</label>
|
||||
<select id="profile-occupation"></select>
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"></select'),
|
||||
'profile group must not be editable'
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const serverPy = fs.readFileSync(path.join(root, 'backend', 'server.py'), 'utf8');
|
||||
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
|
||||
|
||||
assert(
|
||||
serverPy.includes('EXPORT_ROOT'),
|
||||
'server should build GDS exports into a temporary export root'
|
||||
);
|
||||
assert(
|
||||
serverPy.includes("@app.route('/api/exports/<export_id>/<filename>'"),
|
||||
'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'
|
||||
);
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user