diff --git a/backend/server.py b/backend/server.py index 1d422d7..7b3d2f9 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,6 +1,6 @@ # ----------------------------------------------------------------------------- # Description: Flask backend API server for authentication, project management, PDK library access, layout preview, and GDS build endpoints. -# Inside functions: no_cache_response, login_required_json, wrapper, request_ip, record_action, safe_name, user_layout_root, project_root, cell_file_path, cell_svg_path, cell_routes_path, write_route_points_sidecar, project_gds_path, technology_manifest_path_for_project, current_pdk_root, scoped_pdk_root_for_project, pdk_root_for_request_project, project_meta_path, read_project_meta +# Inside functions: no_cache_response, login_required_json, wrapper, request_ip, record_action, safe_name, user_layout_root, project_root, cell_file_path, cell_svg_path, file_version, cell_routes_path, write_route_points_sidecar, project_gds_path, technology_manifest_path_for_project, current_pdk_root, scoped_pdk_root_for_project, pdk_root_for_request_project, project_meta_path, read_project_meta # Developer : Qin Yue @ 2026 # Organization : OptiHK Limited # ----------------------------------------------------------------------------- @@ -9,6 +9,7 @@ import os import re import shutil import json +import uuid import yaml from collections import OrderedDict from functools import wraps @@ -135,6 +136,12 @@ def cell_svg_path(project_name, cell_name): return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg") +def file_version(path): + """Return a cache-busting version token for a completed file.""" + stat = os.stat(path) + return f"{stat.st_mtime_ns}-{stat.st_size}" + + def cell_routes_path(project_name, cell_name): """Return the route sidecar JSON path for a project cell.""" return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml") @@ -739,24 +746,31 @@ def save_layout(): write_route_points_sidecar(content, cell_routes_path(project, cell)) svg_path = None + svg_version = None preview_status = "not_requested" preview_error = None if create_preview: svg_path = cell_svg_path(project, cell) + temp_svg_path = f"{svg_path}.building-{os.getpid()}-{uuid.uuid4().hex}.svg" try: create_routed_layout_svg( content, - svg_path, + temp_svg_path, pdk_root=current_pdk_root(), project_dir=project_root(project), technology_manifest_path=technology_manifest_path_for_project(project), prefer_full_gds=prefer_full_gds_for_session(session), ) + os.replace(temp_svg_path, svg_path) + svg_version = file_version(svg_path) preview_status = "generated" except RouterStackUnavailable as e: preview_status = "skipped" preview_error = str(e) svg_path = None + finally: + if os.path.exists(temp_svg_path): + os.remove(temp_svg_path) record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path}) return jsonify({ @@ -765,7 +779,9 @@ def save_layout(): "cell": cell, "path": save_path, "svg_path": svg_path, - "svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell) if svg_path else None, + "svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell, v=svg_version) if svg_path else None, + "svg_ready": bool(svg_path and svg_version), + "svg_version": svg_version, "preview_status": preview_status, "preview_error": preview_error }), 200 diff --git a/database/admin/layout/mxpic_project_1/canvas_1.svg b/database/admin/layout/mxpic_project_1/canvas_1.svg new file mode 100644 index 0000000..a416194 --- /dev/null +++ b/database/admin/layout/mxpic_project_1/canvas_1.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/canvas_1.yml b/database/admin/layout/mxpic_project_1/canvas_1.yml new file mode 100644 index 0000000..17d83e6 --- /dev/null +++ b/database/admin/layout/mxpic_project_1/canvas_1.yml @@ -0,0 +1,211 @@ +# ============================================= +# mxPIC Cell/Project Definition File +# ============================================= +schema_version: "2.0.0" +kind: cell +coordinate_system: gds_y_up +canvas_size: + width: 500 + height: 600 +project: mxpic_project_1 +name: canvas_1 +type: composite +version: "1.0.0" + +# 1. External Ports (How this cell connects to the outside world) +pins: +- name: port_io1 + layer: WG_CORE + element: port + pin: io1 + x: 40.0 + y: -90.0 + angle: 180.0 + width: 0.5 +- name: port_1_io1 + layer: WG_CORE + element: port_1 + pin: io1 + x: 410.0 + y: -35.0 + angle: 0.0 + width: 0.5 +- name: port_1_io2 + layer: WG_CORE + element: port_1 + pin: io2 + x: 410.0 + y: -25.0 + angle: 0.0 + width: 0.5 +- name: port_2_io1 + layer: WG_CORE + element: port_2 + pin: io1 + x: 390.0 + y: -215.0 + angle: 0.0 + width: 0.5 +- name: port_2_io2 + layer: WG_CORE + element: port_2 + pin: io2 + x: 390.0 + y: -205.0 + angle: 0.0 + width: 0.5 + +# 2. Instances (The sub-components dropped onto this canvas) +instances: + MMI_1: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 130.0 + y: -90.0 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_2: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 280.0 + y: -30.0 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_3: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 320.1 + y: -144.7 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + +elements: + port: + type: port + x: 40.0 + y: -90.0 + angle: 180.0 + pin_number: 1 + pitch: 10 + layer: WG_CORE + width: 0.5 + description: "" + pins: + - name: port_io1 + role: io1 + port: + type: port + x: 40.0 + y: -90.0 + angle: 0.0 + pin_number: 1 + pitch: 10 + layer: WG_CORE + width: 0.5 + description: "" + pins: + - name: port_io1 + role: io1 + port_1: + type: port + x: 410.0 + y: -30.0 + angle: 180.0 + pin_number: 2 + pitch: 10 + layer: WG_CORE + width: 0.5 + description: "" + pins: + - name: port_1_io1 + role: io1 + - name: port_1_io2 + role: io2 + port_2: + type: port + x: 390.0 + y: -210.0 + angle: 180.0 + pin_number: 2 + pitch: 10 + layer: WG_CORE + width: 0.5 + description: "" + pins: + - name: port_2_io1 + role: io1 + - name: port_2_io2 + role: io2 + +# 3. Bundles (Grouped links for multi-bus/parallel routing) +bundles: + output_bus: + routing_type: euler_bend + links: + - from: MMI_1:a1 + to: port:port_io1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_2:a1 + to: MMI_1:b1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_3:a1 + to: MMI_1:b2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_2:b1 + to: port_1:port_1_io1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_2:b1 + to: port_1:port_1_io2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_2:b2 + to: port_1:port_1_io1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_3:b1 + to: port_2:port_2_io2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_3:b2 + to: port_2:port_2_io1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend \ No newline at end of file diff --git a/database/mxpic_data.db b/database/mxpic_data.db index 817af67..f97bbbd 100644 Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ diff --git a/frontend/canvas.html b/frontend/canvas.html index 9acde81..c5dc886 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -3760,6 +3760,8 @@ Organization : OptiHK Limited const initializedRef = useRef(false); const canvasViewportRef = useRef(null); + const buildLayoutRequestRef = useRef(0); + const buildLayoutBusyRef = useRef(false); const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []); const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]); @@ -6208,49 +6210,64 @@ ${bundlesBlock}`; // Save the active page, generate layout preview assets, and show the preview tab. const handleBuildLayout = useCallback(async () => { if (!activePage) return; - if (buildLayoutBusy) return; + if (buildLayoutBusyRef.current) return; if (!validateRouteCrossings(activePage)) return; + const buildPage = activePage; + const buildRequestId = buildLayoutRequestRef.current + 1; + buildLayoutRequestRef.current = buildRequestId; + buildLayoutBusyRef.current = true; setBuildLayoutBusy(true); startBuildProgress('Building layout'); - const yamlContent = buildYamlForPage(activePage); + const yamlContent = buildYamlForPage(buildPage); + const layoutBounds = calculateLayoutBounds(buildPage); // send to backend try { const response = await fetch('/api/save-layout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', body: JSON.stringify({ project: currentProjectName, - cell: activePage.name, + cell: buildPage.name, content: yamlContent, }), }); if (!response.ok) { - const errData = await response.json(); + const errData = await response.json().catch(() => ({})); + if (buildRequestId !== buildLayoutRequestRef.current) return; addLog(errData.error || 'Save failed, unknown error'); stopBuildProgress(); return; } const result = await response.json(); + if (buildRequestId !== buildLayoutRequestRef.current) return; addLog('Successfully saved: ' + result.path); if (result.preview_error) { addLog('Preview skipped: ' + result.preview_error); } - if (result.svg_url) { + if (result.svg_ready && result.svg_url) { completeBuildProgress('Layout ready'); - openLayoutPreview(activePage.name, result.svg_url, calculateLayoutBounds(activePage)); + openLayoutPreview(buildPage.name, result.svg_url, layoutBounds); } else { + if (result.preview_status === 'generated') { + addLog('Layout SVG was not marked ready by the backend.'); + } completeBuildProgress('Layout saved'); } } catch (err) { + if (buildRequestId !== buildLayoutRequestRef.current) return; addLog('Save error: ' + err.message); stopBuildProgress(); } finally { - setBuildLayoutBusy(false); + if (buildRequestId === buildLayoutRequestRef.current) { + buildLayoutBusyRef.current = false; + setBuildLayoutBusy(false); + } } - }, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); + }, [activePage, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); // Save YAML for every editable project/composite page without opening previews. const handleSaveProjectLayouts = useCallback(async () => { diff --git a/tests/layout-backend-static.test.js b/tests/layout-backend-static.test.js index 9721715..2fd64fa 100644 --- a/tests/layout-backend-static.test.js +++ b/tests/layout-backend-static.test.js @@ -60,6 +60,18 @@ assert( serverPy.includes('svg_url'), 'save-layout response should include an svg_url for the new layout tab' ); +assert( + serverPy.includes('svg_ready') && + serverPy.includes('svg_version') && + serverPy.includes('file_version(svg_path)') && + serverPy.includes("url_for('get_layout_svg', project_name=project, cell_name=cell, v=svg_version)"), + 'save-layout response should only expose a versioned SVG URL after the preview file is ready' +); +assert( + serverPy.includes('temp_svg_path') && + serverPy.includes('os.replace(temp_svg_path, svg_path)'), + 'save-layout should publish generated SVG previews atomically instead of serving partially written files' +); assert( serverPy.includes('RouterStackUnavailable') && serverPy.includes('except RouterStackUnavailable as e') && diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js index 1cec144..4552f06 100644 --- a/tests/layout-ui-wiring.test.js +++ b/tests/layout-ui-wiring.test.js @@ -32,6 +32,13 @@ assert( canvasHtml.includes('svg_url'), 'Build Layout should use the backend svg_url response' ); +assert( + 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( canvasHtml.includes('result.preview_error') && canvasHtml.includes('Preview skipped: '),