More annotation added to the program

This commit is contained in:
2026-05-30 12:44:44 +08:00
parent b3f29398f0
commit bf223b52ac
22 changed files with 729 additions and 353 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -3
View File
@@ -10,21 +10,24 @@ import os
from werkzeug.security import generate_password_hash
from datetime import datetime
# Save the database in the backend folder
# Store application data in the shared database folder so all backend modules
# use the same SQLite file regardless of their import path.
DB_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "database", "mxpic_data.db"))
def connect_db():
"""Open a SQLite connection with row-style access for application data queries."""
conn = sqlite3.connect(DB_FILE, timeout=20)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=20000")
return conn
def init_db():
"""Create the user, profile, and audit-log tables required by the backend."""
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)
conn = connect_db()
cursor = conn.cursor()
# Create Users Table
# Core account table used by login, profile, and role-based PDK access.
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,6 +36,8 @@ def init_db():
)
''')
# Audit log table used by dashboard activity history and backend action
# tracing.
cursor.execute('''
CREATE TABLE IF NOT EXISTS user_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -48,6 +53,8 @@ def init_db():
)
''')
# Lightweight migrations keep older local SQLite files compatible after
# profile, credit, occupation, and role fields were added.
cursor.execute("PRAGMA table_info(users)")
existing_columns = {row[1] for row in cursor.fetchall()}
migrations = {
@@ -66,7 +73,8 @@ def init_db():
cursor.execute("UPDATE users SET user_group = 'developers' WHERE username = 'engineer'")
cursor.execute("UPDATE users SET user_group = 'user' WHERE user_group IS NULL OR user_group = ''")
# Insert default users for local multi-account development.
# Default local accounts let developers test manager/developer/user access
# without manually editing the database.
cursor.execute("SELECT * FROM users WHERE username = 'admin'")
if not cursor.fetchone():
test_hash = generate_password_hash("123456")
@@ -89,6 +97,7 @@ def init_db():
conn.close()
def get_user(username):
"""Fetch login credentials and account status for a username."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, user_group FROM users WHERE username = ?", (username,))
@@ -97,6 +106,7 @@ def get_user(username):
return user
def get_user_profile(user_id):
"""Fetch editable profile details for an authenticated user."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute(
@@ -108,6 +118,7 @@ def get_user_profile(user_id):
return user
def get_user_auth_by_id(user_id):
"""Fetch password and account metadata by user id."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash FROM users WHERE id = ?", (user_id,))
@@ -116,6 +127,7 @@ def get_user_auth_by_id(user_id):
return user
def update_user_occupation(user_id, occupation):
"""Persist a user profile occupation update."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute("UPDATE users SET occupation = ? WHERE id = ?", (occupation, user_id))
@@ -123,6 +135,7 @@ def update_user_occupation(user_id, occupation):
conn.close()
def update_user_password(user_id, password):
"""Persist a newly hashed password for a user."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), user_id))
@@ -130,6 +143,7 @@ def update_user_password(user_id, password):
conn.close()
def add_user_log(user_id, username, action, project=None, cell=None, detail=None, ip_address=None):
"""Record an auditable user action with optional project and cell context."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute(
@@ -152,6 +166,7 @@ def add_user_log(user_id, username, action, project=None, cell=None, detail=None
conn.close()
def list_user_logs(user_id, limit=200):
"""Return recent audit-log entries for display in the account page."""
conn = connect_db()
cursor = conn.cursor()
cursor.execute(
@@ -169,5 +184,7 @@ def list_user_logs(user_id, limit=200):
return rows
if __name__ == "__main__":
# Allow this module to be run directly when a fresh local database needs to
# be initialized outside the Flask server.
init_db()
print("Database initialized successfully.")
+27
View File
@@ -17,6 +17,7 @@ from pdk_registry import PdkRegistry
@dataclass
class BuildResult:
"""Container for GDS build output paths, status details, and engine metadata."""
output_path: str
engine: str
cells_built: List[str] = field(default_factory=list)
@@ -35,6 +36,8 @@ def build_project_gds(
if not cells:
raise ValueError("No saved cell YAML files found for this project")
# Prefer the routed builder whenever it is available because it understands
# bundle links, anchor connections, and PDK-aware routing rules.
try:
return _build_with_mxpic_router(
project_dir,
@@ -50,9 +53,13 @@ def build_project_gds(
f"Router import failed: {router_error}"
) from router_error
# Placement-only projects can still be exported with local GDS engines when
# the routed builder is not installed.
registry = PdkRegistry(pdk_public_root, prefer_full_gds=prefer_full_gds)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# gdstk is the preferred fallback; Nazca remains a secondary fallback for
# environments where gdstk is not installed.
try:
return _build_with_gdstk(cells, output_path, registry)
except ImportError as gdstk_error:
@@ -72,6 +79,9 @@ def _build_with_mxpic_router(
technology_manifest_path: str,
prefer_full_gds: bool,
) -> BuildResult:
"""Delegate routed project GDS generation to the external mxpic_router package."""
# mxpic_router lives beside this repository during local development, so add
# that sibling checkout to sys.path only when it exists.
router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router"))
if os.path.isdir(router_root) and router_root not in sys.path:
sys.path.insert(0, router_root)
@@ -93,6 +103,7 @@ def _build_with_mxpic_router(
def _load_project_cells(project_dir: str) -> Dict[str, dict]:
"""Load saved cell YAML documents from a project directory."""
cells = {}
for filename in sorted(os.listdir(project_dir)):
if not filename.lower().endswith((".yml", ".yaml")):
@@ -106,12 +117,14 @@ def _load_project_cells(project_dir: str) -> Dict[str, dict]:
def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]:
"""Order cells so dependencies are built before cells that reference them."""
composites = [name for name, data in cells.items() if data.get("type") != "project"]
projects = [name for name, data in cells.items() if data.get("type") == "project"]
return composites + projects
def _cells_have_links(cells: Dict[str, dict]) -> bool:
"""Detect whether any saved cell contains bundle links that require routed building."""
for data in cells.values():
for bundle in (data.get("bundles") or {}).values():
if bundle.get("links"):
@@ -120,12 +133,15 @@ def _cells_have_links(cells: Dict[str, dict]) -> bool:
def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
"""Assemble a project GDS with gdstk when Nazca or routed building is unavailable."""
import gdstk
library = gdstk.Library()
built_cells = {}
warnings = []
# Build composite cells before project cells so project-level references can
# reuse cells already inserted into the same GDS library.
for cell_name in _ordered_cell_names(cells):
data = cells[cell_name]
gds_cell = library.new_cell(_safe_cell_name(cell_name, built_cells))
@@ -137,6 +153,8 @@ def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkReg
rotation = math.radians(_number(instance.get("rotation")))
child = built_cells.get(component)
if child is None:
# External components are resolved through the active PDK
# registry and imported as references.
asset = registry.resolve(component)
if not asset.gds_path:
warnings.append(f"Missing GDS for {instance_name}: {component}")
@@ -154,10 +172,13 @@ def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkReg
def _import_public_gds(gdstk, library, gds_path: str):
"""Import public PDK GDS geometry into the output library."""
source = gdstk.read_gds(gds_path)
top_cells = source.top_level()
if not top_cells:
raise ValueError(f"No top-level cell found in {gds_path}")
# Avoid adding duplicate cell names when multiple instances reference the
# same imported PDK component.
for source_cell in source.cells:
if _library_cell_by_name(library, source_cell.name) is None:
library.add(source_cell)
@@ -165,11 +186,14 @@ def _import_public_gds(gdstk, library, gds_path: str):
def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult:
"""Assemble a project GDS with Nazca cells and PDK component placements."""
import nazca as nd
warnings = []
built_cells = {}
ordered_names = _ordered_cell_names(cells)
# Nazca cells are built in dependency order and then the final project cell
# is exported as the top-level GDS.
for cell_name in ordered_names:
data = cells[cell_name]
with nd.Cell(cell_name) as current_cell:
@@ -195,6 +219,7 @@ def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkReg
def _safe_cell_name(name: str, existing: dict) -> str:
"""Generate a backend-safe unique cell name for GDS/Nazca libraries."""
base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "cell"
candidate = base
counter = 1
@@ -206,6 +231,7 @@ def _safe_cell_name(name: str, existing: dict) -> str:
def _library_cell_by_name(library, name: str):
"""Find a cell object in a loaded layout library by name."""
for cell in library.cells:
if cell.name == name:
return cell
@@ -213,6 +239,7 @@ def _library_cell_by_name(library, name: str):
def _number(value, default=0.0) -> float:
"""Convert numeric YAML values to floats with a stable default."""
try:
if value is None or value == "":
return default
+18
View File
@@ -13,6 +13,8 @@ import yaml
def create_layout_svg_from_gds(yaml_content: str, output_path: str, pdk_registry, project_dir: str = None) -> str:
"""Create an SVG preview by placing real public _BB.gds cells from layout YAML."""
layout = yaml.safe_load(yaml_content) or {}
# Try gdstk first because it can write SVG directly; keep Nazca as a GDS
# placement fallback for environments where gdstk is unavailable.
try:
return _create_with_gdstk(layout, output_path, pdk_registry, project_dir)
except ImportError as gdstk_error:
@@ -26,6 +28,7 @@ def create_layout_svg_from_gds(yaml_content: str, output_path: str, pdk_registry
def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str:
"""Generate preview SVG geometry using gdstk import and placement APIs."""
import gdstk
library = gdstk.Library()
@@ -37,9 +40,12 @@ def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir
def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: Optional[str], cell_cache: Dict):
"""Build or reuse a gdstk cell for a saved layout document."""
cell_name = _safe_cell_name(layout.get("name") or "layout", library)
top = library.new_cell(cell_name)
# Each saved instance becomes a GDS reference to either another project cell
# or a resolved PDK asset.
for instance_name, instance in (layout.get("instances") or {}).items():
component = str(instance.get("component") or "")
x = _number(instance.get("x"))
@@ -54,9 +60,12 @@ def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: O
def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_dir: Optional[str], cell_cache: Dict):
"""Resolve a placed child component from local cells or PDK assets."""
if component in cell_cache:
return cell_cache[component]
# Project-local composite cells are resolved before external PDK components
# so nested user-created cells can appear in preview output.
local_layout = _load_local_layout(component, project_dir)
if local_layout is not None:
child = _build_gdstk_cell(gdstk, library, local_layout, pdk_registry, project_dir, cell_cache)
@@ -72,10 +81,12 @@ def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_di
def _import_gds_cell(gdstk, library, gds_path: str):
"""Import a GDS file and return the first usable cell for placement."""
source = gdstk.read_gds(gds_path)
top_cells = source.top_level()
if not top_cells:
raise ValueError(f"No top-level cell found in {gds_path}")
# Reuse already-imported cells by name to keep the preview library compact.
for source_cell in source.cells:
if _library_cell_by_name(library, source_cell.name) is None:
library.add(source_cell)
@@ -83,9 +94,12 @@ def _import_gds_cell(gdstk, library, gds_path: str):
def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str:
"""Generate preview SVG geometry using Nazca when gdstk is unavailable."""
import nazca as nd
png_path = os.path.splitext(output_path)[0] + ".gds"
# Nazca can place the same GDS references as the preview path, but this
# backend still requires gdstk for final SVG conversion.
with nd.Cell(str(layout.get("name") or "layout")) as top:
for instance_name, instance in (layout.get("instances") or {}).items():
component = str(instance.get("component") or "")
@@ -101,6 +115,7 @@ def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir
def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[dict]:
"""Load a project-local composite layout referenced by another cell."""
if not project_dir or "/" in component or "\\" in component or component == "generate_with_forge":
return None
for ext in (".yml", ".yaml"):
@@ -112,6 +127,7 @@ def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[d
def _safe_cell_name(name, library) -> str:
"""Generate a backend-safe unique cell name for GDS/Nazca libraries."""
base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "layout"
candidate = base
counter = 1
@@ -122,6 +138,7 @@ def _safe_cell_name(name, library) -> str:
def _library_cell_by_name(library, name: str):
"""Find a cell object in a loaded layout library by name."""
for cell in library.cells:
if cell.name == name:
return cell
@@ -129,6 +146,7 @@ def _library_cell_by_name(library, name: str):
def _number(value, default=0.0) -> float:
"""Convert numeric YAML values to floats with a stable default."""
try:
if value is None or value == "":
return default
+12
View File
@@ -10,6 +10,8 @@ import shutil
import uuid
# Role names control which PDK tree the backend exposes for browsing, preview,
# and GDS export requests.
MANAGER_GROUP = "manager"
DEVELOPER_GROUP = "developers"
USER_GROUP = "user"
@@ -17,12 +19,16 @@ ALLOWED_GROUPS = {MANAGER_GROUP, DEVELOPER_GROUP, USER_GROUP}
def normalize_user_group(user_group: str) -> str:
"""Normalize a session user group into the supported PDK access scope."""
group = (user_group or USER_GROUP).strip().lower()
return group if group in ALLOWED_GROUPS else USER_GROUP
def pdk_root_for_group(user_group: str, repo_root: str) -> str:
"""Resolve the PDK library root that belongs to a user group."""
group = normalize_user_group(user_group)
# Managers may access the private atlas PDK tree; all other roles stay on
# the public PDK tree used for normal project editing.
if group == MANAGER_GROUP:
return os.path.abspath(os.environ.get(
"MXPIC_PDK_ATLAS_ROOT",
@@ -35,14 +41,17 @@ def pdk_root_for_group(user_group: str, repo_root: str) -> str:
def pdk_root_for_session(session_obj, repo_root: str) -> str:
"""Resolve the active PDK root from the current Flask session."""
return pdk_root_for_group(session_obj.get("user_group"), repo_root)
def prefer_full_gds_for_session(session_obj) -> bool:
"""Decide whether resolved PDK assets should prefer full GDS files."""
return normalize_user_group(session_obj.get("user_group")) == MANAGER_GROUP
def create_export_path(export_root: str, project_name: str) -> tuple[str, str, str]:
"""Create a unique export directory and filename for generated downloads."""
export_id = uuid.uuid4().hex
filename = f"{project_name}.gds"
export_dir = os.path.abspath(os.path.join(export_root, export_id))
@@ -51,9 +60,12 @@ def create_export_path(export_root: str, project_name: str) -> tuple[str, str, s
def cleanup_expired_exports(export_root: str, max_age_seconds: int = 86400) -> None:
"""Remove old export folders so temporary download storage stays bounded."""
if not os.path.isdir(export_root):
return
now = time.time()
# Each export is stored in its own UUID directory, so old folders can be
# removed independently without touching active project data.
for name in os.listdir(export_root):
path = os.path.join(export_root, name)
if not os.path.isdir(path):
+15
View File
@@ -13,6 +13,7 @@ import yaml
@dataclass
class PdkAsset:
"""Container describing the YAML and GDS assets resolved for a PDK component."""
component: str
yaml_path: Optional[str] = None
gds_path: Optional[str] = None
@@ -23,11 +24,13 @@ class PdkRegistry:
"""Resolve public PDK component names to metadata and public GDS assets."""
def __init__(self, public_root: str, prefer_full_gds: bool = False):
"""Store the active PDK root and cache resolved component assets."""
self.public_root = os.path.abspath(public_root)
self.prefer_full_gds = prefer_full_gds
self._asset_cache = {}
def resolve(self, component: str) -> PdkAsset:
"""Resolve YAML and GDS assets for a requested component key."""
key = (component or "").strip().replace("\\", "/").strip("/")
if not key:
return PdkAsset(component=component)
@@ -42,6 +45,9 @@ class PdkRegistry:
return asset
def _find_yaml(self, key: str) -> Optional[str]:
"""Locate the component YAML description file in the active PDK tree."""
# Try direct component paths first so saved YAML component references
# resolve without scanning the whole PDK tree.
direct = os.path.join(self.public_root, *key.split("/"))
candidates = []
if direct.lower().endswith((".yml", ".yaml")):
@@ -56,6 +62,8 @@ class PdkRegistry:
return os.path.abspath(candidate)
name = key.split("/")[-1]
# Fall back to a tree scan for older saved references that only stored
# the component folder name.
for root, dirs, files in os.walk(self.public_root):
if os.path.basename(root) == name:
for filename in files:
@@ -65,8 +73,11 @@ class PdkRegistry:
return None
def _find_gds(self, key: str, yaml_path: Optional[str]) -> Optional[str]:
"""Locate the best matching GDS asset for a component YAML or key."""
search_dir = os.path.dirname(yaml_path) if yaml_path else os.path.join(self.public_root, *key.split("/"))
name = key.split("/")[-1]
# Normal users prefer black-box GDS for fast previews; manager sessions
# can prefer full layout geometry for complete export.
if self.prefer_full_gds:
candidates = [
os.path.join(search_dir, f"{name}.gds"),
@@ -80,6 +91,8 @@ class PdkRegistry:
for candidate in candidates:
if self._inside_root(candidate) and os.path.exists(candidate):
return os.path.abspath(candidate)
# If the expected filename is missing, choose the first available GDS in
# the component folder while respecting the full-vs-BB preference.
if os.path.isdir(search_dir):
gds_files = sorted(filename for filename in os.listdir(search_dir) if filename.lower().endswith(".gds"))
full_files = [filename for filename in gds_files if not filename.lower().endswith("_bb.gds")]
@@ -90,11 +103,13 @@ class PdkRegistry:
return None
def _load_yaml(self, yaml_path: Optional[str]) -> Optional[dict]:
"""Read a YAML component metadata file into a dictionary."""
if not yaml_path:
return None
with open(yaml_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def _inside_root(self, path: str) -> bool:
"""Check that a candidate asset path remains inside the permitted PDK root."""
target = os.path.abspath(path)
return target == self.public_root or target.startswith(self.public_root + os.sep)
+7
View File
@@ -24,12 +24,16 @@ def create_routed_layout_svg(
layout = yaml.safe_load(yaml_content) or {}
cell_name = str(layout.get("name") or "layout")
# mxpic_router is kept as a sibling repository, so the backend adds it to
# sys.path only when the local checkout is available.
router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router"))
if os.path.isdir(router_root) and router_root not in sys.path:
sys.path.insert(0, router_root)
from mxpic_router import build_project_gds
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Build into a temporary GDS first, then convert the generated top cell into
# the SVG preview consumed by the canvas.
with tempfile.TemporaryDirectory(prefix="mxpic_routed_preview_") as temp_dir:
temp_gds = os.path.join(temp_dir, f"{cell_name}.gds")
build_project_gds(
@@ -49,7 +53,10 @@ def create_routed_layout_svg(
def layout_has_links(yaml_content: str) -> bool:
"""Detect whether a layout YAML document contains routed bundle links."""
layout = yaml.safe_load(yaml_content) or {}
# Any bundle link means preview generation must use the routed builder
# instead of simple component placement.
for bundle in (layout.get("bundles") or {}).values():
links = bundle.get("links") or []
if links:
+81 -5
View File
@@ -29,6 +29,8 @@ from routed_layout_preview import create_routed_layout_svg, layout_has_links
from technology_manifest import TechnologyManifestError, read_technology_manifest
# --- Path Configurations ---
# Centralize all filesystem roots used by Flask routes, previews, project
# storage, PDK discovery, icon serving, and temporary export downloads.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend')
@@ -39,14 +41,17 @@ PDK_PUBLIC_ROOT = os.path.abspath(os.environ.get(
))
EDA_PDK_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs'))
YML_PATH = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra', 'directories.yaml')
# Define where your new icons folder is located (adjust if it's placed elsewhere)
# Component/category icons are served from backend/icons for the library panel.
ICONS_DIR = os.path.join(BASE_DIR, 'icons')
#build layout save path
# Saved project YAML, generated previews, and temporary exports live under the
# database folder so each user can have isolated project storage.
DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database'))
EXPORT_ROOT = os.path.abspath(os.path.join(DATABASE_ROOT, '_exports'))
# Flask serves the HTML/CSS/JS frontend and exposes JSON APIs for persistence,
# PDK lookup, preview generation, and GDS export.
app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR)
app.secret_key = os.environ.get('MXPIC_SECRET_KEY', 'change_me_for_intranet_deployment')
app.config.update(
@@ -56,6 +61,7 @@ app.config.update(
)
app.json.sort_keys = False
# Initialize the lightweight local database when the server process starts.
database.init_db()
@@ -68,8 +74,10 @@ def no_cache_response(response):
def login_required_json(view_func):
"""Wrap API handlers so unauthenticated requests receive JSON errors."""
@wraps(view_func)
def wrapper(*args, **kwargs):
"""Run the wrapped route only when a user session is present."""
if 'user_id' not in session:
return jsonify({"error": "Authentication required"}), 401
return view_func(*args, **kwargs)
@@ -77,6 +85,7 @@ def login_required_json(view_func):
def request_ip():
"""Extract the best available client IP address for audit logging."""
forwarded_for = request.headers.get('X-Forwarded-For', '')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
@@ -84,6 +93,7 @@ def request_ip():
def record_action(action, project=None, cell=None, detail=None):
"""Write a user action into the audit log using current session context."""
if 'user_id' not in session:
return
if isinstance(detail, (dict, list)):
@@ -112,29 +122,37 @@ def safe_name(value, fallback):
def user_layout_root():
"""Return the current user layout directory under the database root."""
username = safe_name(session.get('username'), 'anonymous')
return os.path.join(DATABASE_ROOT, username, 'layout')
def project_root(project_name):
"""Return the filesystem directory for a named project."""
return os.path.join(user_layout_root(), safe_name(project_name, 'project_1'))
def cell_file_path(project_name, cell_name):
"""Return the YAML file path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.yml")
def cell_svg_path(project_name, cell_name):
"""Return the SVG preview path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg")
def cell_routes_path(project_name, cell_name):
"""Return the route sidecar JSON path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml")
def write_route_points_sidecar(yaml_content, output_path):
"""Extract route points from layout YAML and save them beside the cell."""
layout = yaml.safe_load(yaml_content) or {}
routes = {}
# The sidecar preserves manually edited route control points separately from
# the main YAML file for tooling that wants route-only metadata.
for bundle_name, bundle in (layout.get("bundles") or {}).items():
saved_links = []
for link in bundle.get("links") or []:
@@ -159,34 +177,43 @@ def write_route_points_sidecar(yaml_content, output_path):
def project_gds_path(project_name):
"""Return the generated GDS path for a project."""
return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds")
def technology_manifest_path_for_project(project_name):
"""Return the stored technology manifest path for a project."""
technology_id = read_project_meta(project_name).get("technology") or ""
if "/" not in technology_id:
return None
foundry, technology = technology_id.split("/", 1)
path = os.path.abspath(os.path.join(EDA_PDK_ROOT, safe_name(foundry, ''), safe_name(technology, ''), "technology.yml"))
# Keep stored project metadata from escaping the local EDA PDK root.
if path.startswith(EDA_PDK_ROOT + os.sep) and os.path.exists(path):
return path
return None
def current_pdk_root():
"""Resolve the active PDK root for the current request session."""
return pdk_root_for_session(session, REPO_ROOT)
def current_pdk_registry():
"""Create a PDK registry configured for the current session scope."""
return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session))
def scoped_pdk_root_for_project(project_name):
"""Resolve the correct PDK root for an existing project and session."""
base_root = current_pdk_root()
project = safe_name(project_name, '')
if not project:
return base_root
# When a project has a saved foundry/technology, library browsing is scoped
# to that technology folder; otherwise it falls back to the user's full PDK
# access root.
technology_id = read_project_meta(project).get("technology") or ""
if "/" not in technology_id:
return base_root
@@ -199,15 +226,18 @@ def scoped_pdk_root_for_project(project_name):
def pdk_root_for_request_project():
"""Resolve the PDK root requested by a project-aware API call."""
project = request.args.get('project')
return scoped_pdk_root_for_project(project) if project else current_pdk_root()
def project_meta_path(project_name):
"""Return the metadata JSON path for a project."""
return os.path.join(project_root(project_name), ".project.json")
def read_project_meta(project_name):
"""Read project metadata such as foundry and technology selections."""
path = project_meta_path(project_name)
if not os.path.exists(path):
return {}
@@ -216,25 +246,32 @@ def read_project_meta(project_name):
def write_project_meta(project_name, meta):
"""Persist project metadata to disk."""
os.makedirs(project_root(project_name), exist_ok=True)
with open(project_meta_path(project_name), 'w', encoding='utf-8') as f:
json.dump(meta, f, indent=2)
def ensure_project_path(project_name):
"""Create and return the directory for a project."""
layout_root = os.path.abspath(user_layout_root())
target = os.path.abspath(project_root(project_name))
# All project paths must remain under the authenticated user's layout root.
if target != layout_root and not target.startswith(layout_root + os.sep):
raise ValueError("Invalid project path")
return target
# ... [Keep countSpaces and buildTree exactly as they are] ...
# --- PDK Library Scanning Helpers ---
# These helpers turn the active PDK folder structure into the nested component
# library tree shown in the canvas sidebar.
def findComps(baseDir, path_root=None):
"""Scan component folders, return map of paths -> component info."""
compMap = {}
refDir = baseDir
path_root = os.path.abspath(path_root or baseDir)
# A folder containing a YAML file is treated as a component leaf; scanning
# stops below that leaf so nested assets do not pollute the library tree.
for root, dirs, files in os.walk(baseDir):
ymlFiles = [f for f in files if f.endswith('.yml')]
if ymlFiles:
@@ -260,6 +297,8 @@ def addCompsToTree(compMap):
"""Build a completely fresh tree from scratch and insert component nodes."""
fresh_tree = OrderedDict()
# Convert path tuples collected from disk into the nested object structure
# consumed by the frontend library panel.
for mapKey, compItem in compMap.items():
pathSeg = mapKey[:-1]
compName = compItem['folder']
@@ -280,13 +319,15 @@ def addCompsToTree(compMap):
return fresh_tree
# ... [Keep readCompYaml and Page Routes exactly as they are] ...
# Component metadata lookup helpers used by library/detail API endpoints.
# --- API ROUTES (Library, Components & Icons) ---
@app.route('/api/icon/<category>')
def getIcon(category):
"""Serve the icon corresponding to the component category."""
# Prefer exact category artwork, then fall back to a default icon or a
# transparent 1x1 image to keep the frontend layout stable.
for ext in ('.png', '.svg', '.jpg'):
icon_path = os.path.join(ICONS_DIR, f"{category}{ext}")
if os.path.exists(icon_path):
@@ -304,11 +345,14 @@ def getIcon(category):
)
return Response(transparent_png, mimetype='image/png')
# ... [Keep existing API routes below] ...
# Component metadata helpers sit near the library routes because they share the
# same role-scoped PDK root resolution.
def readCompYaml(compName, comps_root=None):
"""Load YAML from component folder."""
search_root = comps_root or current_pdk_root()
# Component names are resolved by folder basename for compatibility with
# saved canvas component references.
for root, dirs, files in os.walk(search_root):
if os.path.basename(root) == compName:
dirs.clear()
@@ -321,6 +365,7 @@ def readCompYaml(compName, comps_root=None):
def find_component_dir(component_name, comps_root=None):
"""Find the directory containing a named component in the PDK library."""
search_root = comps_root or current_pdk_root()
for root, dirs, files in os.walk(search_root):
if os.path.basename(root) == component_name:
@@ -387,6 +432,7 @@ def logout():
@app.route('/api/health')
def health_check():
"""Expose a small deployment health endpoint."""
return jsonify({"status": "ok", "service": "mxpic_eda"})
@@ -396,6 +442,8 @@ def list_technologies():
"""List technology choices from mxpic/PDKs/<foundry>/<technology>."""
technologies = []
pdks_root = EDA_PDK_ROOT
# Technology choices are built from directory names because each technology
# folder owns its generated technology.yml manifest.
if os.path.isdir(pdks_root):
for foundry in sorted(os.listdir(pdks_root)):
foundry_path = os.path.join(pdks_root, foundry)
@@ -417,6 +465,7 @@ def list_technologies():
@app.route('/api/technologies/<foundry>/<technology>/manifest', methods=['GET'])
@login_required_json
def get_technology_manifest(foundry, technology):
"""Return the routing and layer manifest for a selected technology."""
try:
manifest = read_technology_manifest(
EDA_PDK_ROOT,
@@ -431,6 +480,9 @@ def get_technology_manifest(foundry, technology):
@app.route('/api/profile', methods=['GET', 'PATCH'])
@login_required_json
def account_profile():
"""Return or update profile details for the current user."""
# Keep the frontend occupation selector constrained to known values that are
# meaningful for the account page.
occupations = {'intern', 'senior engineer', 'junior engineer', 'principle engineer'}
user_id = session.get('user_id')
@@ -460,6 +512,7 @@ def account_profile():
@app.route('/api/profile/password', methods=['POST'])
@login_required_json
def change_password():
"""Validate and persist a password change for the current user."""
data = request.get_json(silent=True) or {}
current_password = data.get('current_password') or ''
new_password = data.get('new_password') or ''
@@ -478,6 +531,7 @@ def change_password():
@app.route('/api/logs', methods=['GET', 'POST'])
@login_required_json
def user_logs():
"""Return recent account activity for the current user."""
if request.method == 'POST':
data = request.get_json(silent=True) or {}
action = safe_name(data.get('action'), '')
@@ -520,6 +574,8 @@ def list_projects():
os.makedirs(root, exist_ok=True)
projects = []
# Each project is a folder and each YAML file inside that folder is treated
# as one saved cell/canvas.
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not os.path.isdir(path):
@@ -547,12 +603,15 @@ def list_projects():
@app.route('/api/projects', methods=['POST'])
@login_required_json
def create_project():
"""Create a new project folder and initial cell layout."""
data = request.get_json(silent=True) or {}
requested_name = safe_name(data.get('name'), 'project_1')
technology = data.get('technology') or ''
root = user_layout_root()
os.makedirs(root, exist_ok=True)
# Preserve the user's requested base name, adding a numeric suffix only when
# a project folder already exists.
project_name = requested_name
counter = 1
while os.path.exists(os.path.join(root, project_name)):
@@ -618,6 +677,7 @@ def delete_project(project_name):
@app.route('/api/projects/<project_name>/cells/<cell_name>', methods=['PATCH', 'DELETE'])
@login_required_json
def rename_cell(project_name, cell_name):
"""Rename a project cell and its preview/route sidecar files."""
if request.method == 'DELETE':
cell = safe_name(cell_name, 'canvas_1')
target = os.path.abspath(cell_file_path(project_name, cell))
@@ -653,6 +713,7 @@ def rename_cell(project_name, cell_name):
@app.route('/api/save-layout', methods=['POST'])
@login_required_json
def save_layout():
"""Persist a canvas layout YAML document and refresh its preview assets."""
try:
data = request.get_json()
project = safe_name(data.get('project'), 'project_1')
@@ -670,6 +731,8 @@ def save_layout():
svg_path = None
if create_preview:
svg_path = cell_svg_path(project, cell)
# Routed layouts need the router backend; placement-only layouts can
# use the simpler GDS/SVG preview path.
if layout_has_links(content):
create_routed_layout_svg(
content,
@@ -699,6 +762,7 @@ def save_layout():
@app.route('/api/projects/<project_name>/cells/<cell_name>/layout.svg')
@login_required_json
def get_layout_svg(project_name, cell_name):
"""Serve a saved SVG layout preview for a project cell."""
try:
project_dir = ensure_project_path(project_name)
svg_path = os.path.abspath(cell_svg_path(project_name, cell_name))
@@ -714,9 +778,11 @@ def get_layout_svg(project_name, cell_name):
@app.route('/api/build-gds', methods=['POST'])
@login_required_json
def build_gds():
"""Build project GDS output and return a downloadable export URL."""
data = request.get_json(silent=True) or {}
project = safe_name(data.get('project'), 'project_1')
try:
# Expire old exports before creating a new temporary download folder.
cleanup_expired_exports(EXPORT_ROOT)
project_dir = ensure_project_path(project)
if not os.path.isdir(project_dir):
@@ -751,9 +817,12 @@ def build_gds():
@app.route('/api/exports/<export_id>/<filename>')
@login_required_json
def download_export(export_id, filename):
"""Serve a temporary exported GDS file by export id."""
export_id = safe_name(export_id, '')
safe_filename = safe_name(filename, 'layout.gds')
export_dir = os.path.abspath(os.path.join(EXPORT_ROOT, export_id))
# Export downloads are confined to the temporary export root and removed
# after the response closes.
if not export_id or not export_dir.startswith(EXPORT_ROOT + os.sep):
return jsonify({"error": "Invalid export path"}), 400
if not safe_filename.lower().endswith('.gds'):
@@ -769,6 +838,7 @@ def download_export(export_id, filename):
@app.route('/api/projects/<project_name>/gds/<filename>')
@login_required_json
def get_project_gds(project_name, filename):
"""Serve the latest generated GDS file for a project."""
try:
project_dir = ensure_project_path(project_name)
safe_filename = safe_name(filename, f"{safe_name(project_name, 'project_1')}.gds")
@@ -792,6 +862,8 @@ def getLib():
"""Get library structure."""
comps_root = pdk_root_for_request_project()
fresh_tree = {}
# The library tree is rebuilt on request so project technology changes and
# role-scoped PDK roots are reflected immediately.
if os.path.isdir(comps_root):
compMap = findComps(comps_root, current_pdk_root())
fresh_tree = addCompsToTree(compMap)
@@ -814,6 +886,8 @@ def getCompImg(component_name):
"""Return first image in component folder."""
root, files = find_component_dir(component_name, pdk_root_for_request_project())
if root:
# Use the first common image asset in the component folder as its
# preview thumbnail.
for ext in ('.png', '.jpg', '.jpeg', '.svg'):
for f in files:
if f.lower().endswith(ext):
@@ -821,6 +895,8 @@ def getCompImg(component_name):
return jsonify({"error": "No image found"}), 404
if __name__ == '__main__':
# Allow deployment scripts to choose host, port, and debug behavior through
# environment variables.
host = os.environ.get('MXPIC_HOST', '0.0.0.0')
port = int(os.environ.get('MXPIC_PORT', '3000'))
debug = os.environ.get('MXPIC_DEBUG', '0').lower() in {'1', 'true', 'yes'}
+7
View File
@@ -10,23 +10,30 @@ import yaml
class TechnologyManifestError(Exception):
"""Exception raised when a technology manifest cannot be found or parsed."""
pass
def technology_manifest_path(pdks_root: str, foundry: str, technology: str) -> str:
"""Build the expected path to a foundry/technology manifest YAML file."""
base = os.path.abspath(pdks_root)
path = os.path.abspath(os.path.join(base, foundry, technology, "technology.yml"))
# Keep user-provided foundry/technology names from escaping the configured
# PDK root.
if not path.startswith(base + os.sep):
raise TechnologyManifestError("Invalid technology path")
return path
def read_technology_manifest(pdks_root: str, foundry: str, technology: str) -> dict:
"""Load and validate the active technology manifest for the frontend."""
path = technology_manifest_path(pdks_root, foundry, technology)
if not os.path.exists(path):
raise TechnologyManifestError("technology manifest not generated; run mxpic_forge technology export workflow")
with open(path, "r", encoding="utf-8") as file:
manifest = yaml.safe_load(file) or {}
# The frontend route editor depends on both xsection definitions and global
# defaults being present before it can safely style or build links.
if not isinstance(manifest.get("xsections"), dict):
raise TechnologyManifestError("technology manifest is missing xsections")
if not isinstance(manifest.get("defaults"), dict):
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.
+74
View File
@@ -5,19 +5,28 @@
* Organization : OptiHK Limited
*/
(function (root, factory) {
// Build the helper API once, then expose it to both browser and Node test environments.
const helpers = factory();
if (typeof module === 'object' && module.exports) {
module.exports = helpers;
}
root.MxpicCanvasHelpers = helpers;
})(typeof window !== 'undefined' ? window : globalThis, function () {
// Label used by the canvas to represent generated mxpic_forge components.
const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge';
// Serialized component type used when saving mxpic_forge-generated components.
const FORGE_COMPONENT_TYPE = 'generate_with_forge';
// Fallback visual size for PDK components without explicit metadata.
const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 };
// Default editable canvas dimensions in micrometers.
const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 };
// Base visual diameter and hit area used for port and anchor handles.
const PORT_NODE_SIZE = 30;
// Narrow anchor body width used in the canvas visual representation.
const ANCHOR_NODE_WIDTH = 8;
// Default spacing between repeated anchor or port pins.
const DEFAULT_ELEMENT_PITCH = 10;
// Defines built-in port and anchor element metadata before per-node expansion.
const ELEMENT_COMPONENTS = {
Port: {
name: 'Port',
@@ -35,6 +44,7 @@
}
}
};
// Defines local primitive components that do not require PDK lookup.
const BASIC_COMPONENTS = {
waveguide: {
name: 'waveguide',
@@ -69,6 +79,7 @@
}
};
// Default parameters sent when creating a component through mxpic_forge.
const DEFAULT_FORGE_ARGUMENTS = {
function_name: 'straight',
component_name: '',
@@ -87,6 +98,7 @@
notes: ''
};
// Fallback routing technology data used when the backend manifest is unavailable.
const FALLBACK_TECHNOLOGY_MANIFEST = {
routing_types: ['euler_bend', 'standard_bend'],
defaults: {
@@ -104,18 +116,22 @@
}
};
// Merge user edits with default mxpic_forge arguments for saving and generation.
const createForgeArguments = (overrides) => ({
...DEFAULT_FORGE_ARGUMENTS,
...(overrides || {})
});
// Return a manifest object, falling back to bundled defaults when needed.
const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST;
// Look up width, radius, and family defaults for a routing cross-section.
const getXsectionInfo = (xsection, manifest) => {
const technology = getTechnologyManifest(manifest);
return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {};
};
// Normalize route settings so every edge has xsection, family, width, radius, and bend type.
const createRouteSettings = (manifest, overrides) => {
const technology = getTechnologyManifest(manifest);
const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults;
@@ -132,6 +148,7 @@
};
};
// Apply a single route-field edit while preserving route defaults and width override state.
const updateRouteField = (route, key, value, manifest) => {
const current = createRouteSettings(manifest, route);
const numericFields = new Set(['width', 'radius']);
@@ -143,6 +160,7 @@
};
};
// Switch an edge cross-section and refresh dependent routing defaults.
const updateRouteXsection = (route, xsection, manifest) => {
const technology = getTechnologyManifest(manifest);
const current = createRouteSettings(technology, route);
@@ -159,6 +177,7 @@
return next;
};
// Convert route settings into React Flow edge styling for canvas display.
const routeStyleForSettings = (route, selected) => {
const settings = createRouteSettings(null, route);
const palette = {
@@ -180,14 +199,18 @@
};
};
// Check whether a component name refers to the mxpic_forge generator placeholder.
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
// Check whether a component is one of the built-in primitive canvas elements.
const isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]);
// Merge primitive component defaults with user-entered values.
const createBasicSettings = (componentName, overrides) => ({
...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}),
...(overrides || {})
});
// Normalize angles into the -180 to 180 degree range used by port logic.
const normalizeAngle = (angle) => {
const value = Number(angle);
if (!Number.isFinite(value)) return 0;
@@ -196,6 +219,7 @@
return Object.is(normalized, -0) ? 0 : normalized;
};
// Map a port angle to the canvas side where its handle should appear.
const portSideFromAngle = (angle) => {
const normalized = normalizeAngle(angle);
if (normalized === 0) return 'right';
@@ -205,18 +229,22 @@
return Math.abs(normalized) < 90 ? 'right' : 'left';
};
// Round handle percentages so saved and rendered positions stay stable.
const roundPercent = (value) => Number(value.toFixed(3));
// Generate even fallback spacing for handles when no exact position is available.
const fallbackPercent = (index, count) => {
if (count <= 1) return 50;
return roundPercent(15 + (index / (count - 1)) * 70);
};
// Accept only finite positive numeric values from metadata or user input.
const positiveNumber = (value) => {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : null;
};
// Resolve component visual dimensions from metadata with a safe fallback.
const normalizeBoxSize = (metadata, fallback) => {
const fallbackSize = fallback || DEFAULT_COMPONENT_BOX_SIZE;
const raw = metadata && (metadata.box_size || metadata.box_sz || metadata.boxSize);
@@ -235,6 +263,7 @@
};
};
// Select the physical component to place when a library category is dragged.
const chooseCategoryComponent = (dragName, availableComponents, categoryName) => {
const available = Array.isArray(availableComponents)
? availableComponents.filter(Boolean)
@@ -244,11 +273,13 @@
return physicalComponent || dragName || available[0] || categoryName;
};
// Resolve valid canvas dimensions from saved project metadata.
const normalizeCanvasSize = (size) => ({
width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width,
height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height
});
// Keep dragged or pasted nodes inside the active canvas bounds.
const clampPositionToCanvas = (position, canvasSize, boxSize) => {
const size = normalizeCanvasSize(canvasSize);
const box = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] });
@@ -260,6 +291,7 @@
};
};
// Rotate or mirror a component-box corner for layout bounds calculation.
const transformBoxCorner = (corner, transform) => {
const options = transform || {};
let x = Number(corner && corner.x) || 0;
@@ -277,8 +309,10 @@
};
};
// Round layout-bound values for stable preview and export metadata.
const roundBoundsValue = (value) => Number(value.toFixed(6));
// Calculate the bounding rectangle containing all visible canvas nodes.
const calculateLayoutBounds = (pageOrNodes) => {
const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {});
const nodes = Array.isArray(page.nodes) ? page.nodes : [];
@@ -326,8 +360,10 @@
};
};
// Round ruler measurements for compact display.
const roundMeasureValue = (value) => Number(value.toFixed(3));
// Convert pointer coordinates into valid ruler measurement points.
const normalizeMeasurePoint = (point) => {
const x = Number(point && point.x);
const y = Number(point && point.y);
@@ -335,6 +371,7 @@
return { x: roundMeasureValue(x), y: roundMeasureValue(y) };
};
// Build distance, delta, and midpoint values for the ruler overlay.
const createRulerMeasurement = (startPoint, endPoint) => {
const start = normalizeMeasurePoint(startPoint);
const end = normalizeMeasurePoint(endPoint);
@@ -357,6 +394,7 @@
};
};
// Derive compact symbol dimensions for drawing component previews.
const createComponentSymbolMetrics = (boxSize) => {
const size = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] });
const widthRatio = size.width >= 400 ? 0.95 : 0.9;
@@ -366,6 +404,7 @@
};
};
// Apply node rotation and mirror transforms to a component port definition.
const transformPortInfo = (info, transform) => {
const source = info || {};
const options = transform || {};
@@ -402,6 +441,7 @@
};
};
// Create ordered React Flow handles for all ports on a single visual side.
const buildSideHandles = (ports, side) => {
const vertical = side === 'left' || side === 'right';
@@ -421,6 +461,7 @@
});
};
// Group transformed ports into canvas handles with side and position styling.
const buildPortHandles = (ports, transform) => {
const grouped = { left: [], right: [], top: [], bottom: [] };
Object.entries(ports || {}).forEach(([name, info]) => {
@@ -447,6 +488,7 @@
];
};
// Serialize primitive JavaScript values into YAML-friendly scalar text.
const toYamlScalar = (value) => {
if (value === null || value === undefined) return '""';
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
@@ -458,9 +500,12 @@
return JSON.stringify(String(value));
};
// Convert canvas Y coordinates into layout Y coordinates.
const canvasToLayoutY = (value) => -Number(value || 0);
// Convert layout Y coordinates back into canvas Y coordinates.
const layoutToCanvasY = (value) => -Number(value || 0);
// Serialize nested component settings into YAML blocks.
const buildSettingsYaml = (settings, indent) => {
const pad = ' '.repeat(indent);
const entries = Object.entries(settings || {});
@@ -468,6 +513,7 @@
return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n');
};
// Serialize one component instance into saved layout YAML.
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, flip, flop, forgeArguments, basicArguments }) => {
const forge = isForgeComponent(componentName);
const basic = isBasicComponent(componentName);
@@ -489,6 +535,7 @@
mirror: ${flip ? 'true' : 'false'}${settingsYaml}`;
};
// Serialize all component nodes on a page into the instances YAML section.
const buildInstancesYaml = ({ nodes, resolveComponentPath }) => {
return (nodes || [])
.filter(node => node.data && node.data.componentName && !node.data.elementType)
@@ -514,26 +561,33 @@
.join('\n\n');
};
// Resolve the display/export name for a port-like node.
const getNodePortName = (node) => {
const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label);
return name || (node && node.id) || 'port';
};
// Detect standalone port nodes that become top-level layout ports.
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
// Detect built-in port or anchor nodes for element YAML export.
const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor');
// Clamp repeated port counts to a supported positive integer.
const normalizePortNumber = (value) => {
const number = Math.floor(Number(value));
return Number.isFinite(number) ? Math.max(1, number) : 1;
};
// Resolve repeated port spacing with the default pitch fallback.
const normalizePitch = (value) => {
const number = Number(value);
return Number.isFinite(number) ? Math.max(0, number) : DEFAULT_ELEMENT_PITCH;
};
// Calculate the centered offset for one repeated port index.
const elementPortOffset = (index, count, pitch) => ((count - 1) / 2 - index) * pitch;
// Grow port and anchor visual bodies so repeated port circles do not overlap.
const buildElementBoxSize = (data) => {
const portNumber = normalizePortNumber(data && data.portNumber);
const pitch = normalizePitch(data && data.pitch);
@@ -544,6 +598,7 @@
};
};
// Expand port and anchor definitions into one or more named physical ports.
const buildElementPorts = (elementType, data) => {
const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port'];
if (!element) return {};
@@ -583,6 +638,7 @@
return JSON.parse(JSON.stringify(element.ports));
};
// Generate port metadata for built-in primitive components.
const buildBasicComponentPorts = (componentName, settings) => {
const values = createBasicSettings(componentName, settings);
const length = Number(values.length || 0);
@@ -622,6 +678,7 @@
return {};
};
// Generate visual metadata and port definitions for primitive components.
const getBasicComponentMetadata = (componentName, settings) => {
if (!isBasicComponent(componentName)) return null;
const values = createBasicSettings(componentName, settings);
@@ -646,6 +703,7 @@
};
};
// Convert standalone port nodes into page-level layout ports.
const buildPageComponentPorts = (port, nodes) => {
const portNodes = (nodes || []).filter(isPortElementNode);
if (portNodes.length > 0) {
@@ -683,6 +741,7 @@
};
};
// Serialize standalone canvas ports into a layout ports YAML section.
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
const ports = buildPageComponentPorts(fallbackPort, nodes);
const entries = Object.entries(ports);
@@ -701,8 +760,10 @@
return `ports:\n${lines.join('\n')}`;
};
// Maintain legacy single-port YAML export behavior for older callers.
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
// Serialize built-in port and anchor nodes into layout element metadata.
const buildElementsYaml = (nodes) => {
const elementNodes = (nodes || []).filter(isElementNode);
if (elementNodes.length === 0) return 'elements: {}';
@@ -726,6 +787,7 @@
return `elements:\n${lines.join('\n')}`;
};
// Serialize canvas links into routed bundle YAML including route settings and bend points.
const buildBundlesYaml = (page, manifest) => {
const { nodes = [], edges = [] } = page || {};
const nodeMap = {};
@@ -774,6 +836,7 @@ bundles:
${linksYaml}`;
};
// Return the center point of a node when a more precise port point is unavailable.
const getNodeCenter = (node) => {
if (!node) return null;
return {
@@ -782,6 +845,7 @@ ${linksYaml}`;
};
};
// Resolve the exact canvas coordinate of a named node port or anchor pin.
const getNodePortCanvasPoint = (node, portName) => {
if (!node) return null;
const x = Number((node.position && node.position.x) || 0);
@@ -824,12 +888,14 @@ ${linksYaml}`;
};
};
// Convert handle percent strings into numeric ratios for endpoint lookup.
const percentValue = (value, fallback = 50) => {
if (typeof value !== 'string') return fallback;
const number = Number(value.replace('%', ''));
return Number.isFinite(number) ? number : fallback;
};
// Resolve a route endpoint from an edge handle and its connected node.
const getEdgeEndpointPoint = (edge, nodeMap, endpoint) => {
const nodeId = endpoint === 'source' ? edge.source : edge.target;
const handleId = endpoint === 'source' ? edge.sourceHandle : edge.targetHandle;
@@ -871,6 +937,7 @@ ${linksYaml}`;
return null;
};
// Return the editable route polyline points for crossing checks and YAML export.
const getEdgeRoutePoints = (edge, nodeMap) => {
const explicitPoints = edge && edge.data && Array.isArray(edge.data.points) ? edge.data.points : [];
if (explicitPoints.length >= 2) {
@@ -891,6 +958,7 @@ ${linksYaml}`;
return [getNodeCenter(nodeMap[edge.source]), getNodeCenter(nodeMap[edge.target])].filter(Boolean);
};
// Check two routed polylines for any segment crossing.
const routeSegmentsIntersect = (pointsA, pointsB) => {
for (let i = 0; i < pointsA.length - 1; i += 1) {
for (let j = 0; j < pointsB.length - 1; j += 1) {
@@ -902,12 +970,14 @@ ${linksYaml}`;
return false;
};
// Classify point ordering for the line-segment intersection test.
const orientation = (a, b, c) => {
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
if (Math.abs(value) < 1e-9) return 0;
return value > 0 ? 1 : 2;
};
// Detect whether two line segments cross each other.
const segmentsIntersect = (p1, q1, p2, q2) => {
if (!p1 || !q1 || !p2 || !q2) return false;
const o1 = orientation(p1, q1, p2);
@@ -917,6 +987,7 @@ ${linksYaml}`;
return o1 !== o2 && o3 !== o4;
};
// Normalize equivalent route xsection names before same-type crossing checks.
const routeTypeKey = (route) => {
const xsection = String((route && route.xsection) || '').trim().toLowerCase();
if (xsection === 'metal1') return 'metal_1';
@@ -925,6 +996,7 @@ ${linksYaml}`;
return xsection;
};
// Find an existing same-type route that would cross a candidate edge.
const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route);
const candidateType = routeTypeKey(candidateRoute);
@@ -942,8 +1014,10 @@ ${linksYaml}`;
return null;
};
// Backward-compatible alias for same-type route crossing validation.
const findSameFamilyRouteCrossing = findSameTypeRouteCrossing;
// Expose the helper functions consumed by canvas.html and the Node-based tests.
return {
FORGE_COMPONENT_LABEL,
FORGE_COMPONENT_TYPE,
+111
View File
@@ -1477,6 +1477,7 @@ Organization : OptiHK Limited
const iconPromiseCache = {};
// Loads and caches category icons so repeated library renders do not refetch the same image.
function fetchIcon(category) {
if (!iconPromiseCache[category]) {
let resolveFn;
@@ -1506,6 +1507,7 @@ Organization : OptiHK Limited
}
// Displays a category icon with cached loading and graceful failure behavior.
const IconImg = memo(({ category, containerStyle }) => {
const [src, setSrc] = useState(() => {
if (!category) return undefined;
@@ -1569,6 +1571,7 @@ Organization : OptiHK Limited
// Renders PDK and primitive component instances with transformed ports and selection styling.
const RotatableNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
const prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`);
@@ -1710,6 +1713,7 @@ Organization : OptiHK Limited
// Renders standalone exported port elements with repeated port handles.
const PortNode = ({ id, data, selected }) => {
const angle = data.angle ?? 0;
const ports = buildElementPorts('port', data);
@@ -1754,6 +1758,7 @@ Organization : OptiHK Limited
);
};
// Renders anchor elements with split visual handles while keeping paired layout ports connected.
const AnchorNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
const anchorRotation = data.rotation || 0;
@@ -1855,10 +1860,12 @@ Organization : OptiHK Limited
);
});
// Draws the non-interactive canvas extent marker used by React Flow.
const CanvasBoundaryNode = memo(({ data }) => (
<div className="canvas-boundary-node" title={`${data.size.width} x ${data.size.height} um`} />
));
// Draws invisible connection handles for ruler measurement endpoints.
const RulerPointNode = memo(({ data }) => {
const hiddenHandleStyle = {
width: 1,
@@ -1877,12 +1884,14 @@ Organization : OptiHK Limited
);
});
// Displays the ruler measurement label at the measured midpoint.
const RulerMeasurementNode = memo(({ data }) => (
<div className="ruler-measurement-node" title={data.title || data.label}>
{data.label}
</div>
));
// Maps visual route directions to x/y vectors for edge geometry calculations.
const routeDirectionVector = (direction) => {
if (direction === 'left') return { x: -1, y: 0 };
if (direction === 'right') return { x: 1, y: 0 };
@@ -1890,6 +1899,7 @@ Organization : OptiHK Limited
if (direction === 'bottom') return { x: 0, y: 1 };
return null;
};
// Converts a route direction string into the matching React Flow handle position.
const directionToReactFlowPosition = (direction) => {
if (direction === 'left') return Position.Left;
if (direction === 'right') return Position.Right;
@@ -1898,6 +1908,7 @@ Organization : OptiHK Limited
return undefined;
};
// Draws editable routed links, including parallel offsets and draggable bend control points.
const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => {
const offset = Number(data?.parallelOffset || 0);
const hasExplicitPoints = Array.isArray(data?.points) && data.points.length >= 2;
@@ -1971,6 +1982,7 @@ Organization : OptiHK Limited
);
});
// Displays generated layout SVG previews with zoom and pan controls.
const LayoutSvgPreview = ({ page }) => {
const [layoutScale, setLayoutScale] = useState(100);
const previewBounds = useMemo(
@@ -2039,6 +2051,7 @@ Organization : OptiHK Limited
);
};
// Allows a canvas tab title to be renamed in place.
const EditableCanvasTabName = ({ page, active, onRename }) => {
const [value, setValue] = useState(page.name);
@@ -2077,6 +2090,7 @@ Organization : OptiHK Limited
);
};
// Allows project-tree canvas names to be renamed from the navigation panel.
const EditableTreeCanvasName = ({ pageId, name, canRename, onRename, onOpen }) => {
const [value, setValue] = useState(name);
@@ -2119,8 +2133,10 @@ Organization : OptiHK Limited
);
};
// Checks whether a tree node represents a draggable component entry.
const isLibraryComponentLeaf = (node) => node && node.__type__ === 'component';
// Collects all component names under a library category for drag/drop selection.
const getCategoryComponents = (categoryNode) => {
return Object.entries(categoryNode || {})
.filter(([, childData]) => isLibraryComponentLeaf(childData))
@@ -2130,6 +2146,7 @@ Organization : OptiHK Limited
}));
};
// Renders a top-level draggable category entry in the component library.
const CategoryCard = ({ name, components = [] }) => {
const componentNames = components.map(component => component.name).filter(Boolean);
const selectableComponents = Array.from(new Set([FORGE_COMPONENT_LABEL, ...componentNames]));
@@ -2159,6 +2176,7 @@ Organization : OptiHK Limited
);
};
// Renders recursive component library nodes with drag behavior for leaves.
const TreeNode = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const componentName = children.__name__;
@@ -2305,6 +2323,7 @@ Organization : OptiHK Limited
);
};
// Renders recursive project/cell/instance navigation with open, drag, rename, and delete actions.
const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas }) => {
if (children && children.__type__ === 'project') {
const projectName = children.__name__ || name;
@@ -2428,6 +2447,7 @@ Organization : OptiHK Limited
);
};
// Renders the nested contents of a composite cell inside the project tree.
const CompositeComponentTree = ({ name, children, canvasName, onSelectInstance }) => {
if (children && children.__type__ === 'component') {
const displayText = children.__instance__ || name;
@@ -2465,6 +2485,7 @@ Organization : OptiHK Limited
return null;
};
// Renders project actions, canvas sizing controls, and the component library navigation.
const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => {
const [projectPanelHeight, setProjectPanelHeight] = useState(270);
const [resizingProjectPanel, setResizingProjectPanel] = useState(false);
@@ -2488,6 +2509,7 @@ Organization : OptiHK Limited
};
}, [resizingProjectPanel]);
// Toggle the expanded state of the project tree panel.
const handleProjectToggle = () => {
onProjectToggle();
};
@@ -2585,6 +2607,7 @@ Organization : OptiHK Limited
);
};
// Renders editable properties for selected nodes, ports, anchors, and routes.
const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
const [componentData, setComponentData] = useState(null);
const [loading, setLoading] = useState(false);
@@ -3342,6 +3365,7 @@ Organization : OptiHK Limited
);
};
// Provides a draggable divider for resizing side panels.
const ResizeHandle = ({ onMouseDown }) => (
<div
onMouseDown={onMouseDown}
@@ -3354,6 +3378,7 @@ Organization : OptiHK Limited
/>
);
// Finds the library path for a component name so it can be serialized into YAML.
function findComponentPath(lib, compName) {
function walk(obj, currentPath) {
if (obj && obj.__type__ === 'component' && obj.__name__ === compName) {
@@ -3372,6 +3397,7 @@ Organization : OptiHK Limited
}
return walk(lib, []) || [];
}
// Builds a compact project-tree structure from placed component instances.
function buildCompInstanceTree(compNodes, library) {
const tree = {};
compNodes.forEach(node => {
@@ -3388,6 +3414,7 @@ Organization : OptiHK Limited
}
// Builds a category tree of components used by the current canvas.
function buildCompTree(compNodes, library) {
const tree = {};
compNodes.forEach(node => {
@@ -3409,6 +3436,7 @@ Organization : OptiHK Limited
return tree;
}
// Coordinates editor state, project loading, naming, routing, save/load, and build actions.
function App() {
const currentProjectName = useMemo(() => {
const params = new URLSearchParams(window.location.search);
@@ -3485,6 +3513,7 @@ Organization : OptiHK Limited
[rulerStartPoint, rulerActiveEndPoint]
);
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
// Normalizes free-route control points and removes adjacent duplicates before storage.
const compactRoutePoints = useCallback((points) => {
return (points || [])
.map(point => ({
@@ -3494,7 +3523,9 @@ Organization : OptiHK Limited
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
.filter((point, index, list) => index === 0 || point.x !== list[index - 1].x || point.y !== list[index - 1].y);
}, []);
// Builds stable hidden endpoint node ids for free-route edges.
const routeEndpointNodeId = useCallback((edgeId, endpoint) => `__free_route_${edgeId}_${endpoint}__`, []);
// Creates a React Flow edge object for stored free-route polylines.
const makeFreeRouteEdge = useCallback((edgeId, points, route, selected = false) => {
const view = routeStyleForSettings(route, selected);
return {
@@ -3509,6 +3540,7 @@ Organization : OptiHK Limited
data: { route, points: compactRoutePoints(points), freeRoute: true },
};
}, [compactRoutePoints, routeEndpointNodeId]);
// Builds temporary ruler endpoint and label nodes while measuring distance.
const rulerNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview' || !rulerStartPoint) return [];
const nodes = [{
@@ -3551,6 +3583,7 @@ Organization : OptiHK Limited
}
return nodes;
}, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]);
// Builds temporary ruler edges between measurement endpoints.
const rulerEdges = useMemo(() => {
if (!rulerMeasurement) return [];
return [{
@@ -3568,6 +3601,7 @@ Organization : OptiHK Limited
}
}];
}, [rulerMeasurement, rulerPreviewMeasurement]);
// Creates hidden nodes that let free-route edge endpoints participate in React Flow.
const freeRouteEndpointNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview') return [];
return currentEdges.flatMap(edge => {
@@ -3601,6 +3635,7 @@ Organization : OptiHK Limited
];
});
}, [activePage, currentEdges, compactRoutePoints, routeEndpointNodeId]);
// Combines real nodes with boundary, ruler, and hidden route helper nodes for display.
const renderNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview') return currentNodes;
return [{
@@ -3615,6 +3650,7 @@ Organization : OptiHK Limited
style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' }
}, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes];
}, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]);
// Resolves rotated anchor handle direction so connected canvas links exit the correct side.
const getAnchorHandleRouteDirection = useCallback((node, handleId) => {
if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null;
const handles = buildPortHandles(buildElementPorts('anchor', node.data), {
@@ -3624,6 +3660,7 @@ Organization : OptiHK Limited
});
return handles.find(handle => handle.name === handleId)?.position || null;
}, []);
// Applies parallel offsets, anchor handle directions, and ruler overlays before rendering edges.
const renderEdges = useMemo(() => {
const groups = new Map();
const nodeMap = Object.fromEntries(currentNodes.map(node => [node.id, node]));
@@ -3674,6 +3711,7 @@ Organization : OptiHK Limited
localStorage.setItem('mxpic-theme', themeMode);
}, [themeMode]);
// Append a short status message to the activity log.
const addLog = useCallback((message) => {
setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]);
}, []);
@@ -3689,10 +3727,12 @@ Organization : OptiHK Limited
return () => window.clearInterval(timer);
}, [buildProgress.active, buildProgress.value]);
// Start the build progress indicator for layout or GDS operations.
const startBuildProgress = useCallback((label) => {
setBuildProgress({ active: true, label, value: 8 });
}, []);
// Finish and auto-hide the build progress indicator.
const completeBuildProgress = useCallback((label) => {
setBuildProgress({ active: true, label, value: 100 });
window.setTimeout(() => {
@@ -3700,14 +3740,17 @@ Organization : OptiHK Limited
}, 900);
}, []);
// Clear the build progress indicator after a failure or cancellation.
const stopBuildProgress = useCallback(() => {
setBuildProgress({ active: false, label: '', value: 0 });
}, []);
// Normalize YAML boolean-like values when loading saved projects.
const toBooleanFlag = useCallback((value) => (
value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true'
), []);
// Normalize stored route points and convert layout Y coordinates when needed.
const normalizeRoutePoints = useCallback((points, usesGdsYUp = false) => (
(Array.isArray(points) ? points : [])
.map(point => ({
@@ -3717,6 +3760,7 @@ Organization : OptiHK Limited
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
), []);
// Load routing defaults and cross-section data for the project technology.
const loadTechnologyManifest = useCallback(async (technologyId) => {
if (!technologyId || !technologyId.includes('/')) {
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
@@ -3743,6 +3787,7 @@ Organization : OptiHK Limited
const componentDataCacheRef = useRef(new Map());
// Fetch metadata for a component before creating a loaded or dropped node.
const loadComponentMetadata = useCallback(async (componentName) => {
if (!componentName || isForgeComponent(componentName)) return null;
if (componentDataCacheRef.current.has(componentName)) {
@@ -3755,6 +3800,7 @@ Organization : OptiHK Limited
return data;
}, [currentProjectName]);
// Send an auditable user action to the backend log endpoint.
const recordUserAction = useCallback((action, payload = {}) => {
fetch('/api/logs', {
method: 'POST',
@@ -3763,6 +3809,7 @@ Organization : OptiHK Limited
}).catch(() => {});
}, []);
// Keep project/composite ownership maps in step when cells are placed or removed.
const syncCompositePlacement = useCallback((projectName, compositeName, mode = 'add') => {
setStandaloneComposites(prev => {
if (mode === 'add') return prev.filter(name => name !== compositeName);
@@ -3791,6 +3838,7 @@ Organization : OptiHK Limited
});
}, []);
// Rebuild composite trees from all canvas pages after project load or cell edits.
const syncAllCompositeTrees = useCallback((pagesToScan, libraryData) => {
if (!libraryData) return;
const nextTrees = {};
@@ -3805,6 +3853,7 @@ Organization : OptiHK Limited
}));
}, []);
// Apply React Flow node changes while preserving canvas-only helper nodes.
const onNodesChange = useCallback((changes) => {
if (!activePageId) return;
const relevantChanges = changes.filter(change => change.id !== '__canvas-boundary__');
@@ -3836,6 +3885,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, activePage, activeCanvasSize]);
// Apply React Flow edge changes while preserving route style and selection state.
const onEdgesChange = useCallback((changes) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
@@ -3850,6 +3900,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, technologyManifest]);
// Apply property-panel edits to a selected node.
const handleUpdateNode = useCallback((nodeId, update) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
@@ -3880,6 +3931,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, activeCanvasSize]);
// Update active canvas dimensions and clamp existing node positions inside the new bounds.
const handleCanvasSizeChange = useCallback((axis, value) => {
if (!activePageId) return;
const numericValue = Number(value);
@@ -3904,6 +3956,7 @@ Organization : OptiHK Limited
}));
}, [activePageId]);
// Rotate selected components, ports, and anchors in 90 degree steps from keyboard input.
const rotateComponentByNinety = useCallback((nodeId) => {
if (!activePageId || !nodeId) return;
setPages(prev => prev.map(p => {
@@ -3927,6 +3980,7 @@ Organization : OptiHK Limited
}));
}, [activePageId]);
// Resolve which selected or hovered node should rotate when Space is pressed.
const getSpaceRotationTarget = useCallback(() => {
if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current;
const selectedSpaceNode = selectedNode;
@@ -3935,16 +3989,19 @@ Organization : OptiHK Limited
return selectedSpaceNode.id;
}, [selectedNode]);
// Remember the node under the pointer so Space rotation can target it.
const onNodeMouseDown = useCallback((event, node) => {
if (event.button !== 0) return;
if (node.type !== 'rotatableNode' && node.type !== 'portNode' && node.type !== 'anchorNode') return;
spaceRotateNodeIdRef.current = node.id;
}, []);
// Clear the temporary Space-rotation target when the mouse is released.
const clearSpaceRotateNode = useCallback(() => {
spaceRotateNodeIdRef.current = null;
}, []);
// Apply route setting edits and reject same-type route crossings.
const handleUpdateEdgeRoute = useCallback((edgeIds, routeUpdate) => {
if (!activePageId) return;
const targetEdgeIds = new Set(Array.isArray(edgeIds) ? edgeIds : [edgeIds]);
@@ -3976,6 +4033,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, technologyManifest, addLog]);
// Copy selected nodes into the local editor clipboard.
const handleCopy = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
@@ -3984,6 +4042,7 @@ Organization : OptiHK Limited
}
}, [activePage]);
// Copy and remove selected nodes while releasing their reserved display-name indexes.
const handleCut = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
@@ -3997,6 +4056,7 @@ Organization : OptiHK Limited
}
}, [activePage, setPages]);
// Paste copied nodes with new display names and offset positions.
const handlePaste = useCallback(() => {
if (!activePage || clipboard.nodes.length === 0) return;
const newNodes = clipboard.nodes.map(node => {
@@ -4031,6 +4091,7 @@ Organization : OptiHK Limited
setClipboard({ nodes: newNodes });
}, [activePage, clipboard, generateComponentDisplayName]);
// Delete selected nodes and attached edges while freeing their name indexes.
const handleDelete = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
@@ -4115,6 +4176,7 @@ Organization : OptiHK Limited
terminations: 'TERM'
};
// Split a generated display name into prefix and numeric index.
function parseComponentDisplayName(displayName) {
const match = String(displayName || '').match(/^(.+)_(\d+)$/);
if (!match) return null;
@@ -4123,6 +4185,7 @@ Organization : OptiHK Limited
return { prefix: match[1], index };
}
// Mark a generated name index as used for its prefix.
function reserveComponentDisplayName(displayName) {
const parsed = parseComponentDisplayName(displayName);
if (!parsed) return;
@@ -4131,6 +4194,7 @@ Organization : OptiHK Limited
componentIndexesByPrefixRef.current[parsed.prefix] = usedIndexes;
}
// Release a generated name index so future components can reuse it.
function releaseComponentDisplayName(displayName) {
const parsed = parseComponentDisplayName(displayName);
if (!parsed) return;
@@ -4142,16 +4206,19 @@ Organization : OptiHK Limited
}
}
// Release generated name indexes for a group of deleted or cut nodes.
function releaseComponentDisplayNames(nodes = []) {
nodes.forEach(node => releaseComponentDisplayName(node?.data?.componentDisplayName));
}
// Rebuild the used-name index table from all currently loaded pages.
function reserveComponentDisplayNamesFromPages() {
pages.forEach(page => {
(page.nodes || []).forEach(node => reserveComponentDisplayName(node?.data?.componentDisplayName));
});
}
// Convert a component category into the saved display-name prefix or abbreviation.
const normalizeComponentDisplayNamePrefix = useCallback((prefixSource, options = {}) => {
const cleanedPrefix = String(prefixSource || 'element')
.trim()
@@ -4181,6 +4248,7 @@ Organization : OptiHK Limited
return singularPrefix;
}, []);
// Create the next available prefix-specific component display name.
const generateComponentDisplayName = useCallback((prefixSource = 'element', options = {}) => {
const prefix = normalizeComponentDisplayNamePrefix(prefixSource, options);
reserveComponentDisplayNamesFromPages();
@@ -4193,6 +4261,7 @@ Organization : OptiHK Limited
return name;
}, [normalizeComponentDisplayNamePrefix, pages]);
// Rename a component node and update the name-index reservation table.
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
if (!activePageId) return;
const oldDisplayName = activePage?.nodes.find(node => node.id === nodeId)?.data?.componentDisplayName;
@@ -4209,6 +4278,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, activePage]);
// Load the current project-scoped PDK/component library from the backend.
const fetchLibrary = useCallback(async () => {
try {
const res = await fetch(`/api/library?project=${encodeURIComponent(currentProjectName)}`);
@@ -4220,6 +4290,7 @@ Organization : OptiHK Limited
}, [currentProjectName]);
useEffect(() => { fetchLibrary(); }, [fetchLibrary]);
// Flatten the library tree into component/category pairs.
const collectComponentNames = useCallback((lib) => {
const names = [];
const walk = (obj) => {
@@ -4234,6 +4305,7 @@ Organization : OptiHK Limited
return names;
}, []);
// Restore PDK-selection options for components loaded from saved YAML.
const getAvailableComponentsForLoadedComponent = useCallback((componentName) => {
if (!library || !componentName || isForgeComponent(componentName) || isBasicComponent(componentName)) return undefined;
const componentEntries = collectComponentNames(library);
@@ -4246,6 +4318,7 @@ Organization : OptiHK Limited
return Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents, componentName]));
}, [library, collectComponentNames]);
// Recreate saved port and anchor nodes when a project YAML document is loaded.
const buildElementNodesFromYaml = useCallback((doc, usesGdsYUp, nodeNameMap = {}) => {
const nodes = [];
Object.entries(doc.elements || {}).forEach(([elementName, element]) => {
@@ -4788,6 +4861,7 @@ Organization : OptiHK Limited
const openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]);
// Open a page and select a named instance from the project tree.
const selectInstanceInPage = useCallback((pageName, instanceName) => {
if (!pageName || !instanceName) return;
const targetPage = pages.find(p => p.name === pageName);
@@ -4810,6 +4884,7 @@ Organization : OptiHK Limited
}));
}, [pages]);
// Open an existing project page by name.
const openProject = useCallback((name) => {
setPages(prev => {
const existing = prev.find(p => p.name === name && p.type === 'project');
@@ -4832,6 +4907,7 @@ Organization : OptiHK Limited
});
}, []);
// Open a canvas tab and make it active.
const openPage = useCallback((name) => {
const belongsToProject = Object.values(projectCompositeMap).some(comps => comps.includes(name));
if (!belongsToProject && !standaloneComposites.includes(name)) {
@@ -4868,6 +4944,7 @@ Organization : OptiHK Limited
});
}, [projectCompositeMap, standaloneComposites]);
// Rename a canvas cell and synchronize backend files when needed.
const renameCanvas = useCallback((pageId, requestedName) => {
const normalizedName = requestedName.trim().replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^[._]+|[._]+$/g, '');
if (!normalizedName) return;
@@ -4924,6 +5001,7 @@ Organization : OptiHK Limited
}).catch(() => addLog(`Renamed canvas locally; saved file rename did not complete.`));
}, [pages, currentProjectName, addLog]);
// Create a new composite canvas with a unique cell name.
const createCell = useCallback(() => {
const existingNames = new Set(pages.filter(p => p.type === 'composite').map(p => p.name));
let index = existingNames.size + 1;
@@ -4963,6 +5041,7 @@ Organization : OptiHK Limited
recordUserAction('canvas.create', { project: currentProjectName, cell: cellName });
}, [pages, currentProjectName, recordUserAction]);
// Close a canvas tab without deleting its saved content.
const closePage = useCallback((pageId) => {
setPages(prev => {
const closed = prev.map(p => p.id === pageId ? { ...p, isClosed: true } : p);
@@ -4976,6 +5055,7 @@ Organization : OptiHK Limited
});
}, [activePageId]);
// Delete a saved canvas cell and update project/composite references.
const deleteCanvas = useCallback((cellName) => {
if (!cellName) return;
if (!window.confirm(`Delete canvas "${cellName}" from this project?`)) return;
@@ -5021,10 +5101,12 @@ Organization : OptiHK Limited
}).catch(() => addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`));
}, [pages, activePageId, currentProjectName, addLog]);
// Switch the active editor tab.
const switchPage = useCallback((pageId) => {
setActivePageId(pageId);
}, []);
// Update legacy page-level port settings for a canvas.
const handlePortChange = useCallback((pageId, newPort) => {
setPages(prev => prev.map(p => {
if (p.id !== pageId) return p;
@@ -5050,11 +5132,13 @@ Organization : OptiHK Limited
}));
}, []);
// Allow library and project-tree entries to be dropped onto the canvas.
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Create component, port, anchor, or composite nodes from dropped library entries.
const onDrop = useCallback((event) => {
event.preventDefault();
const rawData = event.dataTransfer.getData('application/reactflow');
@@ -5322,28 +5406,35 @@ Organization : OptiHK Limited
});
}, [activePageId, activePage, activeCanvasSize, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName, toBooleanFlag]);
// Expand all library tree nodes.
const expandAll = useCallback(() => {
if (treeContainerRef.current) {
treeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
// Collapse all library tree nodes.
const collapseAll = useCallback(() => setTreeKey(k => k + 1), []);
// Toggle the expanded state of the component library panel.
const handleToggle = useCallback(() => {
if (expanded) { collapseAll(); setExpanded(false); }
else { expandAll(); setExpanded(true); }
}, [expanded, expandAll, collapseAll]);
// Expand all project tree nodes.
const expandProjectAll = useCallback(() => {
if (projectTreeContainerRef.current) {
projectTreeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
// Collapse all project tree nodes.
const collapseProjectAll = useCallback(() => setProjectTreeKey(k => k + 1), []);
// Toggle the expanded state of the project tree panel.
const handleProjectToggle = useCallback(() => {
if (projectExpanded) { collapseProjectAll(); setProjectExpanded(false); }
else { expandProjectAll(); setProjectExpanded(true); }
}, [projectExpanded, expandProjectAll, collapseProjectAll]);
// Begin side-panel resize tracking.
const handleResizeStart = useCallback((side) => (e) => {
e.preventDefault();
setDragging(side);
@@ -5367,10 +5458,12 @@ Organization : OptiHK Limited
};
}, [dragging]);
// Toggle snap-to-grid movement in the editor.
const toggleGridSnap = useCallback(() => {
setGridSnap(prev => !prev);
}, []);
// Toggle the measurement ruler and clear partial measurements.
const toggleRulerMode = useCallback(() => {
setRulerMode(prev => {
const next = !prev;
@@ -5383,6 +5476,7 @@ Organization : OptiHK Limited
});
}, []);
// Convert a pane click or pointer event into canvas ruler coordinates.
const eventToRulerPoint = useCallback((event) => {
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
return {
@@ -5391,6 +5485,7 @@ Organization : OptiHK Limited
};
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
// Set ruler start/end points from canvas clicks.
const handleRulerPaneClick = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
event.preventDefault();
@@ -5411,12 +5506,14 @@ Organization : OptiHK Limited
}
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]);
// Update the live ruler preview point while measuring.
const handleRulerMouseMove = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
if (!rulerStartPoint || rulerEndPoint) return;
setRulerPreviewPoint(eventToRulerPoint(event));
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]);
// Select a route edge by id with optional additive selection.
const selectEdgeById = useCallback((edgeId, additive = false) => {
if (!activePageId || !edgeId) return;
setPages(prev => prev.map(p => {
@@ -5435,6 +5532,7 @@ Organization : OptiHK Limited
}));
}, [activePageId, technologyManifest]);
// Create a new routed connection and reject same-type crossings.
const handleBasicConnection = useCallback((connection) => {
if (!activePageId || !activePage || activePage.type === 'layoutPreview' || rulerMode) return;
if (!connection?.source || !connection?.target || !connection?.sourceHandle || !connection?.targetHandle) return;
@@ -5469,6 +5567,7 @@ Organization : OptiHK Limited
addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`);
}, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog]);
// Select custom route edges from their SVG hit target.
const handleRouteEdgeMouseDown = useCallback((event) => {
if (rulerMode) return false;
const target = event.target?.closest?.('[data-route-edge-id]');
@@ -5481,6 +5580,7 @@ Organization : OptiHK Limited
return true;
}, [rulerMode, selectEdgeById]);
// Select standard React Flow edges while ignoring helper/ruler edges.
const handleReactFlowEdgeMouseDown = useCallback((event, edge) => {
if (rulerMode || !edge || edge.data?.draft || edge.data?.ruler) return;
event.preventDefault();
@@ -5488,10 +5588,12 @@ Organization : OptiHK Limited
selectEdgeById(edge.id, event.shiftKey);
}, [rulerMode, selectEdgeById]);
// Forward canvas mouse-down events to route-edge selection logic.
const handleCanvasMouseDown = useCallback((event) => {
handleRouteEdgeMouseDown(event);
}, [handleRouteEdgeMouseDown]);
// Build the left-panel project tree from project pages, composites, and instances.
const projectTreeItems = useMemo(() => {
const items = [];
const projectPagesByName = new Map();
@@ -5560,6 +5662,7 @@ Organization : OptiHK Limited
return items;
}, [pages, library, projectCompositeMap, standaloneComposites, compositeTrees, activePageId]);
// Merge saved composite cells, built-in elements, primitives, and PDK entries for dragging.
const libraryWithCells = useMemo(() => {
const cellEntries = {};
pages
@@ -5610,10 +5713,12 @@ Organization : OptiHK Limited
};
}, [pages, library]);
// Serialize current page edges into bundle YAML with route metadata.
const buildBundlesYaml = useCallback((page) => {
return buildRouteBundlesYaml(page, technologyManifest);
}, [technologyManifest]);
// Block layout or GDS builds when same-type route crossings are present.
const validateRouteCrossings = useCallback((page) => {
if (!page || !Array.isArray(page.edges)) return true;
const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node]));
@@ -5632,6 +5737,7 @@ Organization : OptiHK Limited
return true;
}, [technologyManifest, addLog]);
// Serialize a canvas page into the mxPIC YAML file format.
const buildYamlForPage = useCallback((page) => {
if (!page) return '';
const header = `# =============================================
@@ -5673,6 +5779,7 @@ ${elementsBlock}
${bundlesBlock}`;
}, [currentProjectName, library, buildBundlesYaml]);
// Open or refresh a tab showing the generated SVG layout preview.
const openLayoutPreview = useCallback((cellName, svgUrl, layoutBounds) => {
if (!cellName || !svgUrl) return;
const layoutTabId = `layout-${currentProjectName}-${cellName}`;
@@ -5698,6 +5805,7 @@ ${bundlesBlock}`;
setActivePageId(layoutTabId);
}, [currentProjectName]);
// Save the active page, generate layout preview assets, and show the preview tab.
const handleBuildLayout = useCallback(async () => {
if (!activePage) return;
if (buildLayoutBusy) return;
@@ -5741,6 +5849,7 @@ ${bundlesBlock}`;
}
}, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
// Save YAML for every editable project/composite page without opening previews.
const handleSaveProjectLayouts = useCallback(async () => {
if (saveProjectBusy) return;
const savePages = pages.filter(page => page.type !== 'layoutPreview');
@@ -5774,6 +5883,7 @@ ${bundlesBlock}`;
}
}, [saveProjectBusy, pages, currentProjectName, buildYamlForPage, addLog]);
// Build project GDS output through the backend and open the download when ready.
const handleBuildGds = useCallback(async () => {
if (buildGdsBusy) return;
const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page));
@@ -5815,6 +5925,7 @@ ${bundlesBlock}`;
}
}, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
// Open composite cells when their placed instances are double-clicked.
const onNodeDoubleClick = useCallback((event, node) => {
if (node.data?.type === 'composite') {
openPage(node.data.componentName);
+6
View File
@@ -711,6 +711,7 @@ Organization : OptiHK Limited
const logTerminal = document.getElementById('log-terminal');
let technologies = [];
// Append a dashboard status message with a timestamp.
function addLog(message) {
const line = document.createElement('div');
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
@@ -718,6 +719,7 @@ Organization : OptiHK Limited
logTerminal.scrollTop = logTerminal.scrollHeight;
}
// Apply the selected dashboard theme class and persist it for later sessions.
function applyTheme(mode) {
document.body.classList.toggle('light-mode', mode === 'light');
themeToggle.textContent = mode === 'light' ? 'Dark Mode' : 'Bright Mode';
@@ -729,10 +731,12 @@ Organization : OptiHK Limited
applyTheme(document.body.classList.contains('light-mode') ? 'dark' : 'light');
});
// Navigate from the dashboard into the canvas editor for a saved project.
function openProject(name) {
window.location.href = `/canvas?project=${encodeURIComponent(name)}`;
}
// Load account profile details and available occupation choices.
async function loadProfile() {
try {
const response = await fetch('/api/profile');
@@ -809,6 +813,7 @@ Organization : OptiHK Limited
addLog('Password updated.');
});
// Fetch saved projects and render the dashboard project list.
async function loadProjects() {
try {
const response = await fetch('/api/projects');
@@ -889,6 +894,7 @@ Organization : OptiHK Limited
openProject(project.name);
});
// Fetch available foundry/technology choices for new project creation.
async function loadTechnologies() {
const response = await fetch('/api/technologies');
const data = await response.json();
+1
View File
@@ -416,6 +416,7 @@ Organization : OptiHK Limited
<script>
const themeToggle = document.getElementById('theme-toggle');
// Apply the selected login-page theme class and persist it for the next visit.
function applyTheme(mode) {
document.body.classList.toggle('light-mode', mode === 'light');
themeToggle.textContent = mode === 'light' ? 'Dark Mode' : 'Bright Mode';
+5
View File
@@ -6,12 +6,17 @@ Organization : OptiHK Limited
#>
$ErrorActionPreference = "Stop"
# Provide a development-safe secret when the deployment environment has not
# supplied one yet.
if (-not $env:MXPIC_SECRET_KEY) {
$env:MXPIC_SECRET_KEY = "replace-this-with-a-long-random-office-secret"
}
# Bind the Flask server to the intranet-facing interface with production debug
# behavior disabled.
$env:MXPIC_HOST = "0.0.0.0"
$env:MXPIC_PORT = "3000"
$env:MXPIC_DEBUG = "0"
# Start the backend API and static frontend server.
python backend\server.py