updated with github #5

Merged
PotatoMaxwell merged 19 commits from qinyue_main into develope 2026-06-01 05:21:23 +00:00
22 changed files with 729 additions and 353 deletions
Showing only changes of commit bf223b52ac - Show all commits
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