4 Commits

Author SHA1 Message Date
= 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
PotatoMaxwell fa0ebe899c Merge pull request 'rotation bug fixed' (#9) from jingwen_main into qinyue_main
Reviewed-on: #9
2026-06-09 12:17:23 +00:00
xsxx03-art 2846899097 rotation bug fixed 2026-06-08 18:54:42 +08:00
9 changed files with 385 additions and 90 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
+17
View File
@@ -1282,6 +1282,22 @@ bundles:${groupsYaml ? `\n${groupsYaml}` : ' {}'}`;
return null; return null;
}; };
const getRotatableNodeHandleDirection = (node, handleId) => {
if (!node || !handleId) return null;
if (node.type !== 'rotatableNode' && !(!node.data?.elementType && node.data?.componentName)) return null;
const ports = node.data && node.data.ports;
if (!ports || !ports[handleId]) return null;
const boxSize = normalizeBoxSize({ box_size: node.data && node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const handles = buildPortHandles(ports, {
rotation: Number((node.data && node.data.rotation) || 0),
flip: Boolean(node.data && node.data.flip),
flop: Boolean(node.data && node.data.flop),
boxSize
});
const found = handles.find(handle => handle.name === handleId);
return found ? found.position : null;
};
// Backward-compatible alias for same-type route crossing validation. // Backward-compatible alias for same-type route crossing validation.
const findSameFamilyRouteCrossing = findSameTypeRouteCrossing; const findSameFamilyRouteCrossing = findSameTypeRouteCrossing;
@@ -1324,6 +1340,7 @@ bundles:${groupsYaml ? `\n${groupsYaml}` : ' {}'}`;
createComponentSymbolMetrics, createComponentSymbolMetrics,
transformPortInfo, transformPortInfo,
getNodePortCanvasPoint, getNodePortCanvasPoint,
getRotatableNodeHandleDirection,
buildPortHandles, buildPortHandles,
buildElementPorts, buildElementPorts,
buildElementPinEntries, buildElementPinEntries,
+105 -27
View File
@@ -1566,6 +1566,7 @@ Organization : OptiHK Limited
calculateLayoutBounds, calculateLayoutBounds,
calculateCompositeBoxSize, calculateCompositeBoxSize,
buildPortHandles, buildPortHandles,
getRotatableNodeHandleDirection,
buildElementPorts, buildElementPorts,
getElementPinName, getElementPinName,
buildElementBoxSize, buildElementBoxSize,
@@ -1710,8 +1711,10 @@ Organization : OptiHK Limited
useEffect(() => { useEffect(() => {
const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`; const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`;
if (prevTransformRef.current !== transformKey) { if (prevTransformRef.current !== transformKey) {
updateNodeInternalsRef.current(id);
prevTransformRef.current = transformKey; prevTransformRef.current = transformKey;
requestAnimationFrame(() => {
updateNodeInternalsRef.current(id);
});
} }
}, [data.rotation, data.flip, data.flop, id]); }, [data.rotation, data.flip, data.flop, id]);
@@ -1731,6 +1734,16 @@ Organization : OptiHK Limited
top: Position.Top, top: Position.Top,
bottom: Position.Bottom bottom: Position.Bottom
}; };
const rotateHandleDirection = (dir, rot) => {
const norm = ((rot % 360) + 360) % 360;
const map = {
0: { right: 'right', left: 'left', top: 'top', bottom: 'bottom' },
90: { right: 'bottom', left: 'top', top: 'left', bottom: 'right' },
180: { right: 'left', left: 'right', top: 'bottom', bottom: 'top' },
270: { right: 'top', left: 'bottom', top: 'right', bottom: 'left' }
};
return (map[norm] || map[0])[dir] || dir;
};
const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const flippedPorts = useMemo( const flippedPorts = useMemo(
() => { () => {
@@ -1856,24 +1869,28 @@ Organization : OptiHK Limited
transformOrigin: 'center center', transformOrigin: 'center center',
pointerEvents: 'none' pointerEvents: 'none'
}}> }}>
{portHandles.map((portHandle) => ( {portHandles.map((portHandle) => {
const originalDir = portDirectionMap.get(portHandle.name) || portHandle.position;
const effectiveDir = rotateHandleDirection(originalDir, data.rotation || 0);
return (
<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[effectiveDir]}
id={portHandle.name} id={portHandle.name}
title={portHandle.name} title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }} style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }}
/> />
<Handle <Handle
type="target" type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]} position={handlePositionMap[effectiveDir]}
id={portHandle.name} id={portHandle.name}
title={portHandle.name} title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }} style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }}
/> />
</React.Fragment> </React.Fragment>
))} );
})}
{portHandles.map((portHandle) => ( {portHandles.map((portHandle) => (
<span key={`label-${portHandle.name}`} className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}> <span key={`label-${portHandle.name}`} className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
{portHandle.name} {portHandle.name}
@@ -2229,24 +2246,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 (
@@ -2256,9 +2322,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"
@@ -2267,9 +2333,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"
@@ -2277,7 +2343,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"
@@ -2287,6 +2353,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>
@@ -4127,8 +4194,10 @@ Organization : OptiHK Limited
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`; const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
const key = [sourceEndpoint, targetEndpoint].sort().join('<>'); const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
const group = groups.get(key) || []; const group = groups.get(key) || [];
const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle); const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle)
const targetDirection = getAnchorHandleRouteDirection(nodeMap[edge.target], edge.targetHandle); || getRotatableNodeHandleDirection(nodeMap[edge.source], edge.sourceHandle);
const targetDirection = getAnchorHandleRouteDirection(nodeMap[edge.target], edge.targetHandle)
|| getRotatableNodeHandleDirection(nodeMap[edge.target], edge.targetHandle);
const usesAnchorDirection = Boolean(sourceDirection || targetDirection); const usesAnchorDirection = Boolean(sourceDirection || targetDirection);
const hasRoutePoints = edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2; const hasRoutePoints = edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2;
const directionalEdge = usesAnchorDirection const directionalEdge = usesAnchorDirection
@@ -4151,7 +4220,7 @@ Organization : OptiHK Limited
}; };
}); });
return [...separatedEdges, ...rulerEdges]; return [...separatedEdges, ...rulerEdges];
}, [currentEdges, currentNodes, getAnchorHandleRouteDirection, rulerEdges]); }, [currentEdges, currentNodes, getAnchorHandleRouteDirection, getRotatableNodeHandleDirection, rulerEdges]);
const [projectCompositeMap, setProjectCompositeMap] = useState({}); const [projectCompositeMap, setProjectCompositeMap] = useState({});
const [standaloneComposites, setStandaloneComposites] = useState([]); const [standaloneComposites, setStandaloneComposites] = useState([]);
@@ -5192,11 +5261,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)]));
@@ -5258,7 +5336,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) {
@@ -6130,6 +6208,7 @@ Organization : OptiHK Limited
const route = currentLinkRoute; const route = currentLinkRoute;
const view = routeStyleForSettings(route, false); const view = routeStyleForSettings(route, false);
const edgeId = `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`; const edgeId = `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`;
const nodeMap = Object.fromEntries(activePage.nodes.map(node => [node.id, node]));
const candidate = { const candidate = {
id: edgeId, id: edgeId,
source: connection.source, source: connection.source,
@@ -6139,9 +6218,8 @@ Organization : OptiHK Limited
type: view.type, type: view.type,
selectable: true, selectable: true,
style: view.style, style: view.style,
data: { route } data: { route },
}; };
const nodeMap = Object.fromEntries(activePage.nodes.map(node => [node.id, node]));
const conflict = findSameTypeRouteCrossing(candidate, activePage.edges, nodeMap, technologyManifest); const conflict = findSameTypeRouteCrossing(candidate, activePage.edges, nodeMap, technologyManifest);
if (conflict) { if (conflict) {
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
@@ -6155,7 +6233,7 @@ Organization : OptiHK Limited
: p : p
))); )));
addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`); addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`);
}, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog]); }, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog, getAnchorHandleRouteDirection]);
// Select custom route edges from their SVG hit target. // Select custom route edges from their SVG hit target.
const handleRouteEdgeMouseDown = useCallback((event) => { const handleRouteEdgeMouseDown = useCallback((event) => {
-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'
);