updated with github #5

Merged
PotatoMaxwell merged 19 commits from qinyue_main into develope 2026-06-01 05:21:23 +00:00
36 changed files with 2470 additions and 676 deletions
Showing only changes of commit ce7f6e95c4 - Show all commits
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+107 -2
View File
@@ -1,6 +1,6 @@
# -----------------------------------------------------------------------------
# Description: Backend integration wrapper for project GDS generation and build result handling.
# Inside functions: build_project_gds, _build_with_mxpic_router, _load_project_cells, _ordered_cell_names, _cells_have_links, _build_with_gdstk, _import_public_gds, _build_with_nazca, _safe_cell_name, _library_cell_by_name, _number
# Inside functions: build_project_gds, _build_with_mxpic_router, _load_project_cells, _ordered_cell_names, _cells_have_links, _cells_have_elements, _build_with_gdstk, _import_public_gds, _build_with_nazca, _build_nazca_element_cells, _build_nazca_element_cell, _element_port_offset, _safe_cell_name, _library_cell_by_name, _int, _number
# Developer : Qin Yue @ 2026
# Organization : OptiHK Limited
# -----------------------------------------------------------------------------
@@ -58,7 +58,19 @@ def build_project_gds(
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
# Port/Anchor elements are logical pin-bearing devices. Use Nazca for these
# layouts so each element can be a placed cell with local nd.Pin metadata.
if _cells_have_elements(cells):
try:
return _build_with_nazca(cells, output_path, registry)
except ImportError as nazca_error:
raise RuntimeError(
"Build GDS with Port/Anchor elements requires Nazca so element cells can preserve nd.Pin metadata. "
f"Nazca import failed: {nazca_error}"
) from nazca_error
# gdstk is the preferred fallback for placement-only projects without
# Port/Anchor element pin metadata; Nazca remains a secondary fallback for
# environments where gdstk is not installed.
try:
return _build_with_gdstk(cells, output_path, registry)
@@ -132,6 +144,16 @@ def _cells_have_links(cells: Dict[str, dict]) -> bool:
return False
def _cells_have_elements(cells: Dict[str, dict]) -> bool:
"""Detect whether any saved cell contains Port/Anchor element objects."""
for data in cells.values():
elements = data.get("elements") or {}
for element in elements.values():
if str((element or {}).get("type") or "").lower() in {"port", "anchor"}:
return True
return False
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
@@ -196,6 +218,7 @@ def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkReg
# is exported as the top-level GDS.
for cell_name in ordered_names:
data = cells[cell_name]
element_cells = _build_nazca_element_cells(nd, cell_name, data.get("elements") or {}, built_cells)
with nd.Cell(cell_name) as current_cell:
for instance_name, instance in (data.get("instances") or {}).items():
component = str(instance.get("component") or "")
@@ -211,6 +234,12 @@ def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkReg
continue
loaded = nd.load_gds(asset.gds_path)
loaded.put(x, y, rotation)
for element_name, element_cell in element_cells.items():
element = (data.get("elements") or {}).get(element_name) or {}
x = _number(element.get("x"))
y = _number(element.get("y"))
rotation = _number(element.get("angle"))
element_cell.put(x, y, rotation)
built_cells[cell_name] = current_cell
top_name = ordered_names[-1]
@@ -218,6 +247,72 @@ def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkReg
return BuildResult(output_path=output_path, engine="nazca", cells_built=ordered_names, warnings=warnings)
def _build_nazca_element_cells(nd, parent_cell_name: str, elements: dict, existing_cells: dict) -> Dict[str, object]:
"""Build reusable Nazca cells for built-in Port and Anchor element objects."""
element_cells = {}
known_cells = dict(existing_cells or {})
for element_name, element in (elements or {}).items():
element_type = str((element or {}).get("type") or "").lower()
if element_type not in {"port", "anchor"}:
continue
raw_cell_name = f"{parent_cell_name}_{element_name}_{element_type}"
cell_name = _safe_cell_name(raw_cell_name, {**known_cells, **element_cells})
element_cell = _build_nazca_element_cell(nd, cell_name, {**(element or {}), "name": str(element_name)})
element_cells[str(element_name)] = element_cell
return element_cells
def _build_nazca_element_cell(nd, cell_name: str, element: dict):
"""Create one local Port or Anchor cell whose pins are placed in local coordinates."""
element = element or {}
element_type = str(element.get("type") or "").lower()
width = _number(element.get("width"), 0.5)
port_number = max(1, _int(element.get("pin_number", element.get("pinNumber", element.get("port_number", element.get("portNumber")))), 1))
pitch = _number(element.get("pitch"), 10.0)
with nd.Cell(name=cell_name) as element_cell:
if element_type == "port":
for index, pin_name in enumerate(_element_pin_names(cell_name, element, port_number, "port")):
y = 0.0 if port_number == 1 else _element_port_offset(index, port_number, pitch)
nd.Pin(pin_name, width=width).put(0.0, y, 180.0)
return element_cell
anchor_pin_names = _element_pin_names(cell_name, element, port_number, "anchor")
for index in range(port_number):
y = -15.0 + _element_port_offset(index, port_number, pitch)
nd.Pin(anchor_pin_names[index * 2], width=width).put(0.0, y, 180.0)
nd.Pin(anchor_pin_names[index * 2 + 1], width=width).put(0.0, y, 0.0)
return element_cell
def _element_port_offset(index: int, count: int, pitch: float) -> float:
"""Return the local y offset for one repeated Port/Anchor pin."""
return ((max(1, count) - 1) / 2 - index) * pitch
def _element_pin_names(cell_name: str, element: dict, count: int, element_type: str) -> List[str]:
"""Return local element pin names, using YAML overrides when present."""
raw_pins = [pin for pin in (element.get("pins") or []) if isinstance(pin, dict)]
default_base = _safe_pin_name(str(element.get("name") or cell_name).replace("_port", "").replace("_anchor", ""))
roles = []
if element_type == "port":
roles = [f"io{index + 1}" for index in range(count)]
else:
for index in range(count):
roles.extend([f"a{index + 1}", f"b{index + 1}"])
by_role = {str(pin.get("role") or ""): str(pin.get("name") or "") for pin in raw_pins}
by_index = [str(pin.get("name") or "") for pin in raw_pins]
names = []
for index, role in enumerate(roles):
name = by_role.get(role) or (by_index[index] if index < len(by_index) else "") or f"{default_base}_{role}"
names.append(_safe_pin_name(name))
return names
def _safe_pin_name(name: str) -> str:
"""Generate a backend-safe pin name for local Nazca element pins."""
return "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(name or "pin"))
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"
@@ -238,6 +333,16 @@ def _library_cell_by_name(library, name: str):
return None
def _int(value, default=0) -> int:
"""Convert integer YAML values with a stable default."""
try:
if value is None or value == "":
return default
return int(float(value))
except (TypeError, ValueError):
return default
def _number(value, default=0.0) -> float:
"""Convert numeric YAML values to floats with a stable default."""
try:
+18 -1
View File
@@ -107,7 +107,24 @@ class PdkRegistry:
if not yaml_path:
return None
with open(yaml_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
data = yaml.safe_load(file) or {}
return self._normalize_pdk_pins(data)
def _normalize_pdk_pins(self, data: dict) -> dict:
"""Treat legacy PDK `ports` metadata as `pins` inside approved PDK roots."""
if (
self._allows_legacy_ports_as_pins()
and isinstance(data, dict)
and "pins" not in data
and isinstance(data.get("ports"), dict)
):
data = dict(data)
data["pins"] = data["ports"]
return data
def _allows_legacy_ports_as_pins(self) -> bool:
normalized = self.public_root.replace("\\", "/").lower()
return "/opt_pdk_public/" in f"{normalized}/" or "/opt_pdk_atlas/" in f"{normalized}/"
def _inside_root(self, path: str) -> bool:
"""Check that a candidate asset path remains inside the permitted PDK root."""
+7 -1
View File
@@ -360,7 +360,13 @@ def readCompYaml(compName, comps_root=None):
if ymlFiles:
ymlPath = os.path.join(root, ymlFiles[0])
with open(ymlPath, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
data = yaml.safe_load(f) or {}
normalized_root = os.path.abspath(search_root).replace("\\", "/").lower()
pdk_ports_allowed = "/opt_pdk_public/" in f"{normalized_root}/" or "/opt_pdk_atlas/" in f"{normalized_root}/"
if pdk_ports_allowed and isinstance(data, dict) and "pins" not in data and isinstance(data.get("ports"), dict):
data = dict(data)
data["pins"] = data["ports"]
return data
return None
@@ -1,4 +1,4 @@
{
"name": "mxpic_project_1",
"name": "MZM_TX",
"technology": "Silterra/EMO1_2ML_CU_Al_RDL"
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 465 KiB

+102
View File
@@ -0,0 +1,102 @@
# =============================================
# mxPIC Cell/Project Definition File
# =============================================
schema_version: "2.0.0"
kind: cell
coordinate_system: gds_y_up
canvas_size:
width: 5000
height: 5000
project: MZM_TX
name: MZM_TX
type: project
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
pins: []
# 2. Instances (The sub-components dropped onto this canvas)
instances:
Spliter_1x4:
component: Spliter_1x4
x: 330.0
y: -630.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
MZM_1:
component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZI_SiN400_Si220_PIN_mod_1310_L1300_QY_202603
x: 750.0
y: -460.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
EC_1:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/edge_couplers/EC_SiN400_1310_0p5dB_L935_A0_QY_202604
x: 0.0
y: -920.0
rotation: 180.0
flip: 0
flop: 0
mirror: false
settings:
length:
elements:
anchor_2:
type: anchor
x: 470.0
y: -840.0
angle: 0.0
pin_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
pins:
- name: anchor_2_a1
role: a1
- name: anchor_2_b1
role: b1
# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
- from: MZM_1:a1
to: Spliter_1x4:OutUp_io2
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: Spliter_1x4:OutUp_io1
to: MZM_1:a2
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_2:anchor_2_b1
to: EC_1:a1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_2:anchor_2_a1
to: Spliter_1x4:Input_io1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 179 KiB

@@ -0,0 +1,192 @@
# =============================================
# mxPIC Cell/Project Definition File
# =============================================
schema_version: "2.0.0"
kind: cell
coordinate_system: gds_y_up
canvas_size:
width: 500
height: 500
project: MZM_TX
name: Spliter_1x4
type: composite
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
pins:
- name: Input_io1
layer: WG_CORE
element: Input
pin: io1
x: 10.0
y: -110.0
angle: 180.0
width: 0.5
- name: OutUp_io1
layer: WG_CORE
element: OutUp
pin: io1
x: 335.0
y: -20.0
angle: 90.0
width: 0.5
- name: OutUp_io2
layer: WG_CORE
element: OutUp
pin: io2
x: 325.0
y: -20.0
angle: 90.0
width: 0.5
- name: port_4_io1
layer: WG_CORE
element: port_4
pin: io1
x: 350.0
y: -180.0
angle: 0.0
width: 0.5
# 2. Instances (The sub-components dropped onto this canvas)
instances:
MMI_2:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 90.0
y: -110.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
MMI_3:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 230.0
y: -90.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
elements:
Input:
type: port
x: 10.0
y: -110.0
angle: 0.0
pin_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
pins:
- name: Input_io1
role: io1
OutUp:
type: port
x: 330.0
y: -20.0
angle: -90.0
pin_number: 2
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
pins:
- name: OutUp_io1
role: io1
- name: OutUp_io2
role: io2
port_4:
type: port
x: 350.0
y: -180.0
angle: 180.0
pin_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
pins:
- name: port_4_io1
role: io1
anchor_1:
type: anchor
x: 250.0
y: -150.0
angle: 90.0
pin_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
pins:
- name: anchor_1_a1
role: a1
- name: anchor_1_b1
role: b1
# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
- from: MMI_2:a1
to: Input:Input_io1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_3:a1
to: MMI_2:b1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_3:b1
to: OutUp:OutUp_io1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_3:b1
to: OutUp:OutUp_io2
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_3:b2
to: OutUp:OutUp_io1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_2:b2
to: anchor_1:anchor_1_b1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_1:anchor_1_a1
to: port_4:port_4_io1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_1:anchor_1_a1
to: anchor_1:anchor_1_b1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

@@ -1,117 +0,0 @@
# =============================================
# mxPIC Cell/Project Definition File
# =============================================
schema_version: "2.0.0"
kind: cell
coordinate_system: gds_y_up
canvas_size:
width: 5000
height: 500
project: mxpic_project_1
name: canvas_1
type: composite
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
ports:
- name: port
layer: WG_CORE
x: 103.5
y: -127.3
angle: 180.0
width: 0.5
- name: port_1
layer: WG_CORE
x: 108.7
y: -252.6
angle: 180.0
width: 0.5
- name: port_2
layer: WG_CORE
x: 497.4
y: -131.6
angle: 0.0
width: 0.5
# 2. Instances (The sub-components dropped onto this canvas)
instances:
MMI_1:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 177.9
y: -252.1
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
MMI_2:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 356.7
y: -142.9
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
elements:
port:
type: port
x: 103.5
y: -127.3
angle: 0.0
port_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
port_1:
type: port
x: 108.7
y: -252.6
angle: 0.0
port_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
port_2:
type: port
x: 497.4
y: -131.6
angle: 180.0
port_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
- from: MMI_1:a1
to: port_1:port
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_1:b1
to: MMI_2:a1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: MMI_2:b1
to: port_2:port
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 195 KiB

@@ -1,71 +0,0 @@
# =============================================
# mxPIC Cell/Project Definition File
# =============================================
schema_version: "2.0.0"
kind: cell
coordinate_system: gds_y_up
canvas_size:
width: 5000
height: 5000
project: mxpic_project_1
name: mxpic_project_1
type: project
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
ports:
- name: port
layer: WG_CORE
x: 50.0
y: -150.0
angle: 0.0
width: 0.5
# 2. Instances (The sub-components dropped onto this canvas)
instances:
canvas_1:
component: canvas_1
x: 476.9
y: -1056.4
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
canvas_1_1:
component: canvas_1
x: 1139.8
y: -958.5
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
elements:
port:
type: port
x: 50.0
y: -150.0
angle: 180.0
port_number: 1
pitch: 10
layer: WG_CORE
width: 0.5
description: ""
# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
- from: canvas_1_1:port_1
to: canvas_1:port_2
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
Binary file not shown.
+177 -39
View File
@@ -22,8 +22,11 @@
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;
const PORT_LABEL_MIN_CHARS = 5;
const PORT_LABEL_CHAR_WIDTH = 7;
const PORT_LABEL_HORIZONTAL_PADDING = 12;
// Anchor body width used in the canvas visual representation.
const ANCHOR_NODE_WIDTH = 16;
// 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.
@@ -477,7 +480,7 @@
const nextY = x * sin + y * cos;
x = nextX;
y = nextY;
angle += rotation;
angle -= rotation;
}
return {
@@ -489,16 +492,86 @@
};
// Create ordered React Flow handles for all ports on a single visual side.
const buildSideHandles = (ports, side) => {
const clampPercent = (value) => roundPercent(Math.min(100, Math.max(0, value)));
const createCoordinateMetrics = (ports, boxSize) => {
const width = positiveNumber(boxSize && boxSize.width);
const height = positiveNumber(boxSize && boxSize.height);
if (!width || !height) return null;
const values = { x: [], y: [] };
ports.forEach(port => {
const info = port && port.info;
const x = Number(info && info.x);
const y = Number(info && info.y);
if (Number.isFinite(x)) values.x.push(x);
if (Number.isFinite(y)) values.y.push(y);
});
return { width, height, values };
};
const coordinateAxisMode = (axisValues, size) => {
if (!axisValues || axisValues.length === 0 || !size) return 'fallback';
const min = Math.min(...axisValues);
const max = Math.max(...axisValues);
const epsilon = Math.max(0.001, size * 0.001);
if (Math.abs(max - min) <= epsilon) {
return Math.abs(max) <= epsilon ? 'centered' : 'fallback';
}
if (min < -epsilon && max <= size / 2 + epsilon && min >= -size / 2 - epsilon) {
return 'centered';
}
if (min >= -epsilon && max <= size + epsilon) {
return 'positive';
}
return 'fallback';
};
const coordinatePercent = (info, axis, metrics) => {
if (!metrics) return null;
const value = Number(info && info[axis]);
if (!Number.isFinite(value)) return null;
const size = axis === 'x' ? metrics.width : metrics.height;
const mode = coordinateAxisMode(metrics.values[axis], size);
if (mode === 'centered') {
return axis === 'x'
? clampPercent(50 + (value / size) * 100)
: clampPercent(50 - (value / size) * 100);
}
if (mode === 'positive') {
return axis === 'x'
? clampPercent((value / size) * 100)
: clampPercent(100 - (value / size) * 100);
}
return null;
};
const buildSideHandles = (ports, side, metrics) => {
const vertical = side === 'left' || side === 'right';
return ports.map((port, index) => {
const explicitPercent = Number(port.info && port.info.handlePercent);
const percent = Number.isFinite(explicitPercent) ? explicitPercent : fallbackPercent(index, ports.length);
const exactPercent = coordinatePercent(port.info, vertical ? 'y' : 'x', metrics);
const percent = Number.isFinite(explicitPercent)
? explicitPercent
: exactPercent !== null
? exactPercent
: fallbackPercent(index, ports.length);
const percentValue = `${percent}%`;
const style = vertical
? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' }
: { left: percentValue, transform: side === 'top' ? 'translate(-50%, -50%)' : 'translate(-50%, 50%)' };
? {
left: side === 'left' ? 0 : '100%',
right: 'auto',
top: percentValue,
bottom: 'auto',
transform: 'translate(-50%, -50%)'
}
: {
left: percentValue,
right: 'auto',
top: side === 'top' ? 0 : '100%',
bottom: 'auto',
transform: 'translate(-50%, -50%)'
};
return {
name: port.name,
@@ -511,13 +584,18 @@
// Group transformed ports into canvas handles with side and position styling.
const buildPortHandles = (ports, transform) => {
const options = transform || {};
const grouped = { left: [], right: [], top: [], bottom: [] };
const allPorts = [];
Object.entries(ports || {}).forEach(([name, info]) => {
if (name === 'a0' || name === 'b0') return;
const transformedInfo = transformPortInfo(info, transform);
const transformedInfo = transformPortInfo(info, options);
const side = portSideFromAngle(transformedInfo.a);
grouped[side].push({ name, info: transformedInfo });
const port = { name, info: transformedInfo };
grouped[side].push(port);
allPorts.push(port);
});
const metrics = createCoordinateMetrics(allPorts, options.boxSize);
Object.values(grouped).forEach(sidePorts => {
sidePorts.sort((a, b) => {
@@ -529,10 +607,10 @@
});
return [
...buildSideHandles(grouped.left, 'left'),
...buildSideHandles(grouped.right, 'right'),
...buildSideHandles(grouped.top, 'top'),
...buildSideHandles(grouped.bottom, 'bottom')
...buildSideHandles(grouped.left, 'left', metrics),
...buildSideHandles(grouped.right, 'right', metrics),
...buildSideHandles(grouped.top, 'top', metrics),
...buildSideHandles(grouped.bottom, 'bottom', metrics)
];
};
@@ -615,6 +693,35 @@
return name || (node && node.id) || 'port';
};
const pinRoleFromElementPortName = (elementType, portName) => {
const name = String(portName || '');
if (elementType === 'anchor') {
const anchorMatch = name.match(/^([ab])(\d+)$/);
return anchorMatch ? `${anchorMatch[1]}${anchorMatch[2]}` : name;
}
const portMatch = name.match(/^port_(\d+)$/);
return portMatch ? `io${portMatch[1]}` : 'io1';
};
const defaultElementPinName = (elementName, role) => `${elementName}_${role}`;
const getElementPinName = (node, portName) => {
const data = (node && node.data) || {};
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
const elementName = getNodePortName(node);
const role = pinRoleFromElementPortName(elementType, portName);
return (data.pinNames && data.pinNames[role]) || defaultElementPinName(elementName, role);
};
const buildElementPinEntries = (node) => {
const data = (node && node.data) || {};
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
return Object.keys(buildElementPorts(elementType, data)).map(portName => {
const role = pinRoleFromElementPortName(elementType, portName);
return { role, name: getElementPinName(node, portName) };
});
};
// 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.
@@ -658,8 +765,13 @@
const portNumber = normalizePortNumber(data && data.portNumber);
const pitch = normalizePitch(data && data.pitch);
const handleClearance = Math.max(pitch, 14);
const portDisplayName = String((data && (data.portName || data.componentDisplayName || data.label)) || 'port');
const portWidth = Math.max(
PORT_NODE_SIZE,
PORT_LABEL_HORIZONTAL_PADDING + Math.max(PORT_LABEL_MIN_CHARS, portDisplayName.length) * PORT_LABEL_CHAR_WIDTH
);
return {
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : PORT_NODE_SIZE,
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : portWidth,
height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance)
};
};
@@ -695,7 +807,7 @@
if (portNumber > 1) {
const entries = [];
Array.from({ length: portNumber }, (_, index) => {
const y = -PORT_NODE_SIZE / 2 + elementPortOffset(index, portNumber, pitch);
const y = elementPortOffset(index, portNumber, pitch);
entries.push([`a${index + 1}`, { x: 0, y, a: 180, width }]);
entries.push([`b${index + 1}`, { x: 0, y, a: 0, width }]);
});
@@ -771,40 +883,41 @@
};
};
// Flip an internal standalone Port angle into the outward-facing cell port
// angle used when this canvas is placed as a component elsewhere.
// Export standalone Port pins as the outward-facing pin angle.
const externalPortAngle = (angle) => normalizeAngle(Number(angle ?? 0) + 180);
// Convert standalone port nodes into page-level layout ports.
const buildPageComponentPorts = (port, nodes) => {
// Convert standalone port nodes into page-level layout pins.
const buildPageComponentPins = (port, nodes) => {
const portNodes = (nodes || []).filter(isPortElementNode);
if (portNodes.length > 0) {
return portNodes.reduce((ports, node) => {
return portNodes.reduce((pins, node) => {
const data = node.data || {};
const baseName = getNodePortName(node);
const elementPorts = buildElementPorts('port', data);
const entries = Object.entries(elementPorts);
entries.forEach(([portName, portInfo]) => {
const exportName = entries.length === 1
? baseName
: `${baseName}_${portName.replace(/^port_/, '')}`;
const exportName = getElementPinName(node, portName);
const point = getNodePortCanvasPoint(node, portName) || {
x: Number((node.position && node.position.x) || 0),
y: Number((node.position && node.position.y) || 0)
};
ports[exportName] = {
pins[exportName] = {
element: baseName,
pin: pinRoleFromElementPortName('port', portName),
x: Number(point.x || 0),
y: Number(point.y || 0),
a: externalPortAngle(portInfo.a ?? data.angle ?? data.a ?? 0),
width: Number(portInfo.width || data.width || 0.5)
};
});
return ports;
return pins;
}, {});
}
if (!port) return {};
return {
port: {
port_io1: {
element: 'port',
pin: 'io1',
x: Number(port.x || 0),
y: Number(port.y || 0),
a: externalPortAngle(port.a || 0),
@@ -813,27 +926,34 @@
};
};
// Serialize standalone canvas ports into a layout ports YAML section.
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
const ports = buildPageComponentPorts(fallbackPort, nodes);
const entries = Object.entries(ports);
if (entries.length === 0) return 'ports: []';
// Backward-compatible helper name for callers that still use the old JS API.
const buildPageComponentPorts = buildPageComponentPins;
// Serialize standalone canvas pins into a layout pins YAML section.
const buildCanvasPinsYaml = (nodes, fallbackPort) => {
const pins = buildPageComponentPins(fallbackPort, nodes);
const entries = Object.entries(pins);
if (entries.length === 0) return 'pins: []';
const sourceNodes = new Map((nodes || []).filter(isPortElementNode).map(node => [getNodePortName(node), node]));
const lines = entries.map(([name, info]) => {
const data = (sourceNodes.get(name) && sourceNodes.get(name).data) || {};
const data = (sourceNodes.get(info.element) && sourceNodes.get(info.element).data) || {};
const description = data.description ? `\n description: ${toYamlScalar(data.description)}` : '';
return `- name: ${name}
${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'}
element: ${info.element}
pin: ${info.pin}
x: ${Number(info.x || 0).toFixed(1)}
y: ${canvasToLayoutY(info.y).toFixed(1)}
angle: ${Number(info.a || 0).toFixed(1)}
width: ${Number(info.width || 0.5)}${description}`;
});
return `ports:\n${lines.join('\n')}`;
return `pins:\n${lines.join('\n')}`;
};
const buildCanvasPortsYaml = buildCanvasPinsYaml;
// Maintain legacy single-port YAML export behavior for older callers.
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
const buildPortsYaml = (port) => buildCanvasPinsYaml([], port);
// Serialize built-in port and anchor nodes into layout element metadata.
const buildElementsYaml = (nodes) => {
@@ -845,16 +965,21 @@
const angle = data.elementType === 'port' ? data.angle : data.rotation;
const portNumber = normalizePortNumber(data.portNumber);
const pitch = normalizePitch(data.pitch);
const pinLines = buildElementPinEntries(node)
.map(pin => ` - name: ${pin.name}\n role: ${pin.role}`)
.join('\n');
return ` ${name}:
type: ${data.elementType}
x: ${Number((node.position && node.position.x) || 0).toFixed(1)}
y: ${canvasToLayoutY((node.position && node.position.y) || 0).toFixed(1)}
angle: ${Number(angle || 0).toFixed(1)}
port_number: ${portNumber}
pin_number: ${portNumber}
pitch: ${Number(pitch)}
layer: ${data.layer || 'WG_CORE'}
width: ${Number(data.width || 0.5)}
description: ${toYamlScalar(data.description || '')}`;
description: ${toYamlScalar(data.description || '')}
pins:
${pinLines}`;
});
return `elements:\n${lines.join('\n')}`;
};
@@ -872,8 +997,12 @@
const targetNode = nodeMap[edge.target];
const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source;
const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target;
const fromPort = edge.sourceHandle || 'unknown';
const toPort = edge.targetHandle || 'unknown';
const fromPort = sourceNode && sourceNode.data && sourceNode.data.elementType
? getElementPinName(sourceNode, edge.sourceHandle)
: edge.sourceHandle || 'unknown';
const toPort = targetNode && targetNode.data && targetNode.data.elementType
? getElementPinName(targetNode, edge.targetHandle)
: edge.targetHandle || 'unknown';
const route = createRouteSettings(manifest, edge.data && edge.data.route);
const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : [];
const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : [];
@@ -926,7 +1055,12 @@ ${linksYaml}`;
const ports = buildElementPorts('port', node.data);
const portInfo = ports && portName ? ports[portName] : ports.port;
if (!portInfo) return { x: roundMeasureValue(x), y: roundMeasureValue(y) };
const transformedInfo = transformPortInfo(portInfo, { rotation: 0 });
const data = node.data || {};
const transformedInfo = transformPortInfo(portInfo, {
rotation: data.angle ?? data.a ?? data.rotation ?? 0,
flip: Boolean(data.flip),
flop: Boolean(data.flop)
});
return {
x: roundMeasureValue(x + Number(transformedInfo.x || 0)),
y: roundMeasureValue(y - Number(transformedInfo.y || 0))
@@ -1127,12 +1261,16 @@ ${linksYaml}`;
getNodePortCanvasPoint,
buildPortHandles,
buildElementPorts,
buildElementPinEntries,
getElementPinName,
buildElementBoxSize,
buildBasicComponentPorts,
getBasicComponentMetadata,
buildInstanceYaml,
buildInstancesYaml,
buildPageComponentPorts,
buildPageComponentPins,
buildCanvasPinsYaml,
buildCanvasPortsYaml,
buildBundlesYaml,
buildPortsYaml,
+448 -136
View File
@@ -662,6 +662,18 @@ Organization : OptiHK Limited
transform: translateY(-1px);
}
.origin-select-btn {
border-color: rgba(45, 212, 191, 0.55);
color: var(--accent-green);
}
.origin-select-btn.active {
background: rgba(45, 212, 191, 0.16);
border-color: var(--accent-green);
color: var(--text-main);
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.2), 0 10px 20px rgba(45, 212, 191, 0.12);
}
body.light-mode .mini-btn {
background: var(--mini-button-bg);
border-color: rgba(30, 48, 69, 0.18);
@@ -697,7 +709,10 @@ Organization : OptiHK Limited
z-index: 10;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
max-width: calc(100% - 30px);
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
@@ -723,6 +738,71 @@ Organization : OptiHK Limited
color: #102033;
}
.coordinate-readout {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
min-width: 270px;
justify-content: center;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: rgba(13, 22, 38, 0.9);
color: var(--text-main);
box-shadow: 0 14px 28px var(--shadow);
backdrop-filter: blur(14px);
font: 600 0.62rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
pointer-events: none;
white-space: nowrap;
}
.coordinate-readout span {
color: var(--text-muted);
font-weight: 500;
}
body.light-mode .coordinate-readout {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(30, 48, 69, 0.16);
box-shadow: 0 14px 28px rgba(18, 32, 51, 0.12);
}
.origin-crosshair {
position: absolute;
z-index: 18;
width: 22px;
height: 22px;
transform: translate(-50%, -50%);
pointer-events: none;
}
.origin-crosshair::before,
.origin-crosshair::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.35);
}
.origin-crosshair::before {
width: 22px;
height: 1px;
transform: translate(-50%, -50%);
}
.origin-crosshair::after {
width: 1px;
height: 22px;
transform: translate(-50%, -50%);
}
.build-layout-btn {
position: absolute;
bottom: 20px;
@@ -1195,6 +1275,34 @@ Organization : OptiHK Limited
pointer-events: none;
}
.port-pin-label {
position: absolute;
z-index: 12;
pointer-events: none;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.34rem;
font-weight: 700;
line-height: 1;
color: var(--port-label-text);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.port-pin-label span {
display: block;
}
.anchor-node-shell {
position: relative;
font-family: 'IBM Plex Sans', sans-serif;
}
.anchor-visual-body {
position: relative;
box-sizing: border-box;
transform-origin: center center;
}
.build-progress {
position: absolute;
left: 50%;
@@ -1459,11 +1567,12 @@ Organization : OptiHK Limited
calculateCompositeBoxSize,
buildPortHandles,
buildElementPorts,
getElementPinName,
buildElementBoxSize,
getBasicComponentMetadata,
buildInstancesYaml,
buildPageComponentPorts,
buildCanvasPortsYaml,
buildCanvasPinsYaml,
buildElementsYaml,
buildBundlesYaml: buildRouteBundlesYaml,
normalizeAngle,
@@ -1599,9 +1708,9 @@ Organization : OptiHK Limited
}, [id, data.ports, data.componentName, data.boxSize]);
const baseHandleStyle = {
width: 8, height: 8,
width: 6, height: 6,
background: 'var(--bg-main)',
border: '2px solid var(--accent)',
border: '1px solid var(--accent)',
borderRadius: '50%',
};
const handlePositionMap = {
@@ -1610,27 +1719,32 @@ Organization : OptiHK Limited
top: Position.Top,
bottom: Position.Bottom
};
const portHandles = useMemo(
() => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[data.ports, data.rotation, data.flip, data.flop]
);
const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const portHandles = useMemo(
() => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop), boxSize: componentSize }),
[data.ports, data.rotation, data.flip, data.flop, componentSize]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const isAnchorElement = data.elementType === 'anchor';
const isBasicCompactComponent = isBasicComponent(data.componentName) && ['waveguide', 'taper', '90 bend'].includes(data.componentName);
const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize;
const componentVisualTransform = `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`;
const iconSize = createComponentSymbolMetrics(componentSize);
const portLabelStyle = (portHandle) => {
const base = { ...portHandle.style };
if (portHandle.position === 'left') {
return { ...base, right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
return { ...base, left: 'auto', right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
}
if (portHandle.position === 'right') {
return { ...base, left: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'left' };
return { ...base, left: 'calc(100% + 8px)', right: 'auto', transform: 'translateY(-50%)', textAlign: 'left' };
}
if (portHandle.position === 'top') {
return { ...base, bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
return { ...base, top: 'auto', bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
}
return { ...base, top: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
return { ...base, top: 'calc(100% + 8px)', bottom: 'auto', transform: 'translateX(-50%)', textAlign: 'center' };
};
return (
@@ -1651,7 +1765,7 @@ Organization : OptiHK Limited
height: visualSize.height,
minHeight: visualSize.height,
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
transform: `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
transform: componentVisualTransform,
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
...(isBasicCompactComponent ? {
padding: 0,
@@ -1684,22 +1798,34 @@ Organization : OptiHK Limited
)}
</div>
<div style={{
position: 'absolute', inset: 0,
width: componentSize.width,
height: visualSize.height,
pointerEvents: 'none'
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }}
/>
</React.Fragment>
))}
</div>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10 }}
/>
<Handle
type="target"
position={handlePositionMap[portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }}
/>
<React.Fragment key={`label-${portHandle.name}`}>
<span className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
{portHandle.name}
</span>
@@ -1727,16 +1853,34 @@ Organization : OptiHK Limited
// Renders standalone exported port elements with repeated port handles.
const pinLabelFromPortName = (portName) => {
const name = String(portName || '');
const portMatch = name.match(/^port_(\d+)$/);
if (portMatch) return portMatch[1];
if (name === 'port') return '1';
return name;
};
const PortNode = ({ id, data, selected }) => {
const angle = data.angle ?? 0;
const canvasAngle = -Number(angle || 0);
const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port';
const ports = buildElementPorts('port', data);
const elementSize = buildElementBoxSize(data);
const localHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }])
);
const localPortHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize }),
[localHandlePorts, elementSize]
);
const portHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0 }),
[localHandlePorts]
() => buildPortHandles(localHandlePorts, { rotation: canvasAngle }),
[localHandlePorts, canvasAngle]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const handlePositionMap = {
left: Position.Left,
@@ -1746,27 +1890,61 @@ Organization : OptiHK Limited
};
const baseHandleStyle = {
background: 'var(--accent)',
width: 6,
height: 6
width: 5,
height: 5
};
const pinLabelStyle = (portHandle) => {
const base = {
left: portHandle.style?.left,
right: portHandle.style?.right,
top: portHandle.style?.top,
bottom: portHandle.style?.bottom
};
if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 5px), -50%)' };
if (portHandle.position === 'right') return { ...base, transform: 'translate(5px, -50%)' };
if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 5px))' };
return { ...base, transform: 'translate(-50%, 5px)' };
};
const pinLabelTextStyle = {
transform: `rotate(${-canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`
};
return (
<div style={{
width: elementSize.width, height: elementSize.height, borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
color: selected ? 'white' : 'var(--accent)',
fontSize: 8, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${angle}deg)`,
}}>
<span>P</span>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle type="source" position={handlePositionMap[portHandle.position]} id={portHandle.name} style={{ ...baseHandleStyle, ...portHandle.style }} />
<Handle type="target" position={handlePositionMap[portHandle.position]} id={portHandle.name} style={{ ...baseHandleStyle, ...portHandle.style }} />
</React.Fragment>
))}
<div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}>
<div className="component-floating-label" title={portDisplayName}>
<strong>{portDisplayName}</strong>
<span>Port</span>
</div>
<div style={{
width: elementSize.width, height: elementSize.height, borderRadius: 7,
position: 'relative',
boxSizing: 'border-box',
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 8, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
}}>
{localPortHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
};
@@ -1776,17 +1954,18 @@ Organization : OptiHK Limited
const updateNodeInternals = useUpdateNodeInternals();
const anchorRotation = data.rotation || 0;
const anchorVisualRotation = -Number(anchorRotation || 0);
const anchorDisplayName = data.componentDisplayName || data.label || 'anchor';
const ports = buildElementPorts('anchor', data);
const elementSize = buildElementBoxSize(data);
const localAnchorHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: name.startsWith('a') || name.startsWith('left') ? 180 : 0 }])
);
const portHandles = useMemo(
() => buildPortHandles(localAnchorHandlePorts, { rotation: 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[localAnchorHandlePorts, data.flip, data.flop]
() => buildPortHandles(localAnchorHandlePorts, { rotation: 0, boxSize: elementSize, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[localAnchorHandlePorts, elementSize, data.flip, data.flop]
);
const anchorDirectionHandles = useMemo(
() => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])),
() => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: -Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])),
[localAnchorHandlePorts, anchorRotation, data.flip, data.flop]
);
const handlePositionMap = {
@@ -1796,8 +1975,8 @@ Organization : OptiHK Limited
bottom: Position.Bottom
};
const baseHandleStyle = {
width: 6,
height: 6,
width: 5,
height: 5,
background: 'var(--accent)',
border: '1px solid var(--bg-main)',
borderRadius: '50%'
@@ -1806,18 +1985,10 @@ Organization : OptiHK Limited
const name = String(portName || '');
return name.startsWith('a') || name.startsWith('left') ? 'left' : 'right';
};
const anchorPortVisualTop = (portName) => {
const match = String(portName || '').match(/(\d+)$/);
const index = match ? Math.max(1, Number(match[1])) : 1;
const portCount = Math.max(1, Math.floor(Number(data.portNumber || 1)));
if (portCount <= 1) return elementSize.height / 2;
const travel = Math.max(0, elementSize.height - baseHandleStyle.height);
return baseHandleStyle.height / 2 + ((index - 1) / (portCount - 1)) * travel;
};
const anchorHandleVisualStyle = (portHandle, zIndex) => {
const visualSide = anchorPortVisualSide(portHandle.name);
const localLeft = visualSide === 'left' ? 0 : elementSize.width;
const localTop = anchorPortVisualTop(portHandle.name);
const localTop = portHandle.style?.top || '50%';
return {
...baseHandleStyle,
zIndex,
@@ -1828,47 +1999,66 @@ Organization : OptiHK Limited
transform: 'translate(-50%, -50%)'
};
};
const pinLabelStyle = (portHandle) => {
const visualSide = anchorPortVisualSide(portHandle.name);
const localLeft = visualSide === 'left' ? 0 : elementSize.width;
const localTop = portHandle.style?.top || '50%';
return {
left: localLeft,
top: localTop,
right: 'auto',
bottom: 'auto',
transform: visualSide === 'left' ? 'translate(calc(-100% - 5px), -50%)' : 'translate(5px, -50%)'
};
};
const pinLabelTextStyle = {
transform: `rotate(${Number(anchorRotation || 0)}deg)`
};
useEffect(() => {
updateNodeInternals(id);
}, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]);
return (
<div style={{
position: 'relative',
width: elementSize.width,
height: elementSize.height,
borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: selected ? 'white' : 'var(--accent)',
fontSize: 10,
fontWeight: 800,
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${anchorVisualRotation}deg)`,
}}>
<span>A</span>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 10)}
/>
<Handle
type="target"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 5)}
/>
</React.Fragment>
))}
<div className="anchor-node-shell" style={{ width: elementSize.width, height: elementSize.height }}>
<div className="component-floating-label" title={anchorDisplayName}>
<strong>{anchorDisplayName}</strong>
<span>Anchor</span>
</div>
<div className="anchor-visual-body" style={{
width: elementSize.width,
height: elementSize.height,
borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 10,
fontWeight: 800,
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${anchorVisualRotation}deg)`,
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 10)}
/>
<Handle
type="target"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 5)}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
});
@@ -2919,7 +3109,7 @@ Organization : OptiHK Limited
onUpdateNode(selectedNode.id, {
data: {
basicArguments: nextArguments,
ports: metadata?.ports || {},
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : selectedNode.data?.boxSize
}
});
@@ -3558,6 +3748,10 @@ Organization : OptiHK Limited
const [rulerStartPoint, setRulerStartPoint] = useState(null);
const [rulerEndPoint, setRulerEndPoint] = useState(null);
const [rulerPreviewPoint, setRulerPreviewPoint] = useState(null);
const [mouseCanvasPoint, setMouseCanvasPoint] = useState(null);
const [mouseScreenPoint, setMouseScreenPoint] = useState(null);
const [canvasOrigin, setCanvasOrigin] = useState({ x: 0, y: 0 });
const [originPickMode, setOriginPickMode] = useState(false);
const [projectTechnology, setProjectTechnology] = useState('');
const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST);
const [currentLinkXsection, setCurrentLinkXsection] = useState('strip');
@@ -3565,6 +3759,7 @@ Organization : OptiHK Limited
const [clipboard, setClipboard] = useState({ nodes: [] });
const initializedRef = useRef(false);
const canvasViewportRef = useRef(null);
const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []);
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
@@ -3602,6 +3797,14 @@ Organization : OptiHK Limited
[rulerStartPoint, rulerActiveEndPoint]
);
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
const displayMousePoint = useMemo(() => (
mouseCanvasPoint
? {
x: Number((mouseCanvasPoint.x - canvasOrigin.x).toFixed(3)),
y: Number((mouseCanvasPoint.y - canvasOrigin.y).toFixed(3))
}
: null
), [mouseCanvasPoint, canvasOrigin]);
// Normalizes free-route control points and removes adjacent duplicates before storage.
const compactRoutePoints = useCallback((points) => {
return (points || [])
@@ -3743,7 +3946,7 @@ Organization : OptiHK Limited
const getAnchorHandleRouteDirection = useCallback((node, handleId) => {
if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null;
const handles = buildPortHandles(buildElementPorts('anchor', node.data), {
rotation: Number(node.data?.rotation || 0),
rotation: -Number(node.data?.rotation || 0),
flip: Boolean(node.data?.flip),
flop: Boolean(node.data?.flop)
});
@@ -4414,10 +4617,7 @@ Organization : OptiHK Limited
if (!element || typeof element !== 'object') return;
const elementType = element.type === 'anchor' ? 'anchor' : (element.type === 'port' ? 'port' : '');
if (!elementType) return;
if (elementType === 'port' && elementName === 'port' && Array.isArray(doc.ports) && doc.ports.length > 0) {
return;
}
const portNumberValue = Math.floor(Number(element.port_number ?? element.portNumber ?? 1));
const portNumberValue = Math.floor(Number(element.pin_number ?? element.pinNumber ?? element.port_number ?? element.portNumber ?? 1));
const portNumber = Number.isFinite(portNumberValue) ? Math.max(1, portNumberValue) : 1;
const pitchValue = Number(element.pitch ?? DEFAULT_ELEMENT_PITCH);
const pitch = Number.isFinite(pitchValue) ? Math.max(0, pitchValue) : DEFAULT_ELEMENT_PITCH;
@@ -4436,6 +4636,7 @@ Organization : OptiHK Limited
pitch,
layer: element.layer || 'WG_CORE',
description: element.description || '',
pinNames: Object.fromEntries((element.pins || []).map(pin => [pin.role, pin.name]).filter(([role, name]) => role && name)),
boxSize: buildElementBoxSize({ elementType, portNumber, pitch })
};
const nodeId = `element-${elementName}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
@@ -4479,6 +4680,14 @@ Organization : OptiHK Limited
return nodes;
}, []);
const resolveLoadedPinHandle = useCallback((node, pinName) => {
if (!node || !node.data?.elementType) return pinName;
const elementType = node.data.elementType === 'anchor' ? 'anchor' : 'port';
const ports = buildElementPorts(elementType, node.data);
const matched = Object.keys(ports || {}).find(portName => getElementPinName(node, portName) === pinName);
return matched || pinName;
}, []);
useEffect(() => {
const input = document.getElementById('open-yaml-input');
if (!input) return;
@@ -4547,7 +4756,7 @@ Organization : OptiHK Limited
componentDisplayName: instName,
type: isProject ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
@@ -4569,13 +4778,17 @@ Organization : OptiHK Limited
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (sourceId && targetId) {
const sourceNode = newNodes.find(node => node.id === sourceId);
const targetNode = newNodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
newEdges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
@@ -4591,8 +4804,8 @@ Organization : OptiHK Limited
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
const importedPort = Array.isArray(doc.ports) && doc.ports[0]
? { x: Number(doc.ports[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.ports[0].y) : Number(doc.ports[0].y || 0), a: Number(doc.ports[0].angle ?? doc.ports[0].a ?? 0), width: Number(doc.ports[0].width || 0.5) }
const importedPin = Array.isArray(doc.pins) && doc.pins[0]
? { x: Number(doc.pins[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.pins[0].y) : Number(doc.pins[0].y || 0), a: Number(doc.pins[0].angle ?? doc.pins[0].a ?? 0), width: Number(doc.pins[0].width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const newPage = {
id: newPageId,
@@ -4603,8 +4816,8 @@ Organization : OptiHK Limited
{
id: 'page-port',
type: 'portNode',
position: { x: importedPort.x, y: importedPort.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPort.a, width: importedPort.width || 0.5, layer: 'WG_CORE', description: '' },
position: { x: importedPin.x, y: importedPin.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPin.a, width: importedPin.width || 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
@@ -4612,7 +4825,7 @@ Organization : OptiHK Limited
...newNodes,
],
edges: newEdges,
port: importedPort,
port: importedPin,
};
setPages(prev => [...prev, newPage]);
@@ -4663,7 +4876,7 @@ Organization : OptiHK Limited
input.addEventListener('change', handleFile);
return () => input.removeEventListener('change', handleFile);
}, [library, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]);
}, [library, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
useEffect(() => {
setProjectCompositeMap(prev => {
@@ -4713,9 +4926,9 @@ Organization : OptiHK Limited
const pageFromYaml = (cellName, content, manifest, knownCompositeNames = new Set()) => {
const doc = jsyaml.load(content) || {};
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
const firstPort = Array.isArray(doc.ports) ? doc.ports[0] : null;
const pagePort = firstPort
? { x: Number(firstPort.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPort.y) : Number(firstPort.y || 0), a: Number(firstPort.angle ?? firstPort.a ?? 0), width: Number(firstPort.width || 0.5) }
const firstPin = Array.isArray(doc.pins) ? doc.pins[0] : null;
const pagePort = firstPin
? { x: Number(firstPin.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPin.y) : Number(firstPin.y || 0), a: Number(firstPin.angle ?? firstPin.a ?? 0), width: Number(firstPin.width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const nodeNameMap = {};
const nodes = [
@@ -4760,7 +4973,7 @@ Organization : OptiHK Limited
componentDisplayName: instName,
type: instIsComposite ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
@@ -4781,13 +4994,17 @@ Organization : OptiHK Limited
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (!sourceId || !targetId) return;
const sourceNode = nodes.find(node => node.id === sourceId);
const targetNode = nodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
edges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
@@ -4861,7 +5078,7 @@ Organization : OptiHK Limited
};
loadProject();
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]);
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
useEffect(() => {
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
@@ -4949,7 +5166,7 @@ Organization : OptiHK Limited
position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, boxSize),
data: {
...node.data,
ports: metadata.ports || {},
ports: metadata.pins || metadata.ports || {},
boxSize,
foundry: metadata.foundry || '',
process: metadata.process || ''
@@ -5273,7 +5490,7 @@ Organization : OptiHK Limited
type: 'composite',
category: null,
rotation: 0,
ports: parsedData.ports || {},
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
@@ -5324,7 +5541,7 @@ Organization : OptiHK Limited
type: 'composite',
category: null,
rotation: 0,
ports: parsedData.ports || {},
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
@@ -5377,7 +5594,7 @@ Organization : OptiHK Limited
libraryCategory: 'basic',
category: 'basic',
rotation: 0,
ports: metadata?.ports || {},
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : DEFAULT_COMPONENT_BOX_SIZE,
basicArguments
},
@@ -5585,13 +5802,16 @@ Organization : OptiHK Limited
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
} else {
setOriginPickMode(false);
}
return next;
});
}, []);
// Convert a pane click or pointer event into canvas ruler coordinates.
const eventToRulerPoint = useCallback((event) => {
// Convert a pane click or pointer event into canvas coordinates.
const eventToCanvasPoint = useCallback((event) => {
if (!reactFlowInstance || !event) return null;
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
return {
x: Number(Math.min(activeCanvasSize.width, Math.max(0, rawPoint.x)).toFixed(3)),
@@ -5599,12 +5819,41 @@ Organization : OptiHK Limited
};
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
const updateMouseCanvasPoint = useCallback((event) => {
if (!activePage || activePage.type === 'layoutPreview') return null;
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return null;
setMouseCanvasPoint(nextPoint);
const rect = canvasViewportRef.current?.getBoundingClientRect();
if (rect) {
setMouseScreenPoint({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
}
return nextPoint;
}, [activePage, eventToCanvasPoint]);
const toggleOriginPickMode = useCallback(() => {
setOriginPickMode(prev => {
const next = !prev;
if (next) {
setRulerMode(false);
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
}
return next;
});
}, []);
// Set ruler start/end points from canvas clicks.
const handleRulerPaneClick = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
event.preventDefault();
event.stopPropagation();
const nextPoint = eventToRulerPoint(event);
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return;
if (!rulerStartPoint || rulerEndPoint) {
setRulerStartPoint(nextPoint);
setRulerEndPoint(null);
@@ -5618,14 +5867,47 @@ Organization : OptiHK Limited
if (measurement) {
addLog(`Ruler distance: ${measurement.label}`);
}
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]);
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint, 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]);
const nextPoint = eventToCanvasPoint(event);
if (nextPoint) setRulerPreviewPoint(nextPoint);
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint]);
const chooseCanvasOriginFromEvent = useCallback((event) => {
if (!originPickMode || !activePage || activePage.type === 'layoutPreview') return false;
const nextPoint = updateMouseCanvasPoint(event) || eventToCanvasPoint(event);
if (!nextPoint) return false;
event.preventDefault();
event.stopPropagation();
setCanvasOrigin(nextPoint);
setOriginPickMode(false);
addLog(`Canvas origin: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`);
return true;
}, [originPickMode, activePage, updateMouseCanvasPoint, eventToCanvasPoint, addLog]);
const handleCanvasMouseMove = useCallback((event) => {
updateMouseCanvasPoint(event);
handleRulerMouseMove(event);
}, [updateMouseCanvasPoint, handleRulerMouseMove]);
const handleCanvasPaneClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasNodeClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasMouseLeave = useCallback(() => {
setMouseCanvasPoint(null);
setMouseScreenPoint(null);
}, []);
// Select a route edge by id with optional additive selection.
const selectEdgeById = useCallback((edgeId, additive = false) => {
@@ -5873,7 +6155,7 @@ type: ${page.type === 'project' ? 'project' : 'composite'}
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
${buildCanvasPortsYaml(page.nodes)}
${buildCanvasPinsYaml(page.nodes)}
# 2. Instances (The sub-components dropped onto this canvas)
instances:`;
@@ -6098,8 +6380,11 @@ ${bundlesBlock}`;
))}
</div>
<div
ref={canvasViewportRef}
style={{ flex: 1, position: 'relative' }}
onMouseDownCapture={handleCanvasMouseDown}
onMouseMoveCapture={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
>
<div className="canvas-toolbar">
<span className="grid-snap-label">Snap to Grid</span>
@@ -6154,6 +6439,14 @@ ${bundlesBlock}`;
<button className="mini-btn" onClick={toggleRulerMode} aria-pressed={rulerMode ? 'true' : 'false'}>
{rulerMode ? 'Ruler On' : 'Ruler'}
</button>
<button
className={`mini-btn origin-select-btn ${originPickMode ? 'active' : ''}`}
onClick={toggleOriginPickMode}
aria-pressed={originPickMode ? 'true' : 'false'}
title="Select canvas origin"
>
{originPickMode ? 'Picking Origin' : 'Origin Select'}
</button>
</div>
{buildProgress.active && (
@@ -6176,6 +6469,25 @@ ${bundlesBlock}`;
</div>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<div
className="coordinate-readout"
style={{ bottom: rulerMode ? 58 : 18 }}
title={`Origin (${canvasOrigin.x.toFixed(3)}, ${canvasOrigin.y.toFixed(3)}) um`}
>
X {displayMousePoint ? displayMousePoint.x.toFixed(3) : '--'} um
Y {displayMousePoint ? displayMousePoint.y.toFixed(3) : '--'} um
<span>O {canvasOrigin.x.toFixed(3)}, {canvasOrigin.y.toFixed(3)}</span>
</div>
)}
{originPickMode && mouseScreenPoint && activePage?.type !== 'layoutPreview' && (
<div
className="origin-crosshair"
style={{ left: mouseScreenPoint.x, top: mouseScreenPoint.y }}
/>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<button
onClick={handleBuildLayout}
@@ -6196,12 +6508,12 @@ ${bundlesBlock}`;
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleBasicConnection}
onPaneClick={handleRulerPaneClick}
onPaneMouseMove={handleRulerMouseMove}
onPaneClick={handleCanvasPaneClick}
onPaneMouseMove={handleCanvasMouseMove}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={handleRulerPaneClick}
onNodeMouseMove={handleRulerMouseMove}
onNodeClick={handleCanvasNodeClick}
onNodeMouseMove={handleCanvasMouseMove}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseDown={onNodeMouseDown}
onEdgeMouseDown={handleReactFlowEdgeMouseDown}
+2 -2
View File
@@ -16,8 +16,8 @@ assert(
'canvas.html should use buildInstancesYaml for layout instance export'
);
assert(
canvasHtml.includes('buildCanvasPortsYaml(page.nodes)'),
'canvas.html should export ports from active canvas port nodes'
canvasHtml.includes('buildCanvasPinsYaml(page.nodes)'),
'canvas.html should export pins from active canvas port nodes'
);
assert(
canvasHtml.includes('buildPageComponentPorts(page.port, page.nodes)'),
+147 -14
View File
@@ -24,8 +24,12 @@ assert.deepStrictEqual(handles.filter(handle => handle.position === 'right').map
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2b').position, 'top');
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2a').position, 'bottom');
assert.strictEqual(handles.find(handle => handle.name === 'a1').style.top, '15%');
assert.strictEqual(handles.find(handle => handle.name === 'a1').style.left, 0);
assert.strictEqual(handles.find(handle => handle.name === 'a2').style.top, '85%');
assert.strictEqual(handles.find(handle => handle.name === 'b1').style.left, '100%');
assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.left, '50%');
assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.top, 0);
assert.strictEqual(handles.find(handle => handle.name === 'ep2a').style.top, '100%');
const uniformLeftHandles = helpers.buildPortHandles({
p_top: { x: -10, y: 300, a: 180 },
@@ -37,6 +41,46 @@ assert.deepStrictEqual(
['15%', '50%', '85%'],
'ports on the same side should be uniformly spaced after sorting'
);
const exactCenteredHandles = helpers.buildPortHandles({
a1: { x: -91.7, y: 4.475, a: 180 },
a2: { x: -91.7, y: -4.475, a: 180 },
b1: { x: 91.7, y: 4.475, a: 0 },
b2: { x: 91.7, y: -4.475, a: 0 },
}, { boxSize: { width: 183.4, height: 29.65 } });
assert.strictEqual(
exactCenteredHandles.find(handle => handle.name === 'a1').style.top,
'34.907%',
'box_size-aware handles should use exact centered PDK y coordinates instead of uniform spacing'
);
assert.strictEqual(exactCenteredHandles.find(handle => handle.name === 'a2').style.top, '65.093%');
assert.strictEqual(exactCenteredHandles.find(handle => handle.name === 'b1').style.left, '100%');
const exactPositiveHandles = helpers.buildPortHandles({
a1: { x: 0, y: 10, a: 180 },
b1: { x: 150.4, y: 10, a: 0 },
a2: { x: 0, y: -10, a: 180 },
b2: { x: 150.4, y: -10, a: 0 },
}, { boxSize: { width: 150.4, height: 40 } });
assert.strictEqual(exactPositiveHandles.find(handle => handle.name === 'a1').style.top, '25%');
assert.strictEqual(exactPositiveHandles.find(handle => handle.name === 'a2').style.top, '75%');
const exactPortObjectHandles = helpers.buildPortHandles({
port_1: { x: 0, y: 10, a: 0 },
port_2: { x: 0, y: 0, a: 0 },
port_3: { x: 0, y: -10, a: 0 },
}, { boxSize: { width: 47, height: 58 } });
assert.deepStrictEqual(
exactPortObjectHandles.map(handle => handle.style.top),
['32.759%', '50%', '67.241%'],
'standalone Port handles should use their actual y offsets inside the Port body'
);
const exactAnchorHandles = helpers.buildPortHandles(
helpers.buildElementPorts('anchor', { portNumber: 3, pitch: 10 }),
{ boxSize: { width: 16, height: 58 } }
);
assert.deepStrictEqual(
exactAnchorHandles.filter(handle => handle.name.startsWith('a')).map(handle => handle.style.top),
['32.759%', '50%', '67.241%'],
'standalone Anchor handles should be centered around the Anchor body'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_size: [946, 75] }),
@@ -168,6 +212,34 @@ assert.strictEqual(
'bottom',
'rotating a component should rotate vertical port handle sides'
);
const quarterTurnHandles = helpers.buildPortHandles({
out: { x: 50, y: 0, a: 0 },
up: { x: 0, y: 20, a: 90 },
}, { rotation: 90 });
assert.strictEqual(
quarterTurnHandles.find(handle => handle.name === 'out').position,
'bottom',
'90 degree canvas rotation should move a right-facing port direction to the bottom side'
);
assert.strictEqual(
quarterTurnHandles.find(handle => handle.name === 'up').position,
'right',
'90 degree canvas rotation should move a top-facing port direction to the right side'
);
const negativeQuarterTurnHandles = helpers.buildPortHandles({
out: { x: 50, y: 0, a: 0 },
down: { x: 0, y: -20, a: -90 },
}, { rotation: -90 });
assert.strictEqual(
negativeQuarterTurnHandles.find(handle => handle.name === 'out').position,
'top',
'-90 degree canvas rotation should move a right-facing port direction to the top side'
);
assert.strictEqual(
negativeQuarterTurnHandles.find(handle => handle.name === 'down').position,
'right',
'-90 degree canvas rotation should move a bottom-facing port direction to the right side'
);
const args = helpers.createForgeArguments();
assert(Object.keys(args).length >= 10);
@@ -230,8 +302,10 @@ assert.deepStrictEqual(
const ninetyBendHandles = helpers.buildPortHandles(helpers.buildBasicComponentPorts('90 bend', { radius: 15 }));
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').position, 'left');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').style.top, '50%');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'a1').style.left, 0);
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').position, 'top');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').style.left, '50%');
assert.strictEqual(ninetyBendHandles.find(handle => handle.name === 'b1').style.top, 0);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('180 bend', { radius: 15 }).box_size,
[25, 50],
@@ -318,14 +392,17 @@ assert(projectInstancesYaml.includes('cell_1:'));
assert(projectInstancesYaml.includes('component: canvas_1'));
const pagePortsYaml = helpers.buildPortsYaml({ x: 50, y: 150, a: 90 });
assert(pagePortsYaml.includes('- name: port'));
assert(pagePortsYaml.includes('pins:'));
assert(!pagePortsYaml.includes('ports:'));
assert(pagePortsYaml.includes('- name: port_io1'));
assert(pagePortsYaml.includes('pin: io1'));
assert(pagePortsYaml.includes('x: 50.0'));
assert(pagePortsYaml.includes('y: -150.0'));
assert(pagePortsYaml.includes('angle: -90.0'));
const componentPorts = helpers.buildPageComponentPorts({ x: 12, y: -6, a: 180 });
assert.deepStrictEqual(componentPorts, {
port: { x: 12, y: -6, a: 0, width: 0.5 }
port_io1: { element: 'port', pin: 'io1', x: 12, y: -6, a: 0, width: 0.5 }
});
const elementNodes = [
@@ -373,8 +450,9 @@ assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('port', { portNumbe
assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3, pitch: 10 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 });
assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 })), ['a1', 'b1', 'a2', 'b2']);
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).b2, { x: 0, y: -21, a: 0, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a2, { x: 0, y: -21, a: 180, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a1, { x: 0, y: 6, a: 180, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).b2, { x: 0, y: -6, a: 0, width: 0.5 });
assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a2, { x: 0, y: -6, a: 180, width: 0.5 });
assert.deepStrictEqual(
helpers.getNodePortCanvasPoint({
id: 'anchor-rotated',
@@ -391,10 +469,31 @@ assert.deepStrictEqual(
{ x: 115, y: 200 },
'Anchor port endpoint coordinates should rotate with the anchor body'
);
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 1 }), { width: 30, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 1 }), { width: 8, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 4, pitch: 10 }), { width: 8, height: 72 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 4, pitch: 10 }), { width: 30, height: 72 });
assert.deepStrictEqual(
helpers.getNodePortCanvasPoint({
id: 'port-rotated',
type: 'portNode',
position: { x: 100, y: 200 },
data: {
elementType: 'port',
angle: 180,
portNumber: 3,
pitch: 10,
ports: helpers.buildElementPorts('port', { angle: 180, portNumber: 3, pitch: 10 })
}
}, 'port_1'),
{ x: 100, y: 210 },
'Port pin endpoint coordinates should rotate with the Port body'
);
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 1 }), { width: 47, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 1 }), { width: 16, height: 30 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 4, pitch: 10 }), { width: 16, height: 72 });
assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 4, pitch: 10 }), { width: 47, height: 72 });
assert.deepStrictEqual(
helpers.buildElementBoxSize({ elementType: 'port', portName: 'input_port' }),
{ width: 82, height: 30 },
'Port object width should grow to fit the displayed port instance name'
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array',
@@ -403,14 +502,44 @@ assert.deepStrictEqual(
data: { componentDisplayName: 'array', elementType: 'port', portNumber: 3, pitch: 10, width: 0.6 }
}]),
{
array_1: { x: 100, y: 190, a: 180, width: 0.6 },
array_2: { x: 100, y: 200, a: 180, width: 0.6 },
array_3: { x: 100, y: 210, a: 180, width: 0.6 }
array_io1: { element: 'array', pin: 'io1', x: 100, y: 190, a: 180, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 100, y: 200, a: 180, width: 0.6 },
array_io3: { element: 'array', pin: 'io3', x: 100, y: 210, a: 180, width: 0.6 }
}
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array-rotated-90',
type: 'portNode',
position: { x: 100, y: 200 },
data: { componentDisplayName: 'array', elementType: 'port', angle: 90, portNumber: 2, pitch: 10, width: 0.6 }
}]),
{
array_io1: { element: 'array', pin: 'io1', x: 95, y: 200, a: -90, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 105, y: 200, a: -90, width: 0.6 }
}
);
assert.deepStrictEqual(
helpers.buildPageComponentPorts(null, [{
id: 'port-array-rotated',
type: 'portNode',
position: { x: 100, y: 200 },
data: { componentDisplayName: 'array', elementType: 'port', angle: 180, portNumber: 3, pitch: 10, width: 0.6 }
}]),
{
array_io1: { element: 'array', pin: 'io1', x: 100, y: 210, a: 0, width: 0.6 },
array_io2: { element: 'array', pin: 'io2', x: 100, y: 200, a: 0, width: 0.6 },
array_io3: { element: 'array', pin: 'io3', x: 100, y: 190, a: 0, width: 0.6 }
},
'Rotated Port object pins should export rotated coordinates along with their rotated angle'
);
const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes);
assert(canvasPortsYaml.includes('name: in0'));
assert(canvasPortsYaml.includes('pins:'));
assert(!canvasPortsYaml.includes('ports:'));
assert(canvasPortsYaml.includes('name: in0_io1'));
assert(canvasPortsYaml.includes('element: in0'));
assert(canvasPortsYaml.includes('pin: io1'));
assert(canvasPortsYaml.includes('description: "input port"'));
assert(canvasPortsYaml.includes('width: 0.7'));
assert(canvasPortsYaml.includes('y: -20.0'));
@@ -422,7 +551,11 @@ assert(elementsYaml.includes('type: port'));
assert(elementsYaml.includes('anchor_1:'));
assert(elementsYaml.includes('type: anchor'));
assert(elementsYaml.includes('y: -20.0'));
assert(elementsYaml.includes('port_number: 1'));
assert(elementsYaml.includes('pin_number: 1'));
assert(elementsYaml.includes('name: in0_io1'));
assert(elementsYaml.includes('role: io1'));
assert(elementsYaml.includes('name: anchor_1_a1'));
assert(elementsYaml.includes('role: a1'));
assert(elementsYaml.includes('pitch: 10'));
const instancesWithoutElements = helpers.buildInstancesYaml({
@@ -435,7 +568,7 @@ assert(instancesWithoutElements.includes('component_1:'));
assert(instancesWithoutElements.includes('y: -60.0'));
const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes);
assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 0, width: 0.7 });
assert.deepStrictEqual(multiPortComponentPorts.in0_io1, { element: 'in0', pin: 'io1', x: 10, y: 20, a: 0, width: 0.7 });
const technologyManifest = {
defaults: { xsection: 'strip', width: 0.45, radius: 10, routing_type: 'euler_bend' },
+26 -7
View File
@@ -90,22 +90,41 @@ assert(
gdsBuilderPy.includes('_cells_have_links') && gdsBuilderPy.includes('Routed Build GDS requires mxpic_router'),
'Build GDS should not silently fall back to unrouted gdstk when links are present'
);
assert(
gdsBuilderPy.includes('_cells_have_elements') &&
gdsBuilderPy.includes('Build GDS with Port/Anchor elements requires Nazca') &&
gdsBuilderPy.indexOf('if _cells_have_elements(cells):') < gdsBuilderPy.indexOf('return _build_with_gdstk'),
'Build GDS should route layouts with Port/Anchor elements through Nazca so element pins survive as cell metadata'
);
assert(
gdsBuilderPy.includes('_build_nazca_element_cells') &&
gdsBuilderPy.includes('_build_nazca_element_cell') &&
gdsBuilderPy.includes('element.get("pin_number"') &&
gdsBuilderPy.includes('_element_pin_names') &&
gdsBuilderPy.includes('nd.Pin(pin_name, width=width).put(0.0, y, 180.0)') &&
gdsBuilderPy.includes('nd.Pin(anchor_pin_names[index * 2], width=width).put(0.0, y, 180.0)') &&
gdsBuilderPy.includes('nd.Pin(anchor_pin_names[index * 2 + 1], width=width).put(0.0, y, 0.0)') &&
gdsBuilderPy.includes('element_cell.put(x, y, rotation)'),
'Nazca fallback should model Port and Anchor objects as placed element cells with named local nd.Pin definitions'
);
const routerDir = path.resolve(root, '..', 'mxpic_router', 'mxpic_router');
if (fs.existsSync(routerDir)) {
const routerLoaderPy = fs.readFileSync(path.join(routerDir, 'eda_loader.py'), 'utf8');
const routerBuilderPy = fs.readFileSync(path.join(routerDir, 'builder.py'), 'utf8');
assert(
routerLoaderPy.includes('port_number: int = 1') &&
routerLoaderPy.includes('pins: Dict[str, PinSpec]') &&
routerLoaderPy.includes('pin_number') &&
routerLoaderPy.includes('pitch: float = 10.0') &&
routerLoaderPy.includes('port_number=_int(element.get("port_number"'),
'mxpic_router loader should parse multi-port anchor metadata from exported elements'
routerLoaderPy.includes('pins=_pins(element.get("pins"))'),
'mxpic_router loader should parse pins-only layout metadata from exported elements'
);
assert(
routerBuilderPy.includes('for index in range(port_number):') &&
routerBuilderPy.includes('a{index + 1}') &&
routerBuilderPy.includes('b{index + 1}'),
'mxpic_router builder should register aN/bN pins for multi-port anchors'
routerBuilderPy.includes('_port_element_pin_entries') &&
routerBuilderPy.includes('_anchor_element_pin_entries') &&
routerBuilderPy.includes('_metadata_pins') &&
routerBuilderPy.includes('link.src_pin'),
'mxpic_router builder should register named element pins and route through pin endpoints'
);
}
+66 -11
View File
@@ -213,21 +213,20 @@ assert(
canvasHtml.includes('const anchorRotation = data.rotation || 0') &&
canvasHtml.includes('const anchorVisualRotation = -Number(anchorRotation || 0)') &&
canvasHtml.includes('transform: `rotate(${anchorVisualRotation}deg)`') &&
canvasHtml.includes('buildPortHandles(localAnchorHandlePorts, { rotation: 0') &&
canvasHtml.includes('buildPortHandles(localAnchorHandlePorts, { rotation: 0, boxSize: elementSize') &&
canvasHtml.includes('anchorDirectionHandles') &&
canvasHtml.includes('rotation: Number(anchorRotation || 0)') &&
canvasHtml.includes('rotation: -Number(anchorRotation || 0)') &&
canvasHtml.includes('anchorHandleVisualStyle(portHandle') &&
canvasHtml.includes('anchorPortVisualSide') &&
canvasHtml.includes('portHandle.name') &&
canvasHtml.includes('visualSide === \'left\' ? 0 : elementSize.width') &&
canvasHtml.includes('anchorPortVisualTop') &&
canvasHtml.includes('(index - 1) / (portCount - 1)') &&
canvasHtml.includes('elementSize.height - baseHandleStyle.height') &&
canvasHtml.includes('transform: \'translate(-50%, -50%)\'') &&
canvasHtml.includes("portHandle.style?.top || '50%'") &&
canvasHtml.includes('localLeft') &&
canvasHtml.includes('localTop') &&
canvasHtml.includes('handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]') &&
canvasHtml.includes('getAnchorHandleRouteDirection') &&
canvasHtml.includes('rotation: Number(node.data?.rotation || 0)') &&
canvasHtml.includes('rotation: -Number(node.data?.rotation || 0)') &&
canvasHtml.includes('directionToReactFlowPosition') &&
canvasHtml.includes('sourcePosition: directionToReactFlowPosition(sourceDirection)') &&
canvasHtml.includes('targetPosition: directionToReactFlowPosition(targetDirection)') &&
@@ -282,6 +281,16 @@ assert(
canvasHtml.includes('component-floating-label') && canvasHtml.includes('component-visual-body'),
'component labels should float outside the rotated body'
);
assert(
!canvasHtml.includes('const visualPortHandles = useMemo(') &&
canvasHtml.includes('buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop), boxSize: componentSize })') &&
canvasHtml.includes('const portDirectionMap = useMemo(') &&
canvasHtml.includes('position: \'absolute\', inset: 0') &&
canvasHtml.includes('pointerEvents: \'none\'') &&
canvasHtml.includes('pointerEvents: \'all\'') &&
canvasHtml.includes('handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]'),
'component port circles should use transformed pin positions so rendered sides follow pin angles'
);
assert(
canvasHtml.includes('canvasTextVisible') &&
canvasHtml.includes('toggleCanvasText') &&
@@ -353,11 +362,26 @@ assert(
assert(
canvasHtml.includes('Ruler') &&
canvasHtml.includes('rulerMode') &&
canvasHtml.includes('onPaneClick={handleRulerPaneClick}') &&
canvasHtml.includes('onNodeClick={handleRulerPaneClick}') &&
canvasHtml.includes('onPaneMouseMove={handleRulerMouseMove}'),
canvasHtml.includes('onPaneClick={handleCanvasPaneClick}') &&
canvasHtml.includes('onNodeClick={handleCanvasNodeClick}') &&
canvasHtml.includes('onPaneMouseMove={handleCanvasMouseMove}'),
'canvas should expose a ruler mode controlled from the top toolbar, allow measuring on component bodies, and preview to the mouse'
);
assert(
canvasHtml.includes('mouseCanvasPoint') &&
canvasHtml.includes('canvasOrigin') &&
canvasHtml.includes('originPickMode') &&
canvasHtml.includes('displayMousePoint') &&
canvasHtml.includes('toggleOriginPickMode') &&
canvasHtml.includes('onMouseMoveCapture={handleCanvasMouseMove}') &&
canvasHtml.includes('handleCanvasPaneClick') &&
canvasHtml.includes('handleCanvasNodeClick') &&
canvasHtml.includes('origin-select-btn') &&
canvasHtml.includes('Select canvas origin') &&
canvasHtml.includes('className="coordinate-readout"') &&
canvasHtml.includes('className="origin-crosshair"'),
'canvas should show live mouse coordinates and support one-click origin selection with a crosshair preview'
);
assert(
canvasHtml.includes('createRulerMeasurement') &&
canvasHtml.includes('rulerPointNode') &&
@@ -442,11 +466,42 @@ assert(
canvasHtml.includes('font-size: 0.4rem;') &&
canvasHtml.includes('font-size: 0.32rem;') &&
canvasHtml.includes("font: 600 0.5rem/1.35") &&
canvasHtml.includes('width: 8, height: 8') &&
canvasHtml.includes('width: 6,') &&
canvasHtml.includes('width: 6, height: 6') &&
canvasHtml.includes("border: '1px solid var(--accent)'") &&
canvasHtml.includes('width: 5,') &&
canvasHtml.includes('fontSize: 8'),
'canvas labels and port circles should render smaller than the previous sizing'
);
assert(
canvasHtml.includes('const portDisplayName = data.portName || data.componentDisplayName || data.label || \'port\';') &&
canvasHtml.includes('const canvasAngle = -Number(angle || 0);') &&
canvasHtml.includes('const pinLabelFromPortName =') &&
canvasHtml.includes('buildPortHandles(localHandlePorts, { rotation: canvasAngle })') &&
canvasHtml.includes('buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize })') &&
canvasHtml.includes('style={{ ...baseHandleStyle, ...portHandle.style }}') &&
canvasHtml.includes('borderRadius: 7') &&
canvasHtml.includes('boxSizing: \'border-box\'') &&
canvasHtml.includes('width: elementSize.width, height: elementSize.height, position: \'relative\'') &&
canvasHtml.includes('className="component-floating-label"') &&
canvasHtml.includes('className="port-pin-label"') &&
canvasHtml.includes('pinLabelFromPortName(portHandle.name)') &&
canvasHtml.includes('pinLabelStyle(portHandle') &&
canvasHtml.includes('transform: `rotate(${canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`') &&
canvasHtml.includes('{portDisplayName}'),
'standalone Port nodes should render pin labels at their circles while the port instance name floats outside the rotated body'
);
assert(
canvasHtml.includes('const anchorDisplayName = data.componentDisplayName || data.label || \'anchor\';') &&
canvasHtml.includes('className="anchor-node-shell"') &&
canvasHtml.includes('className="anchor-visual-body"') &&
canvasHtml.includes("const localLeft = visualSide === 'left' ? 0 : elementSize.width") &&
canvasHtml.includes("transform: 'translate(-50%, -50%)'") &&
canvasHtml.includes('pinLabelFromPortName(portHandle.name)') &&
canvasHtml.includes('pinLabelStyle(portHandle') &&
canvasHtml.includes('className="port-pin-label"') &&
canvasHtml.includes('{anchorDisplayName}'),
'standalone Anchor nodes should use the same outside name label and per-pin labels as Port nodes'
);
assert(
canvasHtml.includes('ParallelRouteEdge') &&
canvasHtml.includes('parallelOffset') &&