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
+21 -22
View File
@@ -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)
+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
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,
+36 -24
View File
@@ -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
+17
View File
@@ -1282,6 +1282,22 @@ bundles:${groupsYaml ? `\n${groupsYaml}` : ' {}'}`;
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.
const findSameFamilyRouteCrossing = findSameTypeRouteCrossing;
@@ -1324,6 +1340,7 @@ bundles:${groupsYaml ? `\n${groupsYaml}` : ' {}'}`;
createComponentSymbolMetrics,
transformPortInfo,
getNodePortCanvasPoint,
getRotatableNodeHandleDirection,
buildPortHandles,
buildElementPorts,
buildElementPinEntries,
+105 -27
View File
@@ -1566,6 +1566,7 @@ Organization : OptiHK Limited
calculateLayoutBounds,
calculateCompositeBoxSize,
buildPortHandles,
getRotatableNodeHandleDirection,
buildElementPorts,
getElementPinName,
buildElementBoxSize,
@@ -1710,8 +1711,10 @@ Organization : OptiHK Limited
useEffect(() => {
const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`;
if (prevTransformRef.current !== transformKey) {
updateNodeInternalsRef.current(id);
prevTransformRef.current = transformKey;
requestAnimationFrame(() => {
updateNodeInternalsRef.current(id);
});
}
}, [data.rotation, data.flip, data.flop, id]);
@@ -1731,6 +1734,16 @@ Organization : OptiHK Limited
top: Position.Top,
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 flippedPorts = useMemo(
() => {
@@ -1856,24 +1869,28 @@ Organization : OptiHK Limited
transformOrigin: 'center center',
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}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
position={handlePositionMap[effectiveDir]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
position={handlePositionMap[effectiveDir]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }}
/>
</React.Fragment>
))}
);
})}
{portHandles.map((portHandle) => (
<span key={`label-${portHandle.name}`} className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
{portHandle.name}
@@ -2229,24 +2246,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 (
@@ -2256,9 +2322,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"
@@ -2267,9 +2333,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"
@@ -2277,7 +2343,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"
@@ -2287,6 +2353,7 @@ Organization : OptiHK Limited
className="layout-preview-image"
src={page.svgUrl}
alt={`${page.name} layout preview`}
onLoad={handleSvgLoad}
style={{ objectFit: 'contain' }}
/>
</div>
@@ -4127,8 +4194,10 @@ Organization : OptiHK Limited
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
const group = groups.get(key) || [];
const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle);
const targetDirection = getAnchorHandleRouteDirection(nodeMap[edge.target], edge.targetHandle);
const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle)
|| 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 hasRoutePoints = edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2;
const directionalEdge = usesAnchorDirection
@@ -4151,7 +4220,7 @@ Organization : OptiHK Limited
};
});
return [...separatedEdges, ...rulerEdges];
}, [currentEdges, currentNodes, getAnchorHandleRouteDirection, rulerEdges]);
}, [currentEdges, currentNodes, getAnchorHandleRouteDirection, getRotatableNodeHandleDirection, rulerEdges]);
const [projectCompositeMap, setProjectCompositeMap] = useState({});
const [standaloneComposites, setStandaloneComposites] = useState([]);
@@ -5192,11 +5261,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)]));
@@ -5258,7 +5336,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) {
@@ -6130,6 +6208,7 @@ Organization : OptiHK Limited
const route = currentLinkRoute;
const view = routeStyleForSettings(route, false);
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 = {
id: edgeId,
source: connection.source,
@@ -6139,9 +6218,8 @@ Organization : OptiHK Limited
type: view.type,
selectable: true,
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);
if (conflict) {
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
@@ -6155,7 +6233,7 @@ Organization : OptiHK Limited
: p
)));
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.
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'
);
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') &&
+13 -13
View File
@@ -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') &&
+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'
);