Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a230f13cd7 | |||
| 69da8c4fbb | |||
| 5db13a7d69 | |||
| d577edf348 | |||
| 7195dea7cd |
@@ -4,12 +4,11 @@
|
||||
# Developer : Qin Yue @ 2026
|
||||
# Organization : OptiHK Limited
|
||||
# -----------------------------------------------------------------------------
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
from typing import List
|
||||
|
||||
from layout_files import load_layout_cell_files, write_layout_cells_to_directory
|
||||
from router_dependency import require_router_stack
|
||||
|
||||
|
||||
@@ -31,16 +30,25 @@ def build_project_gds(
|
||||
prefer_full_gds: bool = False,
|
||||
) -> BuildResult:
|
||||
"""Build a hierarchical project GDS from saved cell YAML files with mxpic_router."""
|
||||
cells = _load_project_cells(project_dir)
|
||||
cells, warnings = _load_project_cells(project_dir)
|
||||
if not cells:
|
||||
raise ValueError("No saved cell YAML files found for this project")
|
||||
|
||||
return _build_with_mxpic_router(
|
||||
project_dir,
|
||||
output_path,
|
||||
pdk_public_root,
|
||||
technology_manifest_path,
|
||||
prefer_full_gds,
|
||||
with tempfile.TemporaryDirectory(prefix="mxpic_gds_project_") as staged_project_dir:
|
||||
write_layout_cells_to_directory(cells, staged_project_dir)
|
||||
result = _build_with_mxpic_router(
|
||||
staged_project_dir,
|
||||
output_path,
|
||||
pdk_public_root,
|
||||
technology_manifest_path,
|
||||
prefer_full_gds,
|
||||
)
|
||||
|
||||
return BuildResult(
|
||||
output_path=result.output_path,
|
||||
engine=result.engine,
|
||||
cells_built=result.cells_built,
|
||||
warnings=warnings + result.warnings,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,15 +78,6 @@ def _build_with_mxpic_router(
|
||||
)
|
||||
|
||||
|
||||
def _load_project_cells(project_dir: str) -> Dict[str, dict]:
|
||||
def _load_project_cells(project_dir: str):
|
||||
"""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")):
|
||||
continue
|
||||
path = os.path.join(project_dir, filename)
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
data = yaml.safe_load(file) or {}
|
||||
cell_name = str(data.get("name") or os.path.splitext(filename)[0])
|
||||
cells[cell_name] = data
|
||||
return cells
|
||||
return load_layout_cell_files(project_dir)
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,126 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Description: Layout YAML file filtering, parsing, and staging helpers.
|
||||
# Inside functions: is_layout_cell_filename, parse_layout_cell_content, read_layout_cell_file, load_layout_cell_files, layout_cell_filename, write_layout_cells_to_directory
|
||||
# Developer : Qin Yue @ 2026
|
||||
# Organization : OptiHK Limited
|
||||
# -----------------------------------------------------------------------------
|
||||
import os
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
LAYOUT_YAML_EXTENSIONS = (".yml", ".yaml")
|
||||
ROUTE_SIDECAR_SUFFIXES = (".routes.yml", ".routes.yaml")
|
||||
|
||||
|
||||
class LayoutFileError(ValueError):
|
||||
"""Raised when a saved layout YAML file cannot be used as a cell."""
|
||||
|
||||
|
||||
def is_layout_cell_filename(filename):
|
||||
"""Return True for user layout cell YAML files, excluding sidecars/manifests."""
|
||||
lower = (filename or "").lower()
|
||||
if lower == "technology.yml":
|
||||
return False
|
||||
if lower.endswith(ROUTE_SIDECAR_SUFFIXES):
|
||||
return False
|
||||
return lower.endswith(LAYOUT_YAML_EXTENSIONS)
|
||||
|
||||
|
||||
def is_layout_cell_document(data):
|
||||
"""Return True when parsed YAML looks like an mxPIC saved cell/project."""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
|
||||
kind = data.get("kind")
|
||||
if kind and str(kind).strip().lower() != "cell":
|
||||
return False
|
||||
|
||||
layout_keys = {
|
||||
"schema_version",
|
||||
"canvas_size",
|
||||
"canvasSize",
|
||||
"instances",
|
||||
"elements",
|
||||
"pins",
|
||||
"ports",
|
||||
"bundles",
|
||||
}
|
||||
return any(key in data for key in layout_keys)
|
||||
|
||||
|
||||
def parse_layout_cell_content(content, source="layout YAML"):
|
||||
"""Parse and validate saved layout YAML content."""
|
||||
try:
|
||||
data = yaml.safe_load(content or "") or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise LayoutFileError(f"{source} is not valid YAML: {exc}") from exc
|
||||
|
||||
if not is_layout_cell_document(data):
|
||||
raise LayoutFileError(f"{source} is not a saved mxPIC layout cell")
|
||||
return data
|
||||
|
||||
|
||||
def read_layout_cell_file(path):
|
||||
"""Read a saved layout cell file and return parsed data plus raw content."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
except OSError as exc:
|
||||
raise LayoutFileError(f"{os.path.basename(path)} could not be read: {exc}") from exc
|
||||
return parse_layout_cell_content(content, os.path.basename(path)), content
|
||||
|
||||
|
||||
def load_layout_cell_files(project_dir):
|
||||
"""Load valid layout cell files from a project directory and collect warnings."""
|
||||
cells = []
|
||||
warnings = []
|
||||
if not os.path.isdir(project_dir):
|
||||
return cells, warnings
|
||||
|
||||
for filename in sorted(os.listdir(project_dir)):
|
||||
if not is_layout_cell_filename(filename):
|
||||
continue
|
||||
path = os.path.join(project_dir, filename)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
data, content = read_layout_cell_file(path)
|
||||
except LayoutFileError as exc:
|
||||
warnings.append(f"Skipped {filename}: {exc}")
|
||||
continue
|
||||
|
||||
cells.append({
|
||||
"filename": filename,
|
||||
"name": str(data.get("name") or os.path.splitext(filename)[0]),
|
||||
"data": data,
|
||||
"content": content,
|
||||
})
|
||||
return cells, warnings
|
||||
|
||||
|
||||
def layout_cell_filename(cell_name, fallback="canvas_1.yml"):
|
||||
"""Build a safe filename for staging a parsed layout cell."""
|
||||
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(cell_name or "")).strip("._")
|
||||
if not name:
|
||||
return fallback
|
||||
return f"{name}.yml"
|
||||
|
||||
|
||||
def write_layout_cells_to_directory(cells, output_dir):
|
||||
"""Write valid layout cell contents into a clean staging directory."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
used_names = set()
|
||||
for index, cell in enumerate(cells, start=1):
|
||||
filename = layout_cell_filename(cell.get("name"), cell.get("filename") or f"cell_{index}.yml")
|
||||
base, ext = os.path.splitext(filename)
|
||||
unique_filename = filename
|
||||
counter = 1
|
||||
while unique_filename.lower() in used_names:
|
||||
counter += 1
|
||||
unique_filename = f"{base}_{counter}{ext}"
|
||||
used_names.add(unique_filename.lower())
|
||||
|
||||
with open(os.path.join(output_dir, unique_filename), "w", encoding="utf-8") as file:
|
||||
file.write(cell["content"])
|
||||
@@ -9,6 +9,7 @@ import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
from layout_files import load_layout_cell_files, write_layout_cells_to_directory
|
||||
from router_dependency import require_router_stack
|
||||
|
||||
|
||||
@@ -32,9 +33,20 @@ def create_routed_layout_svg(
|
||||
# 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:
|
||||
staged_project_dir = os.path.join(temp_dir, "project")
|
||||
saved_cells, _warnings = load_layout_cell_files(project_dir)
|
||||
staged_cells = [cell for cell in saved_cells if cell["name"] != cell_name]
|
||||
staged_cells.append({
|
||||
"filename": f"{cell_name}.yml",
|
||||
"name": cell_name,
|
||||
"data": layout,
|
||||
"content": yaml_content,
|
||||
})
|
||||
write_layout_cells_to_directory(staged_cells, staged_project_dir)
|
||||
|
||||
temp_gds = os.path.join(temp_dir, f"{cell_name}.gds")
|
||||
build_project_gds(
|
||||
project_dir=project_dir,
|
||||
project_dir=staged_project_dir,
|
||||
output_path=temp_gds,
|
||||
pdk_root=pdk_root,
|
||||
technology_manifest_path=technology_manifest_path,
|
||||
|
||||
@@ -18,6 +18,13 @@ from werkzeug.security import check_password_hash
|
||||
import database
|
||||
from flask import Response
|
||||
from gds_builder import build_project_gds
|
||||
from layout_files import (
|
||||
LayoutFileError,
|
||||
is_layout_cell_filename,
|
||||
load_layout_cell_files,
|
||||
parse_layout_cell_content,
|
||||
read_layout_cell_file,
|
||||
)
|
||||
from pdk_access import (
|
||||
cleanup_expired_exports,
|
||||
create_export_path,
|
||||
@@ -145,13 +152,17 @@ def cell_routes_path(project_name, cell_name):
|
||||
|
||||
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 {}
|
||||
layout = yaml_content if isinstance(yaml_content, dict) else parse_layout_cell_content(yaml_content)
|
||||
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():
|
||||
if not isinstance(bundle, dict):
|
||||
continue
|
||||
saved_links = []
|
||||
for link in bundle.get("links") or []:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
points = link.get("points") or []
|
||||
if not points:
|
||||
continue
|
||||
@@ -587,22 +598,24 @@ 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.
|
||||
# Each project is a folder and each valid layout YAML file inside that
|
||||
# folder is treated as one saved cell/canvas. Route sidecars and malformed
|
||||
# files are ignored so reopen stays resilient to stale runtime artifacts.
|
||||
for name in sorted(os.listdir(root)):
|
||||
path = os.path.join(root, name)
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
cells = []
|
||||
for filename in sorted(os.listdir(path)):
|
||||
if not filename.lower().endswith(('.yml', '.yaml')):
|
||||
if not is_layout_cell_filename(filename):
|
||||
continue
|
||||
cell_name = os.path.splitext(filename)[0]
|
||||
yml_path = os.path.join(path, filename)
|
||||
cells.append({
|
||||
"name": cell_name,
|
||||
"has_layout": os.path.exists(yml_path)
|
||||
})
|
||||
try:
|
||||
read_layout_cell_file(yml_path)
|
||||
except LayoutFileError:
|
||||
continue
|
||||
cells.append({"name": cell_name, "has_layout": True})
|
||||
meta = read_project_meta(name)
|
||||
projects.append({
|
||||
"name": name,
|
||||
@@ -648,24 +661,20 @@ def get_project(project_name):
|
||||
if not os.path.isdir(root):
|
||||
return jsonify({"error": "Project not found"}), 404
|
||||
|
||||
cells = []
|
||||
for filename in sorted(os.listdir(root)):
|
||||
if not filename.lower().endswith(('.yml', '.yaml')):
|
||||
continue
|
||||
cell_name = os.path.splitext(filename)[0]
|
||||
yml_path = os.path.join(root, filename)
|
||||
if not os.path.exists(yml_path):
|
||||
continue
|
||||
with open(yml_path, 'r', encoding='utf-8') as f:
|
||||
cells.append({
|
||||
"name": cell_name,
|
||||
"content": f.read()
|
||||
})
|
||||
loaded_cells, warnings = load_layout_cell_files(root)
|
||||
cells = [
|
||||
{
|
||||
"name": os.path.splitext(cell["filename"])[0],
|
||||
"content": cell["content"]
|
||||
}
|
||||
for cell in loaded_cells
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"name": safe_name(project_name, 'project_1'),
|
||||
"cells": cells,
|
||||
"technology": read_project_meta(project_name).get("technology")
|
||||
"technology": read_project_meta(project_name).get("technology"),
|
||||
"warnings": warnings
|
||||
})
|
||||
|
||||
|
||||
@@ -728,18 +737,19 @@ def rename_cell(project_name, cell_name):
|
||||
def save_layout():
|
||||
"""Persist a canvas layout YAML document and refresh its preview assets."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
data = request.get_json(silent=True) or {}
|
||||
project = safe_name(data.get('project'), 'project_1')
|
||||
cell = safe_name(data.get('cell'), 'canvas_1')
|
||||
content = data.get('content', '')
|
||||
create_preview = bool(data.get('preview', True))
|
||||
layout_doc = parse_layout_cell_content(content, f"{project}/{cell}.yml")
|
||||
|
||||
save_path = cell_file_path(project, cell)
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
with open(save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
write_route_points_sidecar(content, cell_routes_path(project, cell))
|
||||
write_route_points_sidecar(layout_doc, cell_routes_path(project, cell))
|
||||
|
||||
svg_path = None
|
||||
svg_version = None
|
||||
@@ -782,6 +792,8 @@ def save_layout():
|
||||
"preview_error": preview_error
|
||||
}), 200
|
||||
|
||||
except LayoutFileError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@@ -790,14 +790,11 @@
|
||||
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 : portWidth,
|
||||
height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance)
|
||||
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : PORT_NODE_SIZE,
|
||||
height: data && data.elementType === 'anchor'
|
||||
? Math.max(PORT_NODE_SIZE / 2, PORT_NODE_SIZE / 2 + Math.max(0, portNumber - 1) * handleClearance)
|
||||
: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1933,20 +1933,12 @@ Organization : OptiHK Limited
|
||||
const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port';
|
||||
const ports = buildElementPorts('port', data);
|
||||
const elementSize = buildElementBoxSize(data);
|
||||
const localHandlePorts = Object.fromEntries(
|
||||
const handlePorts = 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: canvasAngle }),
|
||||
[localHandlePorts, canvasAngle]
|
||||
);
|
||||
const portDirectionMap = useMemo(
|
||||
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
|
||||
[portHandles]
|
||||
() => buildPortHandles(handlePorts, { rotation: canvasAngle, boxSize: elementSize }),
|
||||
[handlePorts, canvasAngle, elementSize]
|
||||
);
|
||||
const handlePositionMap = {
|
||||
left: Position.Left,
|
||||
@@ -1955,9 +1947,12 @@ Organization : OptiHK Limited
|
||||
bottom: Position.Bottom
|
||||
};
|
||||
const baseHandleStyle = {
|
||||
background: 'var(--accent)',
|
||||
width: 5,
|
||||
height: 5
|
||||
background: selected ? '#f87171' : 'var(--danger)',
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
border: selected ? '2px solid white' : '1px solid rgba(239,68,68,0.5)',
|
||||
boxShadow: selected ? '0 0 8px rgba(239,68,68,0.5)' : 'none'
|
||||
};
|
||||
const pinLabelStyle = (portHandle) => {
|
||||
const base = {
|
||||
@@ -1966,13 +1961,13 @@ Organization : OptiHK Limited
|
||||
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)' };
|
||||
if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 8px), -50%)' };
|
||||
if (portHandle.position === 'right') return { ...base, transform: 'translate(8px, -50%)' };
|
||||
if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 8px))' };
|
||||
return { ...base, transform: 'translate(-50%, 8px)' };
|
||||
};
|
||||
const pinLabelTextStyle = {
|
||||
transform: `rotate(${-canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`
|
||||
transform: `scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`
|
||||
};
|
||||
return (
|
||||
<div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}>
|
||||
@@ -1980,37 +1975,25 @@ Organization : OptiHK Limited
|
||||
<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>
|
||||
{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 }}
|
||||
/>
|
||||
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
|
||||
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2246,24 +2229,73 @@ Organization : OptiHK Limited
|
||||
|
||||
// Displays generated layout SVG previews with zoom and pan controls.
|
||||
const LayoutSvgPreview = ({ page }) => {
|
||||
const [layoutScale, setLayoutScale] = useState(100);
|
||||
const [layoutScale, setLayoutScale] = useState(null);
|
||||
const [previewViewport, setPreviewViewport] = useState({ width: 1, height: 1 });
|
||||
const [svgSize, setSvgSize] = useState(null);
|
||||
const previewCanvasRef = useRef(null);
|
||||
const previewBounds = useMemo(
|
||||
() => page.layoutBounds || calculateLayoutBounds(page),
|
||||
[page.layoutBounds, page.nodes, page.canvasSize]
|
||||
);
|
||||
const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100));
|
||||
const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100;
|
||||
const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100;
|
||||
const minLayoutScale = 0.01;
|
||||
const maxLayoutScale = 800;
|
||||
const scalePrecision = 100;
|
||||
const clampLayoutScale = (value, fallback = 100) => {
|
||||
const numericValue = Number(value);
|
||||
const scale = Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallback;
|
||||
return Number(Math.min(maxLayoutScale, Math.max(minLayoutScale, scale)).toFixed(2));
|
||||
};
|
||||
const baseWidth = Math.max(1, svgSize?.width || previewBounds.width);
|
||||
const baseHeight = Math.max(1, svgSize?.height || previewBounds.height);
|
||||
const availableWidth = Math.max(1, previewViewport.width);
|
||||
const availableHeight = Math.max(1, previewViewport.height);
|
||||
const rawFitScalePercent = Math.min(availableWidth / baseWidth, availableHeight / baseHeight) * 100;
|
||||
const fitScalePercent = clampLayoutScale(Math.floor(rawFitScalePercent * scalePrecision) / scalePrecision);
|
||||
const normalizedScale = clampLayoutScale(layoutScale ?? fitScalePercent, fitScalePercent);
|
||||
const stageWidth = baseWidth * normalizedScale / 100;
|
||||
const stageHeight = baseHeight * normalizedScale / 100;
|
||||
|
||||
useEffect(() => {
|
||||
const previewCanvas = previewCanvasRef.current;
|
||||
if (!previewCanvas) return undefined;
|
||||
|
||||
const measurePreviewViewport = () => {
|
||||
const styles = window.getComputedStyle(previewCanvas);
|
||||
const paddingX = (parseFloat(styles.paddingLeft) || 0) + (parseFloat(styles.paddingRight) || 0);
|
||||
const paddingY = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0);
|
||||
setPreviewViewport({
|
||||
width: Math.max(1, previewCanvas.clientWidth - paddingX),
|
||||
height: Math.max(1, previewCanvas.clientHeight - paddingY)
|
||||
});
|
||||
};
|
||||
|
||||
measurePreviewViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measurePreviewViewport);
|
||||
return () => window.removeEventListener('resize', measurePreviewViewport);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(measurePreviewViewport);
|
||||
observer.observe(previewCanvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateScale = (value) => {
|
||||
setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100)));
|
||||
setLayoutScale(clampLayoutScale(value, fitScalePercent));
|
||||
};
|
||||
|
||||
const handleWheel = (event) => {
|
||||
event.preventDefault();
|
||||
const direction = event.deltaY > 0 ? -1 : 1;
|
||||
const step = event.shiftKey ? 5 : 15;
|
||||
setLayoutScale(current => Math.min(800, Math.max(10, (Number(current) || 100) + direction * step)));
|
||||
setLayoutScale(current => clampLayoutScale((current ?? fitScalePercent) + direction * step, fitScalePercent));
|
||||
};
|
||||
|
||||
const handleSvgLoad = (event) => {
|
||||
const image = event.currentTarget;
|
||||
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
||||
setSvgSize({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2273,9 +2305,9 @@ Organization : OptiHK Limited
|
||||
Scale
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="800"
|
||||
step="5"
|
||||
min={minLayoutScale}
|
||||
max={maxLayoutScale}
|
||||
step="0.01"
|
||||
value={normalizedScale}
|
||||
onChange={(event) => updateScale(event.target.value)}
|
||||
aria-label="Layout SVG preview scale"
|
||||
@@ -2284,9 +2316,9 @@ Organization : OptiHK Limited
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="800"
|
||||
step="5"
|
||||
min={minLayoutScale}
|
||||
max={maxLayoutScale}
|
||||
step="0.01"
|
||||
value={normalizedScale}
|
||||
onChange={(event) => updateScale(event.target.value)}
|
||||
aria-label="Layout SVG preview scale percent"
|
||||
@@ -2294,7 +2326,7 @@ Organization : OptiHK Limited
|
||||
%
|
||||
</label>
|
||||
</div>
|
||||
<div className="layout-preview-canvas" onWheel={handleWheel}>
|
||||
<div className="layout-preview-canvas" ref={previewCanvasRef} onWheel={handleWheel}>
|
||||
<div className="layout-preview-scroll-area">
|
||||
<div
|
||||
className="layout-preview-stage"
|
||||
@@ -2304,6 +2336,7 @@ Organization : OptiHK Limited
|
||||
className="layout-preview-image"
|
||||
src={page.svgUrl}
|
||||
alt={`${page.name} layout preview`}
|
||||
onLoad={handleSvgLoad}
|
||||
style={{ objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -4943,38 +4976,36 @@ Organization : OptiHK Limited
|
||||
}
|
||||
newNodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap));
|
||||
|
||||
if (!isProject) {
|
||||
forEachBundleLink(doc, (bundleName, bundle, link) => {
|
||||
const route = createRouteSettings(technologyManifest, { ...bundle, ...link, bundle_group: bundleName });
|
||||
const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up');
|
||||
if (link.from && link.to) {
|
||||
const [fromInst, fromPort] = link.from.split(':');
|
||||
const [toInst, toPort] = link.to.split(':');
|
||||
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}-${sourceHandle}-${targetId}-${targetHandle}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type: view.type,
|
||||
style: view.style,
|
||||
data: { route, points: routePoints },
|
||||
});
|
||||
}
|
||||
} else if (routePoints.length >= 2) {
|
||||
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||
newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route));
|
||||
forEachBundleLink(doc, (bundleName, bundle, link) => {
|
||||
const route = createRouteSettings(technologyManifest, { ...bundle, ...link, bundle_group: bundleName });
|
||||
const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up');
|
||||
if (link.from && link.to) {
|
||||
const [fromInst, fromPort] = link.from.split(':');
|
||||
const [toInst, toPort] = link.to.split(':');
|
||||
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}-${sourceHandle}-${targetId}-${targetHandle}`,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type: view.type,
|
||||
style: view.style,
|
||||
data: { route, points: routePoints },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (routePoints.length >= 2) {
|
||||
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||
newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route));
|
||||
}
|
||||
});
|
||||
|
||||
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
|
||||
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
|
||||
@@ -5211,11 +5242,20 @@ Organization : OptiHK Limited
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
(data.warnings || []).forEach(warning => addLog(warning));
|
||||
const technology = data.technology || '';
|
||||
setProjectTechnology(technology);
|
||||
const manifest = await loadTechnologyManifest(technology);
|
||||
const knownCompositeNames = new Set((data.cells || []).map(cell => cell.name).filter(name => name !== currentProjectName));
|
||||
const parsedCellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest, knownCompositeNames));
|
||||
const loadedCells = data.cells || [];
|
||||
const knownCompositeNames = new Set(loadedCells.map(cell => cell.name).filter(name => name !== currentProjectName));
|
||||
const parsedCellPages = [];
|
||||
loadedCells.forEach(cell => {
|
||||
try {
|
||||
parsedCellPages.push(pageFromYaml(cell.name, cell.content, manifest, knownCompositeNames));
|
||||
} catch (error) {
|
||||
addLog(`Skipped saved cell "${cell.name}": ${error.message}`);
|
||||
}
|
||||
});
|
||||
const compositeBoxSizes = new Map(parsedCellPages
|
||||
.filter(page => page.type === 'composite')
|
||||
.map(page => [page.name, calculateCompositeBoxSize(page)]));
|
||||
@@ -5277,7 +5317,7 @@ Organization : OptiHK Limited
|
||||
};
|
||||
|
||||
loadProject();
|
||||
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
|
||||
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle, addLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
|
||||
|
||||
@@ -61,7 +61,6 @@ assert(
|
||||
'save-layout response should include an svg_url for the new layout tab'
|
||||
);
|
||||
assert(
|
||||
<<<<<<< HEAD
|
||||
serverPy.includes('svg_ready') &&
|
||||
serverPy.includes('svg_version') &&
|
||||
serverPy.includes('file_version(svg_path)') &&
|
||||
@@ -74,8 +73,6 @@ assert(
|
||||
'save-layout should publish generated SVG previews atomically instead of serving partially written files'
|
||||
);
|
||||
assert(
|
||||
=======
|
||||
>>>>>>> jingwen_main
|
||||
serverPy.includes('RouterStackUnavailable') &&
|
||||
serverPy.includes('except RouterStackUnavailable as e') &&
|
||||
serverPy.includes('"preview_status": preview_status') &&
|
||||
|
||||
@@ -33,16 +33,6 @@ assert(
|
||||
'Build Layout should use the backend svg_url response'
|
||||
);
|
||||
assert(
|
||||
<<<<<<< HEAD
|
||||
canvasHtml.includes('result.svg_ready && result.svg_url') &&
|
||||
canvasHtml.includes('buildLayoutRequestRef') &&
|
||||
canvasHtml.includes('buildLayoutBusyRef') &&
|
||||
canvasHtml.includes("cache: 'no-store'"),
|
||||
'Build Layout should wait for a ready, versioned SVG response and prevent stale duplicate preview updates'
|
||||
);
|
||||
assert(
|
||||
=======
|
||||
>>>>>>> jingwen_main
|
||||
canvasHtml.includes('result.preview_error') &&
|
||||
canvasHtml.includes('Preview skipped: '),
|
||||
'Build Layout should log when the backend saves YAML but skips SVG preview because the router stack is unavailable'
|
||||
@@ -60,8 +50,18 @@ assert(
|
||||
'layout SVG preview should expose an editable scale value'
|
||||
);
|
||||
assert(
|
||||
canvasHtml.includes('objectFit: \'contain\''),
|
||||
'100% layout preview scale should fit the full SVG within the screen'
|
||||
canvasHtml.includes('objectFit: \'contain\'') &&
|
||||
canvasHtml.includes('previewCanvasRef') &&
|
||||
canvasHtml.includes('ResizeObserver') &&
|
||||
canvasHtml.includes('svgSize') &&
|
||||
canvasHtml.includes('naturalWidth') &&
|
||||
canvasHtml.includes('naturalHeight') &&
|
||||
canvasHtml.includes('const fitScalePercent = clampLayoutScale(Math.floor') &&
|
||||
canvasHtml.includes('layoutScale ?? fitScalePercent') &&
|
||||
canvasHtml.includes('const stageWidth = baseWidth * normalizedScale / 100') &&
|
||||
!canvasHtml.includes('useState(100)') &&
|
||||
!canvasHtml.includes('baseWidth * fitScale * normalizedScale / 100'),
|
||||
'layout preview should default to the actual fitted SVG scale percent instead of redefining 100% as fit'
|
||||
);
|
||||
assert(
|
||||
canvasHtml.includes('className="build-gds-btn"'),
|
||||
@@ -354,7 +354,7 @@ assert(
|
||||
canvasHtml.includes('layoutBounds') &&
|
||||
canvasHtml.includes('stageWidth') &&
|
||||
canvasHtml.includes('stageHeight'),
|
||||
'layout preview should mouse-wheel zoom and size 100% from calculated box_size layout bounds'
|
||||
'layout preview should mouse-wheel zoom from the actual calculated SVG scale'
|
||||
);
|
||||
assert(
|
||||
canvasHtml.includes('reactFlowInstance.fitBounds') &&
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Description: Static regression tests for resilient project save/reopen behavior.
|
||||
* Inside functions: N/A - assertion-based test/module script.
|
||||
* Developer : Qin Yue @ 2026
|
||||
* Organization : OptiHK Limited
|
||||
*/
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const backend = path.join(root, 'backend');
|
||||
const layoutFilesPath = path.join(backend, 'layout_files.py');
|
||||
const layoutFilesPy = fs.readFileSync(layoutFilesPath, 'utf8');
|
||||
const serverPy = fs.readFileSync(path.join(backend, 'server.py'), 'utf8');
|
||||
const gdsBuilderPy = fs.readFileSync(path.join(backend, 'gds_builder.py'), 'utf8');
|
||||
const routedPreviewPy = fs.readFileSync(path.join(backend, 'routed_layout_preview.py'), 'utf8');
|
||||
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
|
||||
|
||||
assert(
|
||||
fs.existsSync(layoutFilesPath),
|
||||
'backend/layout_files.py should centralize saved layout YAML filtering and parsing'
|
||||
);
|
||||
assert(
|
||||
layoutFilesPy.includes('ROUTE_SIDECAR_SUFFIXES') &&
|
||||
layoutFilesPy.includes('def is_layout_cell_filename') &&
|
||||
layoutFilesPy.includes('def parse_layout_cell_content') &&
|
||||
layoutFilesPy.includes('def load_layout_cell_files'),
|
||||
'layout file helpers should exclude route sidecars and validate saved layout cells'
|
||||
);
|
||||
assert(
|
||||
serverPy.includes('is_layout_cell_filename') &&
|
||||
serverPy.includes('load_layout_cell_files(root)') &&
|
||||
serverPy.includes('parse_layout_cell_content(content') &&
|
||||
serverPy.includes('except LayoutFileError as e'),
|
||||
'project list/load/save endpoints should filter invalid YAML and reject malformed saves'
|
||||
);
|
||||
assert(
|
||||
gdsBuilderPy.includes('TemporaryDirectory(prefix="mxpic_gds_project_"') &&
|
||||
gdsBuilderPy.includes('write_layout_cells_to_directory(cells, staged_project_dir)'),
|
||||
'Build GDS should call mxpic_router with a clean staged project directory'
|
||||
);
|
||||
assert(
|
||||
routedPreviewPy.includes('staged_project_dir') &&
|
||||
routedPreviewPy.includes('write_layout_cells_to_directory(staged_cells, staged_project_dir)') &&
|
||||
routedPreviewPy.includes('project_dir=staged_project_dir'),
|
||||
'Build Layout preview should stage valid cells before calling mxpic_router'
|
||||
);
|
||||
assert(
|
||||
canvasHtml.includes('(data.warnings || []).forEach(warning => addLog(warning))') &&
|
||||
canvasHtml.includes('const parsedCellPages = [];') &&
|
||||
canvasHtml.includes('Skipped saved cell'),
|
||||
'canvas project loading should report skipped files and keep loading remaining valid cells'
|
||||
);
|
||||