4 Commits

Author SHA1 Message Date
PotatoMaxwell 69da8c4fbb Merge pull request 'port/anchor size adjustment' (#10) from jingwen_main into qinyue_main
Reviewed-on: #10
2026-06-11 13:45:08 +00:00
xsxx03-art 5db13a7d69 port/anchor size adjustment 2026-06-11 20:45:26 +08:00
= d577edf348 Fix project reopen persistence 2026-06-10 19:10:59 +08:00
root 7195dea7cd SVG preview fitting size problem solved 2026-06-09 20:58:37 +08:00
9 changed files with 404 additions and 167 deletions
+17 -18
View File
@@ -4,12 +4,11 @@
# Developer : Qin Yue @ 2026 # Developer : Qin Yue @ 2026
# Organization : OptiHK Limited # Organization : OptiHK Limited
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import os import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import List
import yaml
from layout_files import load_layout_cell_files, write_layout_cells_to_directory
from router_dependency import require_router_stack from router_dependency import require_router_stack
@@ -31,18 +30,27 @@ def build_project_gds(
prefer_full_gds: bool = False, prefer_full_gds: bool = False,
) -> BuildResult: ) -> BuildResult:
"""Build a hierarchical project GDS from saved cell YAML files with mxpic_router.""" """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: if not cells:
raise ValueError("No saved cell YAML files found for this project") raise ValueError("No saved cell YAML files found for this project")
return _build_with_mxpic_router( with tempfile.TemporaryDirectory(prefix="mxpic_gds_project_") as staged_project_dir:
project_dir, write_layout_cells_to_directory(cells, staged_project_dir)
result = _build_with_mxpic_router(
staged_project_dir,
output_path, output_path,
pdk_public_root, pdk_public_root,
technology_manifest_path, technology_manifest_path,
prefer_full_gds, prefer_full_gds,
) )
return BuildResult(
output_path=result.output_path,
engine=result.engine,
cells_built=result.cells_built,
warnings=warnings + result.warnings,
)
def _build_with_mxpic_router( def _build_with_mxpic_router(
project_dir: str, project_dir: str,
@@ -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.""" """Load saved cell YAML documents from a project directory."""
cells = {} return load_layout_cell_files(project_dir)
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
+126
View File
@@ -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"])
+13 -1
View File
@@ -9,6 +9,7 @@ import tempfile
import yaml import yaml
from layout_files import load_layout_cell_files, write_layout_cells_to_directory
from router_dependency import require_router_stack 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 # Build into a temporary GDS first, then convert the generated top cell into
# the SVG preview consumed by the canvas. # the SVG preview consumed by the canvas.
with tempfile.TemporaryDirectory(prefix="mxpic_routed_preview_") as temp_dir: 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") temp_gds = os.path.join(temp_dir, f"{cell_name}.gds")
build_project_gds( build_project_gds(
project_dir=project_dir, project_dir=staged_project_dir,
output_path=temp_gds, output_path=temp_gds,
pdk_root=pdk_root, pdk_root=pdk_root,
technology_manifest_path=technology_manifest_path, technology_manifest_path=technology_manifest_path,
+36 -24
View File
@@ -18,6 +18,13 @@ from werkzeug.security import check_password_hash
import database import database
from flask import Response from flask import Response
from gds_builder import build_project_gds 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 ( from pdk_access import (
cleanup_expired_exports, cleanup_expired_exports,
create_export_path, create_export_path,
@@ -145,13 +152,17 @@ def cell_routes_path(project_name, cell_name):
def write_route_points_sidecar(yaml_content, output_path): def write_route_points_sidecar(yaml_content, output_path):
"""Extract route points from layout YAML and save them beside the cell.""" """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 = {} routes = {}
# The sidecar preserves manually edited route control points separately from # The sidecar preserves manually edited route control points separately from
# the main YAML file for tooling that wants route-only metadata. # the main YAML file for tooling that wants route-only metadata.
for bundle_name, bundle in (layout.get("bundles") or {}).items(): for bundle_name, bundle in (layout.get("bundles") or {}).items():
if not isinstance(bundle, dict):
continue
saved_links = [] saved_links = []
for link in bundle.get("links") or []: for link in bundle.get("links") or []:
if not isinstance(link, dict):
continue
points = link.get("points") or [] points = link.get("points") or []
if not points: if not points:
continue continue
@@ -587,22 +598,24 @@ def list_projects():
os.makedirs(root, exist_ok=True) os.makedirs(root, exist_ok=True)
projects = [] projects = []
# Each project is a folder and each YAML file inside that folder is treated # Each project is a folder and each valid layout YAML file inside that
# as one saved cell/canvas. # 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)): for name in sorted(os.listdir(root)):
path = os.path.join(root, name) path = os.path.join(root, name)
if not os.path.isdir(path): if not os.path.isdir(path):
continue continue
cells = [] cells = []
for filename in sorted(os.listdir(path)): for filename in sorted(os.listdir(path)):
if not filename.lower().endswith(('.yml', '.yaml')): if not is_layout_cell_filename(filename):
continue continue
cell_name = os.path.splitext(filename)[0] cell_name = os.path.splitext(filename)[0]
yml_path = os.path.join(path, filename) yml_path = os.path.join(path, filename)
cells.append({ try:
"name": cell_name, read_layout_cell_file(yml_path)
"has_layout": os.path.exists(yml_path) except LayoutFileError:
}) continue
cells.append({"name": cell_name, "has_layout": True})
meta = read_project_meta(name) meta = read_project_meta(name)
projects.append({ projects.append({
"name": name, "name": name,
@@ -648,24 +661,20 @@ def get_project(project_name):
if not os.path.isdir(root): if not os.path.isdir(root):
return jsonify({"error": "Project not found"}), 404 return jsonify({"error": "Project not found"}), 404
cells = [] loaded_cells, warnings = load_layout_cell_files(root)
for filename in sorted(os.listdir(root)): cells = [
if not filename.lower().endswith(('.yml', '.yaml')): {
continue "name": os.path.splitext(cell["filename"])[0],
cell_name = os.path.splitext(filename)[0] "content": cell["content"]
yml_path = os.path.join(root, filename) }
if not os.path.exists(yml_path): for cell in loaded_cells
continue ]
with open(yml_path, 'r', encoding='utf-8') as f:
cells.append({
"name": cell_name,
"content": f.read()
})
return jsonify({ return jsonify({
"name": safe_name(project_name, 'project_1'), "name": safe_name(project_name, 'project_1'),
"cells": cells, "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(): def save_layout():
"""Persist a canvas layout YAML document and refresh its preview assets.""" """Persist a canvas layout YAML document and refresh its preview assets."""
try: try:
data = request.get_json() data = request.get_json(silent=True) or {}
project = safe_name(data.get('project'), 'project_1') project = safe_name(data.get('project'), 'project_1')
cell = safe_name(data.get('cell'), 'canvas_1') cell = safe_name(data.get('cell'), 'canvas_1')
content = data.get('content', '') content = data.get('content', '')
create_preview = bool(data.get('preview', True)) 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) save_path = cell_file_path(project, cell)
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, 'w', encoding='utf-8') as f: with open(save_path, 'w', encoding='utf-8') as f:
f.write(content) 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_path = None
svg_version = None svg_version = None
@@ -782,6 +792,8 @@ def save_layout():
"preview_error": preview_error "preview_error": preview_error
}), 200 }), 200
except LayoutFileError as e:
return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
+4 -7
View File
@@ -790,14 +790,11 @@
const portNumber = normalizePortNumber(data && data.portNumber); const portNumber = normalizePortNumber(data && data.portNumber);
const pitch = normalizePitch(data && data.pitch); const pitch = normalizePitch(data && data.pitch);
const handleClearance = Math.max(pitch, 14); 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 { return {
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : portWidth, width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : PORT_NODE_SIZE,
height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance) 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)
}; };
}; };
+92 -52
View File
@@ -1933,20 +1933,12 @@ Organization : OptiHK Limited
const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port'; const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port';
const ports = buildElementPorts('port', data); const ports = buildElementPorts('port', data);
const elementSize = buildElementBoxSize(data); const elementSize = buildElementBoxSize(data);
const localHandlePorts = Object.fromEntries( const handlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }]) Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }])
); );
const localPortHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize }),
[localHandlePorts, elementSize]
);
const portHandles = useMemo( const portHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: canvasAngle }), () => buildPortHandles(handlePorts, { rotation: canvasAngle, boxSize: elementSize }),
[localHandlePorts, canvasAngle] [handlePorts, canvasAngle, elementSize]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
); );
const handlePositionMap = { const handlePositionMap = {
left: Position.Left, left: Position.Left,
@@ -1955,9 +1947,12 @@ Organization : OptiHK Limited
bottom: Position.Bottom bottom: Position.Bottom
}; };
const baseHandleStyle = { const baseHandleStyle = {
background: 'var(--accent)', background: selected ? '#f87171' : 'var(--danger)',
width: 5, width: 7,
height: 5 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 pinLabelStyle = (portHandle) => {
const base = { const base = {
@@ -1966,13 +1961,13 @@ Organization : OptiHK Limited
top: portHandle.style?.top, top: portHandle.style?.top,
bottom: portHandle.style?.bottom bottom: portHandle.style?.bottom
}; };
if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 5px), -50%)' }; if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 8px), -50%)' };
if (portHandle.position === 'right') return { ...base, transform: 'translate(5px, -50%)' }; if (portHandle.position === 'right') return { ...base, transform: 'translate(8px, -50%)' };
if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 5px))' }; if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 8px))' };
return { ...base, transform: 'translate(-50%, 5px)' }; return { ...base, transform: 'translate(-50%, 8px)' };
}; };
const pinLabelTextStyle = { 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 ( return (
<div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}> <div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}>
@@ -1980,28 +1975,17 @@ Organization : OptiHK Limited
<strong>{portDisplayName}</strong> <strong>{portDisplayName}</strong>
<span>Port</span> <span>Port</span>
</div> </div>
<div style={{ {portHandles.map((portHandle) => (
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}> <React.Fragment key={portHandle.name}>
<Handle <Handle
type="source" type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]} position={handlePositionMap[portHandle.position]}
id={portHandle.name} id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }} style={{ ...baseHandleStyle, ...portHandle.style }}
/> />
<Handle <Handle
type="target" type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]} position={handlePositionMap[portHandle.position]}
id={portHandle.name} id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }} style={{ ...baseHandleStyle, ...portHandle.style }}
/> />
@@ -2011,7 +1995,6 @@ Organization : OptiHK Limited
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
</div>
); );
}; };
@@ -2246,24 +2229,73 @@ Organization : OptiHK Limited
// Displays generated layout SVG previews with zoom and pan controls. // Displays generated layout SVG previews with zoom and pan controls.
const LayoutSvgPreview = ({ page }) => { 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( const previewBounds = useMemo(
() => page.layoutBounds || calculateLayoutBounds(page), () => page.layoutBounds || calculateLayoutBounds(page),
[page.layoutBounds, page.nodes, page.canvasSize] [page.layoutBounds, page.nodes, page.canvasSize]
); );
const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100)); const minLayoutScale = 0.01;
const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100; const maxLayoutScale = 800;
const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100; 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) => { const updateScale = (value) => {
setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100))); setLayoutScale(clampLayoutScale(value, fitScalePercent));
}; };
const handleWheel = (event) => { const handleWheel = (event) => {
event.preventDefault(); event.preventDefault();
const direction = event.deltaY > 0 ? -1 : 1; const direction = event.deltaY > 0 ? -1 : 1;
const step = event.shiftKey ? 5 : 15; 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 ( return (
@@ -2273,9 +2305,9 @@ Organization : OptiHK Limited
Scale Scale
<input <input
type="range" type="range"
min="10" min={minLayoutScale}
max="800" max={maxLayoutScale}
step="5" step="0.01"
value={normalizedScale} value={normalizedScale}
onChange={(event) => updateScale(event.target.value)} onChange={(event) => updateScale(event.target.value)}
aria-label="Layout SVG preview scale" aria-label="Layout SVG preview scale"
@@ -2284,9 +2316,9 @@ Organization : OptiHK Limited
<label> <label>
<input <input
type="number" type="number"
min="10" min={minLayoutScale}
max="800" max={maxLayoutScale}
step="5" step="0.01"
value={normalizedScale} value={normalizedScale}
onChange={(event) => updateScale(event.target.value)} onChange={(event) => updateScale(event.target.value)}
aria-label="Layout SVG preview scale percent" aria-label="Layout SVG preview scale percent"
@@ -2294,7 +2326,7 @@ Organization : OptiHK Limited
% %
</label> </label>
</div> </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-scroll-area">
<div <div
className="layout-preview-stage" className="layout-preview-stage"
@@ -2304,6 +2336,7 @@ Organization : OptiHK Limited
className="layout-preview-image" className="layout-preview-image"
src={page.svgUrl} src={page.svgUrl}
alt={`${page.name} layout preview`} alt={`${page.name} layout preview`}
onLoad={handleSvgLoad}
style={{ objectFit: 'contain' }} style={{ objectFit: 'contain' }}
/> />
</div> </div>
@@ -4943,7 +4976,6 @@ Organization : OptiHK Limited
} }
newNodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap)); newNodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap));
if (!isProject) {
forEachBundleLink(doc, (bundleName, bundle, link) => { forEachBundleLink(doc, (bundleName, bundle, link) => {
const route = createRouteSettings(technologyManifest, { ...bundle, ...link, bundle_group: bundleName }); const route = createRouteSettings(technologyManifest, { ...bundle, ...link, bundle_group: bundleName });
const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up'); const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up');
@@ -4974,7 +5006,6 @@ Organization : OptiHK Limited
newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route)); newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route));
} }
}); });
}
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5); const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const newPageName = file.name.replace(/\.(yaml|yml)$/i, ''); const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
@@ -5211,11 +5242,20 @@ Organization : OptiHK Limited
} }
const data = await response.json(); const data = await response.json();
(data.warnings || []).forEach(warning => addLog(warning));
const technology = data.technology || ''; const technology = data.technology || '';
setProjectTechnology(technology); setProjectTechnology(technology);
const manifest = await loadTechnologyManifest(technology); const manifest = await loadTechnologyManifest(technology);
const knownCompositeNames = new Set((data.cells || []).map(cell => cell.name).filter(name => name !== currentProjectName)); const loadedCells = data.cells || [];
const parsedCellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest, knownCompositeNames)); 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 const compositeBoxSizes = new Map(parsedCellPages
.filter(page => page.type === 'composite') .filter(page => page.type === 'composite')
.map(page => [page.name, calculateCompositeBoxSize(page)])); .map(page => [page.name, calculateCompositeBoxSize(page)]));
@@ -5277,7 +5317,7 @@ Organization : OptiHK Limited
}; };
loadProject(); loadProject();
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]); }, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle, addLog]);
useEffect(() => { useEffect(() => {
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) { if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
-3
View File
@@ -61,7 +61,6 @@ assert(
'save-layout response should include an svg_url for the new layout tab' 'save-layout response should include an svg_url for the new layout tab'
); );
assert( assert(
<<<<<<< HEAD
serverPy.includes('svg_ready') && serverPy.includes('svg_ready') &&
serverPy.includes('svg_version') && serverPy.includes('svg_version') &&
serverPy.includes('file_version(svg_path)') && 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' 'save-layout should publish generated SVG previews atomically instead of serving partially written files'
); );
assert( assert(
=======
>>>>>>> jingwen_main
serverPy.includes('RouterStackUnavailable') && serverPy.includes('RouterStackUnavailable') &&
serverPy.includes('except RouterStackUnavailable as e') && serverPy.includes('except RouterStackUnavailable as e') &&
serverPy.includes('"preview_status": preview_status') && serverPy.includes('"preview_status": preview_status') &&
+13 -13
View File
@@ -33,16 +33,6 @@ assert(
'Build Layout should use the backend svg_url response' 'Build Layout should use the backend svg_url response'
); );
assert( 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('result.preview_error') &&
canvasHtml.includes('Preview skipped: '), canvasHtml.includes('Preview skipped: '),
'Build Layout should log when the backend saves YAML but skips SVG preview because the router stack is unavailable' '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' 'layout SVG preview should expose an editable scale value'
); );
assert( assert(
canvasHtml.includes('objectFit: \'contain\''), canvasHtml.includes('objectFit: \'contain\'') &&
'100% layout preview scale should fit the full SVG within the screen' 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( assert(
canvasHtml.includes('className="build-gds-btn"'), canvasHtml.includes('className="build-gds-btn"'),
@@ -354,7 +354,7 @@ assert(
canvasHtml.includes('layoutBounds') && canvasHtml.includes('layoutBounds') &&
canvasHtml.includes('stageWidth') && canvasHtml.includes('stageWidth') &&
canvasHtml.includes('stageHeight'), 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( assert(
canvasHtml.includes('reactFlowInstance.fitBounds') && canvasHtml.includes('reactFlowInstance.fitBounds') &&
+54
View File
@@ -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'
);