This commit is contained in:
2026-05-28 17:53:41 +08:00
parent 48555f5686
commit e6e9e13cf2
22 changed files with 1743 additions and 186 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
import math
import os
from dataclasses import dataclass, field
from typing import Dict, List
import yaml
from pdk_registry import PdkRegistry
@dataclass
class BuildResult:
output_path: str
engine: str
cells_built: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
def build_project_gds(project_dir: str, output_path: str, pdk_public_root: str) -> 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)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
try:
return _build_with_gdstk(cells, output_path, registry)
except ImportError as gdstk_error:
try:
return _build_with_nazca(cells, output_path, registry)
except ImportError as nazca_error:
raise RuntimeError(
"Build GDS requires either gdstk or a working nazca installation. "
f"gdstk import failed: {gdstk_error}. nazca import failed: {nazca_error}"
) from nazca_error
def _load_project_cells(project_dir: str) -> Dict[str, dict]:
cells = {}
for filename in sorted(os.listdir(project_dir)):
if not filename.lower().endswith((".yml", ".yaml")):
continue
path = os.path.join(project_dir, filename)
with open(path, "r", encoding="utf-8") as file:
data = yaml.safe_load(file) or {}
cell_name = str(data.get("name") or os.path.splitext(filename)[0])
cells[cell_name] = data
return cells
def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]:
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 _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
import gdstk
library = gdstk.Library()
built_cells = {}
warnings = []
for cell_name in _ordered_cell_names(cells):
data = cells[cell_name]
gds_cell = library.new_cell(_safe_cell_name(cell_name, built_cells))
built_cells[cell_name] = gds_cell
for instance_name, instance in (data.get("instances") or {}).items():
component = str(instance.get("component") or "")
x = _number(instance.get("x"))
y = _number(instance.get("y"))
rotation = math.radians(_number(instance.get("rotation")))
child = built_cells.get(component)
if child is None:
asset = registry.resolve(component)
if not asset.gds_path:
warnings.append(f"Missing GDS for {instance_name}: {component}")
continue
child = _import_public_gds(gdstk, library, asset.gds_path)
gds_cell.add(gdstk.Reference(child, origin=(x, y), rotation=rotation))
library.write_gds(output_path)
return BuildResult(
output_path=output_path,
engine="gdstk",
cells_built=list(built_cells.keys()),
warnings=warnings,
)
def _import_public_gds(gdstk, library, gds_path: str):
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}")
for source_cell in source.cells:
if _library_cell_by_name(library, source_cell.name) is None:
library.add(source_cell)
return top_cells[0]
def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
import nazca as nd
warnings = []
built_cells = {}
ordered_names = _ordered_cell_names(cells)
for cell_name in ordered_names:
data = cells[cell_name]
with nd.Cell(cell_name) as current_cell:
for instance_name, instance in (data.get("instances") or {}).items():
component = str(instance.get("component") or "")
x = _number(instance.get("x"))
y = _number(instance.get("y"))
rotation = _number(instance.get("rotation"))
if component in built_cells:
built_cells[component].put(x, y, rotation)
continue
asset = registry.resolve(component)
if not asset.gds_path:
warnings.append(f"Missing GDS for {instance_name}: {component}")
continue
loaded = nd.load_gds(asset.gds_path)
loaded.put(x, y, rotation)
built_cells[cell_name] = current_cell
top_name = ordered_names[-1]
nd.export_gds(built_cells[top_name], filename=output_path)
return BuildResult(output_path=output_path, engine="nazca", cells_built=ordered_names, warnings=warnings)
def _safe_cell_name(name: str, existing: dict) -> str:
base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "cell"
candidate = base
counter = 1
used = {cell.name for cell in existing.values()}
while candidate in used:
counter += 1
candidate = f"{base}_{counter}"
return candidate
def _library_cell_by_name(library, name: str):
for cell in library.cells:
if cell.name == name:
return cell
return None
def _number(value, default=0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
+131
View File
@@ -0,0 +1,131 @@
import os
from typing import Dict, Optional
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:
return _create_with_gdstk(layout, output_path, pdk_registry, project_dir)
except ImportError as gdstk_error:
try:
return _create_with_nazca(layout, output_path, pdk_registry, project_dir)
except ImportError as nazca_error:
raise RuntimeError(
"Layout SVG requires GDS geometry support. Install gdstk, or fix nazca dependencies. "
f"gdstk import failed: {gdstk_error}. nazca import failed: {nazca_error}"
) from nazca_error
def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str:
import gdstk
library = gdstk.Library()
cell_cache = {}
top = _build_gdstk_cell(gdstk, library, layout, pdk_registry, project_dir, cell_cache)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
top.write_svg(output_path)
return output_path
def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: Optional[str], cell_cache: Dict):
cell_name = _safe_cell_name(layout.get("name") or "layout", library)
top = library.new_cell(cell_name)
for instance_name, instance in (layout.get("instances") or {}).items():
component = str(instance.get("component") or "")
x = _number(instance.get("x"))
y = _number(instance.get("y"))
rotation = _number(instance.get("rotation")) * 3.141592653589793 / 180
child = _resolve_child_cell(gdstk, library, component, pdk_registry, project_dir, cell_cache)
if child is None:
raise FileNotFoundError(f"Unable to resolve _BB.gds for instance {instance_name}: {component}")
top.add(gdstk.Reference(child, origin=(x, y), rotation=rotation))
return top
def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_dir: Optional[str], cell_cache: Dict):
if component in cell_cache:
return cell_cache[component]
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)
cell_cache[component] = child
return child
asset = pdk_registry.resolve(component)
if not asset.gds_path:
return None
child = _import_gds_cell(gdstk, library, asset.gds_path)
cell_cache[component] = child
return child
def _import_gds_cell(gdstk, library, gds_path: str):
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}")
for source_cell in source.cells:
if _library_cell_by_name(library, source_cell.name) is None:
library.add(source_cell)
return top_cells[0]
def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str:
import nazca as nd
png_path = os.path.splitext(output_path)[0] + ".gds"
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 "")
asset = pdk_registry.resolve(component)
if not asset.gds_path:
raise FileNotFoundError(f"Unable to resolve _BB.gds for instance {instance_name}: {component}")
loaded = nd.load_gds(asset.gds_path)
loaded.put(_number(instance.get("x")), _number(instance.get("y")), _number(instance.get("rotation")))
nd.export_gds(top, filename=png_path)
raise RuntimeError(
"Nazca can build the placed GDS, but SVG preview export requires gdstk in this backend."
)
def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[dict]:
if not project_dir or "/" in component or "\\" in component or component == "generate_with_forge":
return None
for ext in (".yml", ".yaml"):
path = os.path.join(project_dir, f"{component}{ext}")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
return None
def _safe_cell_name(name, library) -> str:
base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "layout"
candidate = base
counter = 1
while _library_cell_by_name(library, candidate) is not None:
counter += 1
candidate = f"{base}_{counter}"
return candidate
def _library_cell_by_name(library, name: str):
for cell in library.cells:
if cell.name == name:
return cell
return None
def _number(value, default=0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
+87
View File
@@ -0,0 +1,87 @@
import os
from dataclasses import dataclass
from typing import Optional
import yaml
@dataclass
class PdkAsset:
component: str
yaml_path: Optional[str] = None
gds_path: Optional[str] = None
metadata: Optional[dict] = None
class PdkRegistry:
"""Resolve public PDK component names to metadata and public GDS assets."""
def __init__(self, public_root: str):
self.public_root = os.path.abspath(public_root)
self._asset_cache = {}
def resolve(self, component: str) -> PdkAsset:
key = (component or "").strip().replace("\\", "/").strip("/")
if not key:
return PdkAsset(component=component)
if key in self._asset_cache:
return self._asset_cache[key]
yaml_path = self._find_yaml(key)
gds_path = self._find_gds(key, yaml_path)
metadata = self._load_yaml(yaml_path) if yaml_path else None
asset = PdkAsset(component=component, yaml_path=yaml_path, gds_path=gds_path, metadata=metadata)
self._asset_cache[key] = asset
return asset
def _find_yaml(self, key: str) -> Optional[str]:
direct = os.path.join(self.public_root, *key.split("/"))
candidates = []
if direct.lower().endswith((".yml", ".yaml")):
candidates.append(direct)
else:
name = key.split("/")[-1]
candidates.append(os.path.join(direct, f"{name}.yml"))
candidates.append(os.path.join(direct, f"{name}.yaml"))
for candidate in candidates:
if self._inside_root(candidate) and os.path.exists(candidate):
return os.path.abspath(candidate)
name = key.split("/")[-1]
for root, dirs, files in os.walk(self.public_root):
if os.path.basename(root) == name:
for filename in files:
if filename.lower().endswith((".yml", ".yaml")):
return os.path.join(root, filename)
dirs.clear()
return None
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"),
]
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)
return None
def _load_yaml(self, yaml_path: Optional[str]) -> Optional[dict]:
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:
target = os.path.abspath(path)
return target == self.public_root or target.startswith(self.public_root + os.sep)
+112 -4
View File
@@ -10,18 +10,29 @@ from flask import Flask, jsonify, send_from_directory, request, redirect, url_fo
from werkzeug.security import check_password_hash
import database
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 technology_manifest import TechnologyManifestError, read_technology_manifest
# --- Path Configurations ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend')
REPO_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', '..'))
PDK_PUBLIC_ROOT = os.path.abspath(os.environ.get(
'MXPIC_PDK_PUBLIC_ROOT',
os.path.join(REPO_ROOT, 'opt_pdk_public', 'foundries')
))
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.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra')
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)
app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR)
@@ -101,6 +112,14 @@ def cell_file_path(project_name, cell_name):
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 os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg")
def project_gds_path(project_name):
return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds")
def project_meta_path(project_name):
return os.path.join(project_root(project_name), ".project.json")
@@ -118,6 +137,14 @@ def write_project_meta(project_name, meta):
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):
layout_root = os.path.abspath(user_layout_root())
target = os.path.abspath(project_root(project_name))
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] ...
def findComps(baseDir):
@@ -271,7 +298,7 @@ def health_check():
def list_technologies():
"""List technology choices from mxpic/PDKs/<foundry>/<technology>."""
technologies = []
pdks_root = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs')
pdks_root = EDA_PDK_ROOT
if os.path.isdir(pdks_root):
for foundry in sorted(os.listdir(pdks_root)):
foundry_path = os.path.join(pdks_root, foundry)
@@ -290,6 +317,20 @@ def list_technologies():
return jsonify({"technologies": technologies})
@app.route('/api/technologies/<foundry>/<technology>/manifest', methods=['GET'])
@login_required_json
def get_technology_manifest(foundry, technology):
try:
manifest = read_technology_manifest(
EDA_PDK_ROOT,
safe_name(foundry, ''),
safe_name(technology, '')
)
return jsonify({"manifest": manifest})
except TechnologyManifestError as e:
return jsonify({"error": str(e)}), 404
@app.route('/api/profile', methods=['GET', 'PATCH'])
@login_required_json
def account_profile():
@@ -526,18 +567,85 @@ def save_layout():
with open(save_path, 'w', encoding='utf-8') as f:
f.write(content)
record_action('layout.save', project=project, cell=cell, detail={"bytes": len(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))
record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path})
return jsonify({
"message": "successfully saved",
"project": project,
"cell": cell,
"path": save_path
"path": save_path,
"svg_path": svg_path,
"svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell)
}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/projects/<project_name>/cells/<cell_name>/layout.svg')
@login_required_json
def get_layout_svg(project_name, cell_name):
try:
project_dir = ensure_project_path(project_name)
svg_path = os.path.abspath(cell_svg_path(project_name, cell_name))
if not svg_path.startswith(project_dir + os.sep):
return jsonify({"error": "Invalid SVG path"}), 400
if not os.path.exists(svg_path):
return jsonify({"error": "Layout SVG not found"}), 404
return no_cache_response(send_from_directory(os.path.dirname(svg_path), os.path.basename(svg_path), mimetype='image/svg+xml'))
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.route('/api/build-gds', methods=['POST'])
@login_required_json
def build_gds():
data = request.get_json(silent=True) or {}
project = safe_name(data.get('project'), 'project_1')
try:
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)
record_action('gds.build', project=project, detail={
"path": result.output_path,
"engine": result.engine,
"warnings": result.warnings
})
return jsonify({
"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)),
"engine": result.engine,
"cells_built": result.cells_built,
"warnings": result.warnings
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/projects/<project_name>/gds/<filename>')
@login_required_json
def get_project_gds(project_name, filename):
try:
project_dir = ensure_project_path(project_name)
safe_filename = safe_name(filename, f"{safe_name(project_name, 'project_1')}.gds")
if not safe_filename.lower().endswith('.gds'):
return jsonify({"error": "Invalid GDS filename"}), 400
gds_path = os.path.abspath(os.path.join(project_dir, safe_filename))
if not gds_path.startswith(project_dir + os.sep):
return jsonify({"error": "Invalid GDS path"}), 400
if not os.path.exists(gds_path):
return jsonify({"error": "GDS not found"}), 404
return send_from_directory(project_dir, safe_filename, as_attachment=True)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# --- API ROUTES (Library & Components) ---
@app.route('/api/library')
+28
View File
@@ -0,0 +1,28 @@
import os
import yaml
class TechnologyManifestError(Exception):
pass
def technology_manifest_path(pdks_root: str, foundry: str, technology: str) -> str:
base = os.path.abspath(pdks_root)
path = os.path.abspath(os.path.join(base, foundry, technology, "technology.yml"))
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:
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 {}
if not isinstance(manifest.get("xsections"), dict):
raise TechnologyManifestError("technology manifest is missing xsections")
if not isinstance(manifest.get("defaults"), dict):
raise TechnologyManifestError("technology manifest is missing defaults")
return manifest
Binary file not shown.
@@ -0,0 +1,41 @@
<?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>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,61 @@
# =============================================
# 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
@@ -0,0 +1,45 @@
<?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>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -9,67 +9,34 @@ type: project
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
ports:
- name: port_3
layer: WG_CORE
x: 359.0
y: 447.0
angle: 0.0
width: 0.5
- name: component_4
layer: WG_CORE
x: 366.0
y: 615.0
angle: 0.0
width: 0.5
ports: []
# 2. Instances (The sub-components dropped onto this canvas)
instances:
component_2:
component: EMO1_2ML_CU_Al_RDL/composite/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303
x: 799.0
y: 420.0
canvas_1:
component: canvas_1
x: 390.0
y: 290.0
rotation: 0.0
mirror: false
settings:
length:
elements:
anchor_1:
type: anchor
x: 479.0
y: 503.0
angle: 0.0
layer: WG_CORE
width: 0.5
description: ""
port_3:
type: port
x: 359.0
y: 447.0
angle: 0.0
layer: WG_CORE
width: 0.5
description: ""
component_4:
type: port
x: 366.0
y: 615.0
angle: 0.0
layer: WG_CORE
width: 0.5
description: ""
component_7:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY
x: 840.0
y: 290.0
rotation: 0.0
mirror: false
settings:
length:
elements: {}
# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
- from: anchor_1:right
to: component_2:s1b
- from: anchor_1:left
to: port_3:port_3
- from: component_2:s1b
to: component_2:s1b
- from: component_2:g2b
to: component_4:component_4
- from: component_7:a1
to: canvas_1:port
Binary file not shown.
+170
View File
@@ -43,11 +43,99 @@
notes: ''
};
const FALLBACK_TECHNOLOGY_MANIFEST = {
routing_types: ['euler_bend', 'standard_bend'],
defaults: {
xsection: 'strip',
family: 'optical',
width: 0.45,
radius: 10,
routing_type: 'euler_bend'
},
xsections: {
strip: { family: 'optical', default_width: 0.45, default_radius: 10 },
rib_low: { family: 'optical', default_width: 0.45, default_radius: 10 },
metal_1: { family: 'electrical', default_width: 5, default_radius: 10 },
metal_2: { family: 'electrical', default_width: 5, default_radius: 10 }
}
};
const createForgeArguments = (overrides) => ({
...DEFAULT_FORGE_ARGUMENTS,
...(overrides || {})
});
const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST;
const getXsectionInfo = (xsection, manifest) => {
const technology = getTechnologyManifest(manifest);
return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {};
};
const createRouteSettings = (manifest, overrides) => {
const technology = getTechnologyManifest(manifest);
const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults;
const xsection = (overrides && overrides.xsection) || defaults.xsection || 'strip';
const xsectionInfo = getXsectionInfo(xsection, technology);
const family = (overrides && overrides.family) || xsectionInfo.family || defaults.family || 'optical';
return {
xsection,
family,
width: Number((overrides && overrides.width) ?? xsectionInfo.default_width ?? defaults.width ?? 0.45),
radius: Number((overrides && overrides.radius) ?? xsectionInfo.default_radius ?? defaults.radius ?? 10),
routing_type: (overrides && overrides.routing_type) || defaults.routing_type || 'euler_bend',
widthEdited: Boolean(overrides && overrides.widthEdited)
};
};
const updateRouteField = (route, key, value, manifest) => {
const current = createRouteSettings(manifest, route);
const numericFields = new Set(['width', 'radius']);
const nextValue = numericFields.has(key) ? Number(value || 0) : value;
return {
...current,
[key]: nextValue,
widthEdited: key === 'width' ? true : current.widthEdited
};
};
const updateRouteXsection = (route, xsection, manifest) => {
const technology = getTechnologyManifest(manifest);
const current = createRouteSettings(technology, route);
const xsectionInfo = getXsectionInfo(xsection, technology);
const next = {
...current,
xsection,
family: xsectionInfo.family || current.family
};
if (!current.widthEdited) {
next.width = Number(xsectionInfo.default_width ?? current.width);
}
next.radius = Number(xsectionInfo.default_radius ?? current.radius);
return next;
};
const routeStyleForSettings = (route, selected) => {
const settings = createRouteSettings(null, route);
const palette = {
strip: '#38bdf8',
rib_low: '#22c55e',
metal_1: '#f59e0b',
metal_2: '#f97316'
};
const electrical = settings.family === 'electrical';
const strokeWidth = electrical ? 3.5 : 2.4;
return {
type: electrical ? 'step' : 'smoothstep',
style: {
stroke: palette[settings.xsection] || palette.strip,
strokeWidth: selected ? strokeWidth + 1.2 : strokeWidth,
strokeDasharray: electrical ? '8 5' : undefined,
filter: selected ? 'drop-shadow(0 0 5px rgba(255,255,255,0.45))' : undefined
}
};
};
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
const normalizeAngle = (angle) => {
@@ -272,12 +360,93 @@
return `elements:\n${lines.join('\n')}`;
};
const buildBundlesYaml = (page, manifest) => {
const { nodes = [], edges = [] } = page || {};
const nodeMap = {};
nodes.forEach(n => { nodeMap[n.id] = n; });
let linksYaml = '';
if (edges.length > 0) {
const linkLines = edges.map(edge => {
const sourceNode = nodeMap[edge.source];
const targetNode = nodeMap[edge.target];
const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source;
const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target;
const fromPort = edge.sourceHandle || 'unknown';
const toPort = edge.targetHandle || 'unknown';
const route = createRouteSettings(manifest, edge.data && edge.data.route);
return ` - from: ${sourceName}:${fromPort}
to: ${targetName}:${toPort}
xsection: ${route.xsection}
family: ${route.family}
width: ${Number(route.width)}
radius: ${Number(route.radius)}
routing_type: ${route.routing_type}`;
});
linksYaml = linkLines.join('\n');
}
return `# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
${linksYaml}`;
};
const getNodeCenter = (node) => {
if (!node) return null;
return {
x: Number((node.position && node.position.x) || 0),
y: Number((node.position && node.position.y) || 0)
};
};
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;
};
const segmentsIntersect = (p1, q1, p2, q2) => {
if (!p1 || !q1 || !p2 || !q2) return false;
const o1 = orientation(p1, q1, p2);
const o2 = orientation(p1, q1, q2);
const o3 = orientation(p2, q2, p1);
const o4 = orientation(p2, q2, q1);
return o1 !== o2 && o3 !== o4;
};
const findSameFamilyRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route);
const candidateStart = getNodeCenter(nodeMap[candidateEdge.source]);
const candidateEnd = getNodeCenter(nodeMap[candidateEdge.target]);
for (const edge of existingEdges || []) {
if (!edge || edge.id === candidateEdge.id) continue;
if (edge.source === candidateEdge.source || edge.source === candidateEdge.target || edge.target === candidateEdge.source || edge.target === candidateEdge.target) continue;
const route = createRouteSettings(manifest, edge.data && edge.data.route);
if (route.family !== candidateRoute.family) continue;
const start = getNodeCenter(nodeMap[edge.source]);
const end = getNodeCenter(nodeMap[edge.target]);
if (segmentsIntersect(candidateStart, candidateEnd, start, end)) {
return { conflictEdge: edge, family: route.family };
}
}
return null;
};
return {
FORGE_COMPONENT_LABEL,
FORGE_COMPONENT_TYPE,
ELEMENT_COMPONENTS,
DEFAULT_FORGE_ARGUMENTS,
FALLBACK_TECHNOLOGY_MANIFEST,
createForgeArguments,
createRouteSettings,
updateRouteField,
updateRouteXsection,
routeStyleForSettings,
findSameFamilyRouteCrossing,
isForgeComponent,
normalizeAngle,
portSideFromAngle,
@@ -287,6 +456,7 @@
buildInstancesYaml,
buildPageComponentPorts,
buildCanvasPortsYaml,
buildBundlesYaml,
buildPortsYaml,
buildElementsYaml,
buildSettingsYaml,
+595 -132
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
schema_version: 1.0.0
foundry: Silterra
technology: EMO1_2ML_CU_Al_RDL
source_class: mxpic.foundries.Silterra.EOM1_2ML_CU_RDL
constants:
STD_SMWG_WIDTH: 0.45
SLAB_GROWTH: 2
W_METAL_MIN: 5
SPACING_HEATER_MIN: 2
SPACING_METAL_MIN: 4
W_HEATER_MIN: 3
layers:
WG_HM: {layer: 275, datatype: 0}
WG_STRIP: {layer: 101, datatype: 251}
WG_LOWRIB: {layer: 100, datatype: 90}
WG_HIGHRIB: {layer: 232, datatype: 0}
HEATER: {layer: 29, datatype: 30}
CT_SI: {layer: 268, datatype: 0}
CT_GE: {layer: 35, datatype: 0}
UTV: {layer: 172, datatype: 0}
RDL_VIA: {layer: 194, datatype: 0}
UTM: {layer: 173, datatype: 0}
UTM2: {layer: 197, datatype: 0}
RDL_MET: {layer: 195, datatype: 0}
PAD_ELE: {layer: 100, datatype: 170}
PAD_OPTICAL: {layer: 100, datatype: 160}
PAD_AL: {layer: 145, datatype: 0}
WG_N: {layer: 263, datatype: 0}
SiN_Rib_WG: {layer: 63, datatype: 30}
SSIN0: {layer: 283, datatype: 0}
SSIN1: {layer: 289, datatype: 0}
SSIN2: {layer: 290, datatype: 0}
SSIN3: {layer: 291, datatype: 0}
routing_types:
- euler_bend
- standard_bend
defaults:
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
xsections:
strip:
family: optical
default_width: 0.45
default_radius: 10
layers:
- {layer: WG_HM, growx: 0, growy: 0}
- {layer: WG_STRIP, growx: 4, growy: 4}
rib_low:
family: optical
default_width: 0.45
default_radius: 10
layers:
- {layer: WG_HM, growx: 0, growy: 0}
- {layer: WG_SRIB, growx: 3, growy: 3}
- {layer: WG_STRIP, leftedge: [-0.5, -3], rightedge: [-0.5, -3.5]}
- {layer: WG_STRIP, leftedge: [0.5, 3.5], rightedge: [0.5, 3]}
metal_1:
family: electrical
default_width: 5
default_radius: 10
layers:
- {layer: UTM, growx: 0, growy: 0}
- {layer: SSIN0, growx: 2.5, growy: 2.5}
metal_2:
family: electrical
default_width: 5
default_radius: 10
layers:
- {layer: UTM2, growx: 0, growy: 0}
- {layer: SSIN1, growx: 2.5, growy: 2.5}
+84
View File
@@ -145,3 +145,87 @@ assert(instancesWithoutElements.includes('component_1:'));
const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes);
assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 180, width: 0.7 });
const technologyManifest = {
defaults: { xsection: 'strip', width: 0.45, radius: 10, routing_type: 'euler_bend' },
xsections: {
strip: { family: 'optical', default_width: 0.45 },
rib_low: { family: 'optical', default_width: 0.5 },
metal_1: { family: 'electrical', default_width: 5 },
metal_2: { family: 'electrical', default_width: 6 }
},
routing_types: ['euler_bend', 'standard_bend']
};
const routeDefaults = helpers.createRouteSettings(technologyManifest);
assert.deepStrictEqual(routeDefaults, {
xsection: 'strip',
family: 'optical',
width: 0.45,
radius: 10,
routing_type: 'euler_bend',
widthEdited: false
});
const metalRoute = helpers.updateRouteXsection(routeDefaults, 'metal_1', technologyManifest);
assert.strictEqual(metalRoute.family, 'electrical');
assert.strictEqual(metalRoute.width, 5);
const manuallyEditedWidth = helpers.updateRouteField(routeDefaults, 'width', 0.62, technologyManifest);
const changedXsection = helpers.updateRouteXsection(manuallyEditedWidth, 'rib_low', technologyManifest);
assert.strictEqual(changedXsection.width, 0.62);
assert.strictEqual(changedXsection.family, 'optical');
const styledStrip = helpers.routeStyleForSettings({ xsection: 'strip', family: 'optical' }, false);
const styledMetal = helpers.routeStyleForSettings({ xsection: 'metal_1', family: 'electrical' }, true);
assert.notStrictEqual(styledStrip.style.stroke, styledMetal.style.stroke);
assert(styledMetal.style.strokeDasharray, 'electrical routes should use a visibly different line treatment');
assert(styledMetal.style.strokeWidth > styledStrip.style.strokeWidth);
const routeYaml = helpers.buildBundlesYaml({
nodes: [
{ id: 'a', data: { componentDisplayName: 'inst_a' } },
{ id: 'b', data: { componentDisplayName: 'inst_b' } }
],
edges: [{
id: 'edge-a-b',
source: 'a',
target: 'b',
sourceHandle: 'out',
targetHandle: 'in',
data: { route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' } }
}]
}, technologyManifest);
assert(routeYaml.includes('xsection: metal_1'));
assert(routeYaml.includes('family: electrical'));
assert(routeYaml.includes('radius: 20'));
assert(routeYaml.includes('routing_type: standard_bend'));
const edgeA = {
id: 'edge-a-b',
source: 'a',
target: 'b',
data: { route: { family: 'optical' } }
};
const edgeB = {
id: 'edge-c-d',
source: 'c',
target: 'd',
data: { route: { family: 'optical' } }
};
const edgeC = {
id: 'edge-e-f',
source: 'e',
target: 'f',
data: { route: { family: 'electrical' } }
};
const crossingNodes = {
a: { position: { x: 0, y: 0 } },
b: { position: { x: 100, y: 100 } },
c: { position: { x: 0, y: 100 } },
d: { position: { x: 100, y: 0 } },
e: { position: { x: 0, y: 100 } },
f: { position: { x: 100, y: 0 } }
};
assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b');
assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeC, [edgeA], crossingNodes), null);
+66
View File
@@ -0,0 +1,66 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const backendDir = path.join(root, 'backend');
const serverPy = fs.readFileSync(path.join(backendDir, 'server.py'), 'utf8');
assert(
fs.existsSync(path.join(backendDir, 'layout_preview.py')),
'backend/layout_preview.py should generate SVG previews from saved layout YAML'
);
assert(
fs.existsSync(path.join(backendDir, 'pdk_registry.py')),
'backend/pdk_registry.py should resolve public PDK YAML/GDS assets'
);
assert(
fs.existsSync(path.join(backendDir, 'gds_builder.py')),
'backend/gds_builder.py should build hierarchical GDS from saved project YAML'
);
assert(
serverPy.includes('create_layout_svg_from_gds'),
'save-layout route should create a GDS-derived layout SVG preview'
);
assert(
serverPy.includes('svg_url'),
'save-layout response should include an svg_url for the new layout tab'
);
assert(
serverPy.includes("@app.route('/api/projects/<project_name>/cells/<cell_name>/layout.svg')"),
'server should expose a route for saved cell SVG previews'
);
assert(
serverPy.includes("@app.route('/api/build-gds'"),
'server should expose a Build GDS API route'
);
assert(
serverPy.includes("@app.route('/api/technologies/<foundry>/<technology>/manifest'"),
'server should expose a technology manifest API route'
);
assert(
fs.existsSync(path.join(backendDir, 'technology_manifest.py')),
'backend/technology_manifest.py should read generated technology manifests'
);
const techManifestPath = path.join(root, 'mxpic', 'PDKs', 'Silterra', 'EMO1_2ML_CU_Al_RDL', 'technology.yml');
assert(
fs.existsSync(techManifestPath),
'Silterra technology.yml should be generated into the EDA PDK folder'
);
const techManifest = fs.readFileSync(techManifestPath, 'utf8');
for (const xsection of ['strip', 'rib_low', 'metal_1', 'metal_2']) {
assert(techManifest.includes(`${xsection}:`), `technology.yml should include ${xsection}`);
}
assert(techManifest.includes('family: optical'), 'technology.yml should classify optical xsections');
assert(techManifest.includes('family: electrical'), 'technology.yml should classify electrical xsections');
const layoutPreviewPy = fs.readFileSync(path.join(backendDir, 'layout_preview.py'), 'utf8');
assert(
layoutPreviewPy.includes('read_gds') || layoutPreviewPy.includes('load_gds'),
'layout_preview.py should load public _BB.gds geometry, not draw only schematic boxes'
);
assert(
layoutPreviewPy.includes('_BB.gds') || layoutPreviewPy.includes('gds_path'),
'layout_preview.py should resolve public GDS assets for placed components'
);
+75
View File
@@ -0,0 +1,75 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
assert(
canvasHtml.includes('Build GDS'),
'Project Tree header should include a Build GDS button'
);
assert(
canvasHtml.includes('/api/build-gds'),
'Build GDS button should call the backend build-gds API'
);
assert(
canvasHtml.includes(':layout'),
'Build Layout should open an SVG preview tab named like canvas_1:layout'
);
assert(
canvasHtml.includes('svg_url'),
'Build Layout should use the backend svg_url response'
);
assert(
canvasHtml.includes('layoutPreview'),
'canvas pages should support a layoutPreview tab type'
);
assert(
canvasHtml.includes('LayoutSvgPreview'),
'layout preview tabs should use the auto-scaling SVG viewer'
);
assert(
canvasHtml.includes('layoutScale'),
'layout SVG preview should expose an editable scale value'
);
assert(
canvasHtml.includes('objectFit: \'contain\''),
'100% layout preview scale should fit the full SVG within the screen'
);
assert(
canvasHtml.includes('className="build-gds-btn"'),
'Build GDS should use a dedicated polished button class'
);
assert(
canvasHtml.includes('buildGdsBusy'),
'Build GDS should expose an in-progress state to prevent duplicate requests'
);
assert(
canvasHtml.includes('Build GDS network error'),
'Build GDS fetch failures should produce a specific network diagnostic'
);
assert(
canvasHtml.includes('className="build-layout-btn"'),
'Build Layout should use the polished primary action class'
);
assert(
canvasHtml.includes('Route Editor'),
'Selecting an edge should expose a route editor'
);
assert(
canvasHtml.includes('selectedEdge'),
'canvas should track selected edges separately from selected nodes'
);
assert(
canvasHtml.includes('technologyManifest'),
'canvas should load the selected technology manifest'
);
assert(
canvasHtml.includes('standard_bend'),
'route editor should offer standard_bend as a routing type'
);
assert(
canvasHtml.includes('findSameFamilyRouteCrossing'),
'canvas should validate same-family route crossings'
);