diff --git a/backend/server.py b/backend/server.py index c529b71..943e7d5 100644 --- a/backend/server.py +++ b/backend/server.py @@ -122,6 +122,36 @@ 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 cell_routes_path(project_name, cell_name): + return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml") + + +def write_route_points_sidecar(yaml_content, output_path): + layout = yaml.safe_load(yaml_content) or {} + routes = {} + for bundle_name, bundle in (layout.get("bundles") or {}).items(): + saved_links = [] + for link in bundle.get("links") or []: + points = link.get("points") or [] + if not points: + continue + saved_links.append({ + "from": link.get("from"), + "to": link.get("to"), + "xsection": link.get("xsection"), + "width": link.get("width"), + "radius": link.get("radius"), + "points": points, + }) + if saved_links: + routes[bundle_name] = {"links": saved_links} + if routes: + with open(output_path, 'w', encoding='utf-8') as file: + yaml.safe_dump({"routes": routes}, file, sort_keys=False) + elif os.path.exists(output_path): + os.remove(output_path) + + def project_gds_path(project_name): return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds") @@ -145,6 +175,28 @@ def current_pdk_registry(): return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session)) +def scoped_pdk_root_for_project(project_name): + base_root = current_pdk_root() + project = safe_name(project_name, '') + if not project: + return base_root + + technology_id = read_project_meta(project).get("technology") or "" + if "/" not in technology_id: + return base_root + + foundry, technology = technology_id.split("/", 1) + scoped_root = os.path.abspath(os.path.join(base_root, foundry, technology)) + if scoped_root == base_root or not scoped_root.startswith(base_root + os.sep): + return base_root + return scoped_root if os.path.isdir(scoped_root) else base_root + + +def pdk_root_for_request_project(): + project = request.args.get('project') + return scoped_pdk_root_for_project(project) if project else current_pdk_root() + + def project_meta_path(project_name): return os.path.join(project_root(project_name), ".project.json") @@ -172,10 +224,11 @@ def ensure_project_path(project_name): # ... [Keep countSpaces and buildTree exactly as they are] ... -def findComps(baseDir): +def findComps(baseDir, path_root=None): """Scan component folders, return map of paths -> component info.""" compMap = {} refDir = baseDir + path_root = os.path.abspath(path_root or baseDir) for root, dirs, files in os.walk(baseDir): ymlFiles = [f for f in files if f.endswith('.yml')] if ymlFiles: @@ -191,7 +244,8 @@ def findComps(baseDir): compMap[parts + (compName,)] = { 'folder': compName, 'yml': ymlFiles[0], - 'category': category # Save the category to the map + 'category': category, # Save the category to the map + 'path': os.path.relpath(root, path_root).replace(os.sep, '/') } dirs.clear() return compMap @@ -214,7 +268,8 @@ def addCompsToTree(compMap): "__type__": "component", "__name__": compName, "__yml__": compItem['yml'], - "__category__": compItem['category'] # Inject category into the tree + "__category__": compItem['category'], # Inject category into the tree + "__path__": compItem.get('path') }) return fresh_tree @@ -258,6 +313,15 @@ def readCompYaml(compName, comps_root=None): return yaml.safe_load(f) return None + +def find_component_dir(component_name, comps_root=None): + search_root = comps_root or current_pdk_root() + for root, dirs, files in os.walk(search_root): + if os.path.basename(root) == component_name: + dirs.clear() + return root, files + return None, [] + # --- AUTHENTICATION & PAGE ROUTES --- @app.route('/') def home(): @@ -588,25 +652,29 @@ def save_layout(): 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)) 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)) - svg_path = cell_svg_path(project, cell) - if layout_has_links(content): - create_routed_layout_svg( - content, - 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), - ) - else: - create_layout_svg_from_gds(content, svg_path, pdk_registry=current_pdk_registry(), project_dir=project_root(project)) + svg_path = None + if create_preview: + svg_path = cell_svg_path(project, cell) + if layout_has_links(content): + create_routed_layout_svg( + content, + 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), + ) + else: + create_layout_svg_from_gds(content, svg_path, pdk_registry=current_pdk_registry(), project_dir=project_root(project)) record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path}) return jsonify({ @@ -615,7 +683,7 @@ 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) + "svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell) if svg_path else None }), 200 except Exception as e: @@ -716,10 +784,10 @@ def get_project_gds(project_name, filename): @login_required_json def getLib(): """Get library structure.""" - comps_root = current_pdk_root() + comps_root = pdk_root_for_request_project() fresh_tree = {} if os.path.isdir(comps_root): - compMap = findComps(comps_root) + compMap = findComps(comps_root, current_pdk_root()) fresh_tree = addCompsToTree(compMap) return jsonify(fresh_tree) @@ -729,7 +797,7 @@ def getLib(): @login_required_json def getComp(component_name): """Return component YAML data.""" - data = readCompYaml(component_name, current_pdk_root()) + data = readCompYaml(component_name, pdk_root_for_request_project()) if data is None: return jsonify({"error": "Component not found"}), 404 return jsonify(data) @@ -738,14 +806,12 @@ def getComp(component_name): @login_required_json def getCompImg(component_name): """Return first image in component folder.""" - for root, dirs, files in os.walk(current_pdk_root()): - if os.path.basename(root) == component_name: - dirs.clear() - for ext in ('.png', '.jpg', '.jpeg', '.svg'): - for f in files: - if f.lower().endswith(ext): - return send_from_directory(root, f) - break + root, files = find_component_dir(component_name, pdk_root_for_request_project()) + if root: + for ext in ('.png', '.jpg', '.jpeg', '.svg'): + for f in files: + if f.lower().endswith(ext): + return send_from_directory(root, f) return jsonify({"error": "No image found"}), 404 if __name__ == '__main__': diff --git a/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds b/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds new file mode 100644 index 0000000..3016058 Binary files /dev/null and b/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds differ diff --git a/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds b/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds new file mode 100644 index 0000000..e07f9ec Binary files /dev/null and b/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds differ diff --git a/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds b/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds new file mode 100644 index 0000000..de0eb3e Binary files /dev/null and b/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds differ diff --git a/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds b/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds new file mode 100644 index 0000000..bd92f93 Binary files /dev/null and b/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds differ diff --git a/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds b/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds new file mode 100644 index 0000000..6f61183 Binary files /dev/null and b/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds differ diff --git a/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds b/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds new file mode 100644 index 0000000..e5408f9 Binary files /dev/null and b/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds differ diff --git a/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds b/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds new file mode 100644 index 0000000..db2f8b4 Binary files /dev/null and b/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds differ diff --git a/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds b/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds new file mode 100644 index 0000000..b2b1294 Binary files /dev/null and b/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds differ diff --git a/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds b/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds new file mode 100644 index 0000000..41be835 Binary files /dev/null and b/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds differ diff --git a/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds b/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds new file mode 100644 index 0000000..aa4c470 Binary files /dev/null and b/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds differ diff --git a/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds b/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds new file mode 100644 index 0000000..c79fa56 Binary files /dev/null and b/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds differ diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg new file mode 100644 index 0000000..39e4fad --- /dev/null +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml index da29e37..b021f2a 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml @@ -3,6 +3,10 @@ # ============================================= schema_version: "2.0.0" kind: cell +coordinate_system: gds_y_up +canvas_size: + width: 5000 + height: 5000 project: mxpic_project_1 name: mxpic_project_1 type: project @@ -13,35 +17,41 @@ ports: - name: port layer: WG_CORE x: 50.0 - y: 150.0 + y: -150.0 angle: 0.0 width: 0.5 # 2. Instances (The sub-components dropped onto this canvas) instances: component_1: - component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY - x: 300.0 - y: 440.0 + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 100.0 + y: -2290.0 rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + component_4: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 100.0 + y: -1970.0 + rotation: 0.0 + flip: 0 + flop: 0 mirror: false settings: length: component_2: - component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303 - x: 820.0 - y: 250.0 - rotation: 0.0 - mirror: false - settings: - length: - - component_3: - component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303 - x: 820.0 - y: 660.0 + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 100.0 + y: -2560.0 rotation: 0.0 + flip: 0 + flop: 0 mirror: false settings: length: @@ -50,7 +60,23 @@ elements: port: type: port x: 50.0 - y: 150.0 + y: -150.0 + angle: 0.0 + layer: WG_CORE + width: 0.5 + description: "" + anchor_1: + type: anchor + x: 120.0 + y: -2150.0 + angle: 0.0 + layer: WG_CORE + width: 0.5 + description: "" + anchor_2: + type: anchor + x: 130.0 + y: -2430.0 angle: 0.0 layer: WG_CORE width: 0.5 @@ -61,15 +87,29 @@ bundles: output_bus: routing_type: euler_bend links: - - from: component_2:g2b - to: component_1:b1 + - from: anchor_1:right + to: component_4:b2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: anchor_1:left + to: component_1:a1 xsection: strip family: optical width: 0.45 radius: 10 routing_type: euler_bend - from: component_1:b2 - to: component_3:g2b + to: anchor_2:right + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: anchor_2:left + to: component_2:a1 xsection: strip family: optical width: 0.45 diff --git a/database/mxpic_data.db b/database/mxpic_data.db index 37b007d..9d36722 100644 Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ diff --git a/frontend/canvas-helpers.js b/frontend/canvas-helpers.js index d0e3482..3821dc6 100644 --- a/frontend/canvas-helpers.js +++ b/frontend/canvas-helpers.js @@ -7,6 +7,9 @@ })(typeof window !== 'undefined' ? window : globalThis, function () { const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge'; const FORGE_COMPONENT_TYPE = 'generate_with_forge'; + const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 }; + const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 }; + const PORT_NODE_SIZE = 30; const ELEMENT_COMPONENTS = { Port: { name: 'Port', @@ -19,11 +22,44 @@ name: 'Anchor', elementType: 'anchor', ports: { - left: { x: -20, y: 0, a: 180, width: 0.5 }, - right: { x: 20, y: 0, a: 0, width: 0.5 } + left: { x: 0, y: -PORT_NODE_SIZE / 2, a: 180, width: 0.5 }, + right: { x: PORT_NODE_SIZE, y: -PORT_NODE_SIZE / 2, a: 0, width: 0.5 } } } }; + const BASIC_COMPONENTS = { + waveguide: { + name: 'waveguide', + category: 'basic', + settings: { length: 100, width: 0.5, xsection: 'strip' } + }, + '90 bend': { + name: '90 bend', + category: 'basic', + settings: { radius: 10, width: 0.5, xsection: 'strip' } + }, + '180 bend': { + name: '180 bend', + category: 'basic', + settings: { radius: 10, width: 0.5, xsection: 'strip' } + }, + circle: { + name: 'circle', + category: 'basic', + settings: { radius: 10, width: 0.5, xsection: 'strip' } + }, + cricle: { + name: 'cricle', + category: 'basic', + hidden: true, + settings: { radius: 10, width: 0.5, xsection: 'strip' } + }, + taper: { + name: 'taper', + category: 'basic', + settings: { length: 50, width1: 0.5, width2: 1, xsection: 'strip' } + } + }; const DEFAULT_FORGE_ARGUMENTS = { function_name: 'straight', @@ -137,6 +173,12 @@ }; const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE; + const isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]); + + const createBasicSettings = (componentName, overrides) => ({ + ...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}), + ...(overrides || {}) + }); const normalizeAngle = (angle) => { const value = Number(angle); @@ -157,28 +199,206 @@ const roundPercent = (value) => Number(value.toFixed(3)); - const scaledPercent = (value, min, max, invert) => { - if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || min === max) return null; - const ratio = (value - min) / (max - min); - const visualRatio = invert ? 1 - ratio : ratio; - return roundPercent(15 + visualRatio * 70); - }; - const fallbackPercent = (index, count) => { if (count <= 1) return 50; return roundPercent(15 + (index / (count - 1)) * 70); }; + const positiveNumber = (value) => { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : null; + }; + + const normalizeBoxSize = (metadata, fallback) => { + const fallbackSize = fallback || DEFAULT_COMPONENT_BOX_SIZE; + const raw = metadata && (metadata.box_size || metadata.box_sz || metadata.boxSize); + let width = null; + let height = null; + if (Array.isArray(raw)) { + width = positiveNumber(raw[0]); + height = positiveNumber(raw[1]); + } else if (raw && typeof raw === 'object') { + width = positiveNumber(raw.width ?? raw.w ?? raw.x); + height = positiveNumber(raw.height ?? raw.h ?? raw.y); + } + return { + width: width || fallbackSize.width, + height: height || fallbackSize.height + }; + }; + + const chooseCategoryComponent = (dragName, availableComponents, categoryName) => { + const available = Array.isArray(availableComponents) + ? availableComponents.filter(Boolean) + : []; + if (dragName && !isForgeComponent(dragName)) return dragName; + const physicalComponent = available.find(component => !isForgeComponent(component)); + return physicalComponent || dragName || available[0] || categoryName; + }; + + const normalizeCanvasSize = (size) => ({ + width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width, + height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height + }); + + const clampPositionToCanvas = (position, canvasSize, boxSize) => { + const size = normalizeCanvasSize(canvasSize); + const box = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); + const maxX = Math.max(0, size.width - box.width); + const maxY = Math.max(0, size.height - box.height); + return { + x: Math.min(maxX, Math.max(0, Number(position && position.x) || 0)), + y: Math.min(maxY, Math.max(0, Number(position && position.y) || 0)) + }; + }; + + const transformBoxCorner = (corner, transform) => { + const options = transform || {}; + let x = Number(corner && corner.x) || 0; + let y = Number(corner && corner.y) || 0; + if (options.flop) x = -x; + if (options.flip) y = -y; + const rotation = Number(options.rotation || 0); + if (!rotation) return { x, y }; + const radians = rotation * Math.PI / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { + x: x * cos - y * sin, + y: x * sin + y * cos + }; + }; + + const roundBoundsValue = (value) => Number(value.toFixed(6)); + + const calculateLayoutBounds = (pageOrNodes) => { + const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {}); + const nodes = Array.isArray(page.nodes) ? page.nodes : []; + const points = []; + + nodes.forEach(node => { + if (!node || !node.position || !node.data || !node.data.componentName || node.data.elementType) return; + const box = normalizeBoxSize({ box_size: node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); + const origin = { + x: Number(node.position.x) || 0, + y: Number(node.position.y) || 0 + }; + [ + { x: 0, y: 0 }, + { x: box.width, y: 0 }, + { x: box.width, y: box.height }, + { x: 0, y: box.height } + ].forEach(corner => { + const transformed = transformBoxCorner(corner, node.data); + points.push({ + x: origin.x + transformed.x, + y: origin.y + transformed.y + }); + }); + }); + + if (points.length === 0) { + const size = normalizeCanvasSize(page.canvasSize || DEFAULT_CANVAS_SIZE); + points.push({ x: 0, y: 0 }, { x: size.width, y: size.height }); + } + + const minX = roundBoundsValue(Math.min(...points.map(point => point.x))); + const maxX = roundBoundsValue(Math.max(...points.map(point => point.x))); + const minY = roundBoundsValue(Math.min(...points.map(point => point.y))); + const maxY = roundBoundsValue(Math.max(...points.map(point => point.y))); + return { + minX, + minY, + maxX, + maxY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + bottomLeft: { x: minX, y: minY }, + topRight: { x: maxX, y: maxY } + }; + }; + + const roundMeasureValue = (value) => Number(value.toFixed(3)); + + const normalizeMeasurePoint = (point) => { + const x = Number(point && point.x); + const y = Number(point && point.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; + }; + + const createRulerMeasurement = (startPoint, endPoint) => { + const start = normalizeMeasurePoint(startPoint); + const end = normalizeMeasurePoint(endPoint); + if (!start || !end) return null; + const dx = roundMeasureValue(end.x - start.x); + const dy = roundMeasureValue(end.y - start.y); + const distance = roundMeasureValue(Math.hypot(dx, dy)); + const midpoint = { + x: roundMeasureValue((start.x + end.x) / 2), + y: roundMeasureValue((start.y + end.y) / 2) + }; + return { + start, + end, + dx, + dy, + distance, + midpoint, + label: `${distance.toFixed(3)} um dx ${dx.toFixed(3)} dy ${dy.toFixed(3)}` + }; + }; + + const createComponentSymbolMetrics = (boxSize) => { + const size = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); + const widthRatio = size.width >= 400 ? 0.95 : 0.9; + return { + width: roundMeasureValue(size.width * widthRatio), + height: roundMeasureValue(size.height * 0.68) + }; + }; + + const transformPortInfo = (info, transform) => { + const source = info || {}; + const options = transform || {}; + let x = Number(source.x || 0); + let y = Number(source.y || 0); + let angle = Number(source.a || 0); + + if (options.flip) { + y = -y; + angle = -angle; + } + if (options.flop) { + x = -x; + angle = 180 - angle; + } + + const rotation = Number(options.rotation || 0); + if (rotation) { + const radians = rotation * Math.PI / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const nextX = x * cos - y * sin; + const nextY = x * sin + y * cos; + x = nextX; + y = nextY; + angle += rotation; + } + + return { + ...source, + x, + y, + a: normalizeAngle(angle) + }; + }; + const buildSideHandles = (ports, side) => { const vertical = side === 'left' || side === 'right'; - const coordinate = vertical ? 'y' : 'x'; - const values = ports.map(port => Number(port.info[coordinate])).filter(Number.isFinite); - const min = values.length ? Math.min(...values) : null; - const max = values.length ? Math.max(...values) : null; return ports.map((port, index) => { - const physicalPercent = scaledPercent(Number(port.info[coordinate]), min, max, vertical); - const percent = physicalPercent == null ? fallbackPercent(index, ports.length) : physicalPercent; + const percent = fallbackPercent(index, ports.length); const percentValue = `${percent}%`; const style = vertical ? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' } @@ -193,12 +413,13 @@ }); }; - const buildPortHandles = (ports) => { + const buildPortHandles = (ports, transform) => { const grouped = { left: [], right: [], top: [], bottom: [] }; Object.entries(ports || {}).forEach(([name, info]) => { if (name === 'a0' || name === 'b0') return; - const side = portSideFromAngle(info && info.a); - grouped[side].push({ name, info: info || {} }); + const transformedInfo = transformPortInfo(info, transform); + const side = portSideFromAngle(transformedInfo.a); + grouped[side].push({ name, info: transformedInfo }); }); Object.values(grouped).forEach(sidePorts => { @@ -229,6 +450,9 @@ return JSON.stringify(String(value)); }; + const canvasToLayoutY = (value) => -Number(value || 0); + const layoutToCanvasY = (value) => -Number(value || 0); + const buildSettingsYaml = (settings, indent) => { const pad = ' '.repeat(indent); const entries = Object.entries(settings || {}); @@ -236,18 +460,25 @@ return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n'); }; - const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, forgeArguments }) => { + const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, flip, flop, forgeArguments, basicArguments }) => { const forge = isForgeComponent(componentName); - const componentValue = forge ? FORGE_COMPONENT_TYPE : componentPath; + const basic = isBasicComponent(componentName); + const componentValue = forge ? FORGE_COMPONENT_TYPE : (basic ? componentName : componentPath); const settings = forge ? createForgeArguments(forgeArguments) : null; - const settingsYaml = forge ? `\n settings:\n${buildSettingsYaml(settings, 6)}` : '\n settings:\n length:'; + const settingsYaml = forge + ? `\n settings:\n${buildSettingsYaml(settings, 6)}` + : basic + ? `\n settings:\n${buildSettingsYaml(createBasicSettings(componentName, basicArguments), 6)}` + : '\n settings:\n length:'; return ` ${instanceName}: component: ${componentValue} x: ${Number(position.x || 0).toFixed(1)} - y: ${Number(position.y || 0).toFixed(1)} + y: ${canvasToLayoutY(position.y).toFixed(1)} rotation: ${Number(rotation || 0).toFixed(1)} - mirror: false${settingsYaml}`; + flip: ${flip ? 1 : 0} + flop: ${flop ? 1 : 0} + mirror: ${flip ? 'true' : 'false'}${settingsYaml}`; }; const buildInstancesYaml = ({ nodes, resolveComponentPath }) => { @@ -266,7 +497,10 @@ componentPath, position: node.position || { x: 0, y: 0 }, rotation: data.rotation || 0, - forgeArguments: data.forgeArguments + flip: Boolean(data.flip), + flop: Boolean(data.flop), + forgeArguments: data.forgeArguments, + basicArguments: data.basicArguments }); }) .join('\n\n'); @@ -296,6 +530,69 @@ return JSON.parse(JSON.stringify(element.ports)); }; + const buildBasicComponentPorts = (componentName, settings) => { + const values = createBasicSettings(componentName, settings); + const length = Number(values.length || 0); + const radius = Number(values.radius || 10); + const width = Number(values.width ?? values.width1 ?? 0.5); + const xsection = values.xsection || values.xs || 'strip'; + if (componentName === 'waveguide') { + return { + a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, + b1: { x: length, y: 0, a: 0, width, xsection, description: 'Optical power output' } + }; + } + if (componentName === '90 bend') { + return { + a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, + b1: { x: radius, y: radius, a: 90, width, xsection, description: 'Optical power output' } + }; + } + if (componentName === '180 bend') { + return { + a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, + b1: { x: 0, y: 2 * radius, a: 180, width, xsection, description: 'Optical power output' } + }; + } + if (componentName === 'cricle' || componentName === 'circle') { + return { + a1: { x: radius, y: 0, a: 180, width, xsection, description: 'Optical power input' }, + b1: { x: radius, y: 0, a: 180, width, xsection, description: 'Optical power output' } + }; + } + if (componentName === 'taper') { + return { + a1: { x: 0, y: 0, a: 180, width: Number(values.width1 || width), xsection, description: 'Optical power input' }, + b1: { x: length, y: 0, a: 0, width: Number(values.width2 || width), xsection, description: 'Optical power output' } + }; + } + return {}; + }; + + const getBasicComponentMetadata = (componentName, settings) => { + if (!isBasicComponent(componentName)) return null; + const values = createBasicSettings(componentName, settings); + const length = Number(values.length || 0); + const radius = Number(values.radius || 10); + const width = Number(values.width ?? values.width1 ?? 0.5); + const width2 = Number(values.width2 ?? width); + const boxSize = componentName === 'waveguide' + ? [Math.max(length, 10), Math.max(width * 4, 4)] + : componentName === 'taper' + ? [Math.max(length, 10), Math.max(width, width2) * 10 + 18] + : componentName === '180 bend' + ? [radius, radius * 2] + : [radius, radius]; + return { + name: componentName, + foundry: 'mxpic', + process: 'basic nazca', + ports: buildBasicComponentPorts(componentName, values), + box_size: boxSize, + settings: values + }; + }; + const buildPageComponentPorts = (port, nodes) => { const portNodes = (nodes || []).filter(isPortElementNode); if (portNodes.length > 0) { @@ -332,7 +629,7 @@ return `- name: ${name} ${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'} x: ${Number(info.x || 0).toFixed(1)} - y: ${Number(info.y || 0).toFixed(1)} + y: ${canvasToLayoutY(info.y).toFixed(1)} angle: ${Number(info.a || 0).toFixed(1)} width: ${Number(info.width || 0.5)}${description}`; }); @@ -351,7 +648,7 @@ return ` ${name}: type: ${data.elementType} x: ${Number((node.position && node.position.x) || 0).toFixed(1)} - y: ${Number((node.position && node.position.y) || 0).toFixed(1)} + y: ${canvasToLayoutY((node.position && node.position.y) || 0).toFixed(1)} angle: ${Number(angle || 0).toFixed(1)} layer: ${data.layer || 'WG_CORE'} width: ${Number(data.width || 0.5)} @@ -375,13 +672,27 @@ const fromPort = edge.sourceHandle || 'unknown'; const toPort = edge.targetHandle || 'unknown'; const route = createRouteSettings(manifest, edge.data && edge.data.route); + const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : []; + const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : []; + const pointsYaml = points.length > 0 + ? `\n points:\n${points.map(point => ` - x: ${Number(point.x || 0).toFixed(1)}\n y: ${canvasToLayoutY(point.y).toFixed(1)}`).join('\n')}` + : ''; + const isFreeRoute = Boolean(edge.data && edge.data.freeRoute) || (!sourceNode && !targetNode && points.length >= 2); + if (isFreeRoute) { + return ` - id: ${toYamlScalar(edge.id)} + xsection: ${route.xsection} + family: ${route.family} + width: ${Number(route.width)} + radius: ${Number(route.radius)} + routing_type: ${route.routing_type}${pointsYaml}`; + } return ` - from: ${sourceName}:${fromPort} to: ${targetName}:${toPort} xsection: ${route.xsection} family: ${route.family} width: ${Number(route.width)} radius: ${Number(route.radius)} - routing_type: ${route.routing_type}`; + routing_type: ${route.routing_type}${pointsYaml}`; }); linksYaml = linkLines.join('\n'); } @@ -402,6 +713,103 @@ ${linksYaml}`; }; }; + const getNodePortCanvasPoint = (node, portName) => { + if (!node) return null; + const x = Number((node.position && node.position.x) || 0); + const y = Number((node.position && node.position.y) || 0); + if (node.type === 'portNode' || (node.data && node.data.elementType === 'port')) { + return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; + } + const ports = node.data && node.data.ports; + const portInfo = ports && portName ? ports[portName] : null; + if (!portInfo) return null; + const transformedInfo = transformPortInfo(portInfo, { + rotation: (node.data && node.data.rotation) || 0, + flip: Boolean(node.data && node.data.flip), + flop: Boolean(node.data && node.data.flop) + }); + return { + x: roundMeasureValue(x + Number(transformedInfo.x || 0)), + y: roundMeasureValue(y - Number(transformedInfo.y || 0)) + }; + }; + + const percentValue = (value, fallback = 50) => { + if (typeof value !== 'string') return fallback; + const number = Number(value.replace('%', '')); + return Number.isFinite(number) ? number : fallback; + }; + + const getEdgeEndpointPoint = (edge, nodeMap, endpoint) => { + const nodeId = endpoint === 'source' ? edge.source : edge.target; + const handleId = endpoint === 'source' ? edge.sourceHandle : edge.targetHandle; + const node = nodeMap[nodeId]; + if (!node) return null; + + const pinPoint = getNodePortCanvasPoint(node, handleId); + if (pinPoint) return pinPoint; + + const ports = node.data && node.data.ports; + if (ports && handleId) { + const handles = buildPortHandles(ports, { + rotation: (node.data && node.data.rotation) || 0, + flip: Boolean(node.data && node.data.flip), + flop: Boolean(node.data && node.data.flop) + }); + const handle = handles.find(item => item.name === handleId); + if (handle) { + const componentSize = normalizeBoxSize({ box_size: node.data && node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); + let x = Number((node.position && node.position.x) || 0); + let y = Number((node.position && node.position.y) || 0); + if (handle.position === 'left') { + y += componentSize.height * percentValue(handle.style && handle.style.top) / 100; + } else if (handle.position === 'right') { + x += componentSize.width; + y += componentSize.height * percentValue(handle.style && handle.style.top) / 100; + } else if (handle.position === 'top') { + x += componentSize.width * percentValue(handle.style && handle.style.left) / 100; + } else { + x += componentSize.width * percentValue(handle.style && handle.style.left) / 100; + y += componentSize.height; + } + return { x: Number(x.toFixed(3)), y: Number(y.toFixed(3)) }; + } + } + + return null; + }; + + const getEdgeRoutePoints = (edge, nodeMap) => { + const explicitPoints = edge && edge.data && Array.isArray(edge.data.points) ? edge.data.points : []; + if (explicitPoints.length >= 2) { + const points = explicitPoints + .map(point => ({ + x: Number(point && point.x), + y: Number(point && point.y) + })) + .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)); + if (!Boolean(edge.data && edge.data.freeRoute) && points.length >= 2) { + const sourcePoint = getEdgeEndpointPoint(edge, nodeMap, 'source'); + const targetPoint = getEdgeEndpointPoint(edge, nodeMap, 'target'); + if (sourcePoint) points[0] = sourcePoint; + if (targetPoint) points[points.length - 1] = targetPoint; + } + return points; + } + return [getNodeCenter(nodeMap[edge.source]), getNodeCenter(nodeMap[edge.target])].filter(Boolean); + }; + + const routeSegmentsIntersect = (pointsA, pointsB) => { + for (let i = 0; i < pointsA.length - 1; i += 1) { + for (let j = 0; j < pointsB.length - 1; j += 1) { + if (segmentsIntersect(pointsA[i], pointsA[i + 1], pointsB[j], pointsB[j + 1])) { + return true; + } + } + } + return false; + }; + const orientation = (a, b, c) => { const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y); if (Math.abs(value) < 1e-9) return 0; @@ -417,41 +825,61 @@ ${linksYaml}`; return o1 !== o2 && o3 !== o4; }; - const findSameFamilyRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => { + const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => { const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route); - const candidateStart = getNodeCenter(nodeMap[candidateEdge.source]); - const candidateEnd = getNodeCenter(nodeMap[candidateEdge.target]); + const candidatePoints = getEdgeRoutePoints(candidateEdge, nodeMap); for (const edge of existingEdges || []) { if (!edge || edge.id === candidateEdge.id) continue; if (edge.source === candidateEdge.source || edge.source === candidateEdge.target || edge.target === candidateEdge.source || edge.target === candidateEdge.target) continue; const route = createRouteSettings(manifest, edge.data && edge.data.route); - if (route.family !== candidateRoute.family) continue; - const start = getNodeCenter(nodeMap[edge.source]); - const end = getNodeCenter(nodeMap[edge.target]); - if (segmentsIntersect(candidateStart, candidateEnd, start, end)) { - return { conflictEdge: edge, family: route.family }; + if (route.xsection !== candidateRoute.xsection) continue; + const points = getEdgeRoutePoints(edge, nodeMap); + if (routeSegmentsIntersect(candidatePoints, points)) { + return { conflictEdge: edge, xsection: route.xsection }; } } return null; }; + const findSameFamilyRouteCrossing = findSameTypeRouteCrossing; + return { FORGE_COMPONENT_LABEL, FORGE_COMPONENT_TYPE, + DEFAULT_COMPONENT_BOX_SIZE, + DEFAULT_CANVAS_SIZE, + PORT_NODE_SIZE, ELEMENT_COMPONENTS, + BASIC_COMPONENTS, DEFAULT_FORGE_ARGUMENTS, FALLBACK_TECHNOLOGY_MANIFEST, + canvasToLayoutY, + layoutToCanvasY, createForgeArguments, createRouteSettings, updateRouteField, updateRouteXsection, routeStyleForSettings, + findSameTypeRouteCrossing, findSameFamilyRouteCrossing, isForgeComponent, + isBasicComponent, + createBasicSettings, normalizeAngle, portSideFromAngle, + normalizeBoxSize, + chooseCategoryComponent, + normalizeCanvasSize, + clampPositionToCanvas, + calculateLayoutBounds, + createRulerMeasurement, + createComponentSymbolMetrics, + transformPortInfo, + getNodePortCanvasPoint, buildPortHandles, buildElementPorts, + buildBasicComponentPorts, + getBasicComponentMetadata, buildInstanceYaml, buildInstancesYaml, buildPageComponentPorts, diff --git a/frontend/canvas.html b/frontend/canvas.html index cd49e5d..63eec67 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -38,6 +38,13 @@ --shadow: rgba(0, 0, 0, 0.36); --surface-highlight: rgba(255, 255, 255, 0.045); --focus-ring: rgba(69, 214, 200, 0.22); + --floating-label-bg: rgba(15, 23, 36, 0.96); + --floating-label-border: rgba(142, 169, 198, 0.34); + --floating-label-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(69, 214, 200, 0.08); + --port-label-bg: rgba(13, 21, 33, 0.96); + --port-label-text: #cbd6e3; + --mini-button-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)), #101a29; + --mini-button-text: #d5dfec; } body.light-mode { @@ -60,6 +67,13 @@ --shadow: rgba(18, 32, 51, 0.12); --surface-highlight: rgba(255, 255, 255, 0.72); --focus-ring: rgba(8, 127, 115, 0.18); + --floating-label-bg: rgba(255, 255, 255, 0.94); + --floating-label-border: rgba(30, 48, 69, 0.18); + --floating-label-shadow: 0 10px 22px rgba(18, 32, 51, 0.14), 0 0 0 1px rgba(8, 127, 115, 0.08); + --port-label-bg: rgba(255, 255, 255, 0.96); + --port-label-text: #334155; + --mini-button-bg: linear-gradient(180deg, #ffffff, #eef5f8); + --mini-button-text: #17263a; } .left-block { @@ -524,6 +538,7 @@ overflow: auto; padding: 58px 18px 18px; box-sizing: border-box; + overscroll-behavior: contain; } .layout-preview-scroll-area { @@ -539,6 +554,7 @@ display: flex; align-items: center; justify-content: center; + background: rgba(255, 255, 255, 0.02); } .layout-preview-image { @@ -620,9 +636,9 @@ } .mini-btn { - background: linear-gradient(180deg, var(--surface-highlight), transparent), var(--input-bg); + background: var(--mini-button-bg); border: 1px solid var(--border); - color: var(--text-muted); + color: var(--mini-button-text); border-radius: 8px; cursor: pointer; height: 32px; @@ -639,6 +655,67 @@ transform: translateY(-1px); } + body.light-mode .mini-btn { + background: var(--mini-button-bg); + border-color: rgba(30, 48, 69, 0.18); + color: var(--mini-button-text); + box-shadow: 0 6px 14px rgba(18, 32, 51, 0.08); + } + + .site-nav-actions { + position: fixed; + top: 14px; + right: 16px; + z-index: 80; + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(13, 22, 38, 0.92); + box-shadow: 0 16px 34px var(--shadow); + backdrop-filter: blur(14px); + } + + body.light-mode .site-nav-actions { + background: rgba(255, 255, 255, 0.94); + border-color: rgba(30, 48, 69, 0.16); + } + + .canvas-toolbar { + position: absolute; + top: 15px; + right: 15px; + z-index: 10; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(13, 22, 38, 0.9); + box-shadow: 0 16px 34px var(--shadow); + backdrop-filter: blur(14px); + } + + body.light-mode .canvas-toolbar { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(30, 48, 69, 0.16); + } + + .grid-snap-label { + font-size: 0.85em; + font-weight: 600; + color: var(--text-main); + user-select: none; + white-space: nowrap; + } + + body.light-mode .grid-snap-label { + color: #102033; + } + .build-layout-btn { position: absolute; bottom: 20px; @@ -814,6 +891,67 @@ right: 1px; } + .element-card-icon.basic-icon { + border-radius: 4px; + overflow: hidden; + background: rgba(15, 23, 42, 0.92); + } + + .element-card-icon.basic-icon::before { + content: ''; + position: absolute; + left: 2px; + right: 2px; + top: 7px; + height: 2px; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.14); + } + + .coordinate-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + align-items: end; + } + + .coordinate-grid label { + display: grid; + gap: 4px; + min-width: 0; + } + + .port-info-list { + display: grid; + gap: 6px; + margin: 0 0 15px 0; + padding: 0; + list-style: none; + color: var(--text-muted); + } + + .port-info-list li { + line-height: 1.45; + letter-spacing: 0.2px; + } + + .save-project-btn { + border: 1px solid rgba(45, 212, 191, 0.45); + background: linear-gradient(180deg, rgba(45, 212, 191, 0.16), rgba(13, 148, 136, 0.12)); + color: var(--text-main); + border-radius: 5px; + padding: 5px 8px; + font-size: 0.68rem; + font-weight: 700; + cursor: pointer; + } + + .save-project-btn:disabled { + cursor: wait; + opacity: 0.66; + } + .category-card { min-height: 94px; } @@ -945,6 +1083,327 @@ border-radius: 999px; background: currentColor; } + + .link-mode-tabs { + position: relative; + display: inline-block; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--input-bg); + } + + .link-mode-summary { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 118px; + height: 30px; + padding: 0 9px; + list-style: none; + cursor: pointer; + user-select: none; + } + + .link-mode-summary::-webkit-details-marker { + display: none; + } + + .link-mode-label { + font-size: 0.72rem; + color: var(--text-muted); + font-weight: 600; + } + + .link-mode-current { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: 0.72rem; + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + } + + .link-mode-current::before, + .link-mode-btn::before { + content: ""; + width: 16px; + height: 3px; + border-radius: 999px; + background: currentColor; + } + + .link-mode-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 40; + min-width: 154px; + display: grid; + gap: 4px; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card); + box-shadow: 0 18px 40px var(--shadow); + } + + .link-mode-btn { + display: flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-main); + font-size: 0.72rem; + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + cursor: pointer; + text-align: left; + } + + .link-mode-btn.active { + background: rgba(14, 165, 233, 0.18); + border-color: currentColor; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + } + + .port-name-label { + position: absolute; + z-index: 4; + max-width: 58px; + padding: 1px 4px; + border-radius: 4px; + background: var(--port-label-bg); + border: 1px solid var(--floating-label-border); + color: var(--port-label-text); + box-shadow: 0 5px 12px rgba(0, 0, 0, 0.18); + font-size: 0.42rem; + line-height: 1.2; + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + } + + .build-progress { + position: absolute; + left: 50%; + top: 14px; + transform: translateX(-50%); + z-index: 20; + width: min(360px, calc(100% - 180px)); + padding: 9px 12px; + border-radius: 8px; + border: 1px solid var(--border-strong); + background: rgba(11, 19, 32, 0.94); + box-shadow: 0 18px 38px var(--shadow); + backdrop-filter: blur(14px); + } + + .build-progress-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 7px; + font-size: 0.72rem; + color: var(--text-main); + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + } + + .build-progress-track { + height: 7px; + border-radius: 999px; + overflow: hidden; + background: rgba(148, 163, 184, 0.18); + } + + .build-progress-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--accent-green)); + transition: width 0.28s ease; + } + + .component-node-shell { + position: relative; + min-width: 0; + max-width: none; + width: 132px; + min-height: 82px; + text-align: center; + font-family: 'IBM Plex Sans', sans-serif; + } + + .component-visual-body { + min-height: 74px; + padding: 10px 15px; + border-radius: 6px; + background: var(--bg-card); + color: var(--text-main); + box-sizing: border-box; + transition: none; + transform-origin: center center; + } + + .component-floating-label { + position: absolute; + left: 50%; + bottom: calc(100% + 7px); + transform: translateX(-50%); + z-index: 8; + min-width: 84px; + max-width: 160px; + padding: 3px 6px; + border-radius: 5px; + border: 1px solid var(--floating-label-border); + background: var(--floating-label-bg); + color: var(--text-main); + box-shadow: var(--floating-label-shadow); + pointer-events: none; + } + + body.light-mode .component-floating-label { + background: var(--floating-label-bg); + border-color: var(--floating-label-border); + box-shadow: var(--floating-label-shadow); + } + + body.light-mode .port-name-label { + background: var(--port-label-bg); + border-color: var(--floating-label-border); + color: var(--port-label-text); + box-shadow: 0 5px 12px rgba(18, 32, 51, 0.1); + } + + .box-size-readout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin: 10px 0 14px; + padding: 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text-main); + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + font-size: 0.68rem; + } + + .box-size-readout span { + color: var(--text-muted); + } + + .component-floating-label strong, + .component-floating-label span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .component-floating-label strong { + font-size: 0.52rem; + font-weight: 650; + } + + .component-floating-label span { + margin-top: 1px; + color: var(--text-muted); + font-size: 0.44rem; + } + + .canvas-size-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: + linear-gradient(180deg, var(--surface-highlight), transparent 74%), + var(--input-bg); + } + + .canvas-size-panel label { + display: block; + margin-bottom: 4px; + color: var(--text-muted); + font-size: 0.68rem; + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + } + + .canvas-size-title { + grid-column: 1 / -1; + color: var(--text-main); + font-size: 0.74rem; + font-weight: 700; + font-family: 'IBM Plex Mono', Consolas, Monaco, monospace; + } + + .canvas-boundary-node { + width: 100%; + height: 100%; + box-sizing: border-box; + border: 4px solid rgba(69, 214, 200, 0.92); + background: rgba(69, 214, 200, 0.018); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14), 0 0 22px rgba(69, 214, 200, 0.18); + pointer-events: none; + } + + .ruler-point-node { + width: 12px; + height: 12px; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid #f8fafc; + background: var(--accent-green); + box-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18), 0 8px 18px rgba(0, 0, 0, 0.28); + pointer-events: none; + } + + .ruler-measurement-node { + min-width: 148px; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid rgba(45, 212, 191, 0.5); + background: rgba(9, 18, 28, 0.94); + color: #e2f7f3; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3); + font: 600 0.62rem/1.35 'IBM Plex Mono', Consolas, Monaco, monospace; + text-align: center; + transform: translate(-50%, -50%); + pointer-events: none; + white-space: nowrap; + } + + body.light-mode .ruler-measurement-node { + background: rgba(255, 255, 255, 0.96); + color: #12323a; + box-shadow: 0 10px 24px rgba(18, 32, 51, 0.14); + } + + .ruler-status { + position: absolute; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 18; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(45, 212, 191, 0.38); + background: rgba(9, 18, 28, 0.9); + color: #c8f7f0; + font: 600 0.68rem/1 'IBM Plex Mono', Consolas, Monaco, monospace; + pointer-events: none; + } + + body.light-mode .ruler-status { + background: rgba(255, 255, 255, 0.94); + color: #0f4c54; + } @@ -963,6 +1422,7 @@ addEdge, Handle, Position, + SelectionMode, useUpdateNodeInternals, applyNodeChanges, applyEdgeChanges, @@ -970,24 +1430,42 @@ const { FORGE_COMPONENT_LABEL, FORGE_COMPONENT_TYPE, + DEFAULT_COMPONENT_BOX_SIZE, + DEFAULT_CANVAS_SIZE, + PORT_NODE_SIZE, ELEMENT_COMPONENTS, + BASIC_COMPONENTS, createForgeArguments, isForgeComponent, + isBasicComponent, + createBasicSettings, + normalizeBoxSize, + chooseCategoryComponent, + normalizeCanvasSize, + clampPositionToCanvas, + calculateLayoutBounds, buildPortHandles, buildElementPorts, + getBasicComponentMetadata, buildInstancesYaml, buildPageComponentPorts, buildCanvasPortsYaml, buildElementsYaml, buildBundlesYaml: buildRouteBundlesYaml, + normalizeAngle, createRouteSettings, updateRouteField, updateRouteXsection, routeStyleForSettings, - findSameFamilyRouteCrossing, - FALLBACK_TECHNOLOGY_MANIFEST + findSameTypeRouteCrossing, + createRulerMeasurement, + createComponentSymbolMetrics, + FALLBACK_TECHNOLOGY_MANIFEST, + layoutToCanvasY } = window.MxpicCanvasHelpers; + const FULL_SELECTION_MODE = SelectionMode && SelectionMode.Full ? SelectionMode.Full : 'full'; + const iconPromiseCache = {}; function fetchIcon(category) { @@ -1066,9 +1544,9 @@ src={src} alt={category} style={{ - maxWidth: '100%', - maxHeight: '100%', - objectFit: 'contain', + width: '100%', + height: '100%', + objectFit: 'fill', pointerEvents: 'none', }} onError={(e) => { @@ -1084,7 +1562,7 @@ const RotatableNode = memo(({ id, data, selected }) => { const updateNodeInternals = useUpdateNodeInternals(); - const prevRotationRef = useRef(data.rotation); + const prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`); const updateNodeInternalsRef = useRef(updateNodeInternals); useEffect(() => { @@ -1092,15 +1570,16 @@ }, [updateNodeInternals]); useEffect(() => { - if (prevRotationRef.current !== data.rotation) { + const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`; + if (prevTransformRef.current !== transformKey) { updateNodeInternalsRef.current(id); - prevRotationRef.current = data.rotation; + prevTransformRef.current = transformKey; } - }, [data.rotation, id]); + }, [data.rotation, data.flip, data.flop, id]); useEffect(() => { updateNodeInternalsRef.current(id); - }, [id, data.ports, data.componentName]); + }, [id, data.ports, data.componentName, data.boxSize]); const baseHandleStyle = { width: 10, height: 10, @@ -1114,53 +1593,68 @@ top: Position.Top, bottom: Position.Bottom }; - const portHandles = useMemo(() => buildPortHandles(data.ports), [data.ports]); + const portHandles = useMemo( + () => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }), + [data.ports, data.rotation, data.flip, data.flop] + ); + const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); + const isAnchorElement = data.elementType === 'anchor'; + const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize; + const iconSize = createComponentSymbolMetrics(componentSize); + const portLabelStyle = (portHandle) => { + const base = { ...portHandle.style }; + if (portHandle.position === 'left') { + return { ...base, right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' }; + } + if (portHandle.position === 'right') { + return { ...base, left: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'left' }; + } + if (portHandle.position === 'top') { + return { ...base, bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' }; + } + return { ...base, top: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' }; + }; return ( -
-
- {!data.hideIcon && data.category && ( -
- -
- )} - -
- {data.componentDisplayName} -
+
+
+ {data.componentDisplayName} {data.componentName && data.componentName !== data.componentDisplayName && ( -
- {data.componentName} + {data.componentName} + )} +
+
+ {isAnchorElement ? ( + A + ) : ( +
+ {!data.hideIcon && data.category && ( +
+ +
+ )} + {!data.category &&
}
)}
@@ -1181,6 +1675,9 @@ title={portHandle.name} style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }} /> + + {portHandle.name} + ))}
@@ -1193,6 +1690,9 @@ prevProps.data.componentName === nextProps.data.componentName && prevProps.data.category === nextProps.data.category && prevProps.data.rotation === nextProps.data.rotation && + prevProps.data.flip === nextProps.data.flip && + prevProps.data.flop === nextProps.data.flop && + prevProps.data.boxSize === nextProps.data.boxSize && prevProps.data.hideIcon === nextProps.data.hideIcon && prevProps.data.ports === nextProps.data.ports ); @@ -1206,7 +1706,7 @@ const handleId = data.portName || data.componentDisplayName || 'port'; return (
{ + const updateNodeInternals = useUpdateNodeInternals(); + const ports = data.ports || buildElementPorts('anchor'); + const portHandles = useMemo( + () => buildPortHandles(ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }), + [ports, data.rotation, data.flip, data.flop] + ); + const handlePositionMap = { + left: Position.Left, + right: Position.Right, + top: Position.Top, + bottom: Position.Bottom + }; + const baseHandleStyle = { + width: 8, + height: 8, + background: 'var(--accent)', + border: '1px solid var(--bg-main)', + borderRadius: '50%' + }; + + useEffect(() => { + updateNodeInternals(id); + }, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]); + + return ( +
+ A + {portHandles.map((portHandle) => ( + + + + + ))} +
+ ); + }); + + const CanvasBoundaryNode = memo(({ data }) => ( +
+ )); + + const RulerPointNode = memo(({ data }) => { + const hiddenHandleStyle = { + width: 1, + height: 1, + opacity: 0, + border: 0, + background: 'transparent', + pointerEvents: 'none' + }; + return ( + <> +
+ + + + ); + }); + + const RulerMeasurementNode = memo(({ data }) => ( +
+ {data.label} +
+ )); + + const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => { + const offset = Number(data?.parallelOffset || 0); + let rawPoints = Array.isArray(data?.points) && data.points.length >= 2 + ? data.points.map(point => ({ x: Number(point.x), y: Number(point.y) })).filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) + : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }]; + if (!data?.freeRoute && rawPoints.length >= 2) { + const sourcePoint = data?.sourceSnap?.point || { x: sourceX, y: sourceY }; + const targetPoint = data?.targetSnap?.point || { x: targetX, y: targetY }; + rawPoints = [ + { x: Number(sourcePoint.x), y: Number(sourcePoint.y) }, + ...rawPoints.slice(1, -1), + { x: Number(targetPoint.x), y: Number(targetPoint.y) } + ]; + } + const firstPoint = rawPoints[0] || { x: sourceX, y: sourceY }; + const lastPoint = rawPoints[rawPoints.length - 1] || { x: targetX, y: targetY }; + const dx = lastPoint.x - firstPoint.x; + const dy = lastPoint.y - firstPoint.y; + const length = Math.hypot(dx, dy) || 1; + const normalX = -dy / length; + const normalY = dx / length; + const points = rawPoints.map(point => ({ + x: point.x + normalX * offset, + y: point.y + normalY * offset + })); + const path = points.length >= 2 + ? `M ${points[0].x},${points[0].y} ${points.slice(1).map(point => `L ${point.x},${point.y}`).join(' ')}` + : `M ${sourceX},${sourceY} Q ${(sourceX + targetX) / 2 + normalX * offset},${(sourceY + targetY) / 2 + normalY * offset} ${targetX},${targetY}`; + const routeStyle = { + ...(style || {}), + fill: 'none', + strokeWidth: selected ? Number(style?.strokeWidth || 2.4) + 1.2 : style?.strokeWidth + }; + return ( + + + + + ); + }); + const LayoutSvgPreview = ({ page }) => { const [layoutScale, setLayoutScale] = useState(100); + 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 updateScale = (value) => { setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100))); }; + 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))); + }; + return (
@@ -1258,11 +1921,11 @@ %
-
+
{ + if (isBasicElement) { + const dragData = JSON.stringify({ + name: componentName, + type: 'basic', + componentName, + settings: createBasicSettings(componentName) + }); + event.dataTransfer.setData('application/reactflow', dragData); + event.dataTransfer.setData('text/plain', dragData); + event.dataTransfer.effectAllowed = 'move'; + return; + } if (isVirtualElement) { const dragData = JSON.stringify({ name: componentName, @@ -1456,20 +2134,20 @@ return (
- {isVirtualElement && ( + {(isVirtualElement || isBasicElement) && ( )} - {!isUserCell && !isVirtualElement && ( + {!isUserCell && !isVirtualElement && !isBasicElement && (
@@ -1485,6 +2163,9 @@ const hasChildren = entries.length > 0; const isComponentGrid = hasChildren && entries.every(([, childData]) => isLibraryComponentLeaf(childData)); const isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true); + const isDirectLeafGrid = isComponentGrid && entries.every(([, childData]) => ( + childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true + )); const isCategoryGrid = hasChildren && entries.every(([, childData]) => { const childEntries = Object.entries(childData || {}); return childEntries.length > 0 && childEntries.every(([, grandChild]) => isLibraryComponentLeaf(grandChild)); @@ -1499,7 +2180,13 @@ {hasChildren && ( - isCategoryGrid && !isElementComponentGrid ? ( + isDirectLeafGrid ? ( +
+ {entries.map(([childName, childData]) => ( + + ))} +
+ ) : isCategoryGrid && !isElementComponentGrid ? (
{entries.map(([childName, childData]) => ( @@ -1679,10 +2366,11 @@ return null; }; - const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => { + const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => { const [projectPanelHeight, setProjectPanelHeight] = useState(270); const [resizingProjectPanel, setResizingProjectPanel] = useState(false); const leftPanelRef = useRef(null); + const size = normalizeCanvasSize(canvasSize); useEffect(() => { if (!resizingProjectPanel) return; @@ -1720,6 +2408,9 @@
Project Tree
+ @@ -1729,6 +2420,29 @@
+
+
Canvas Size
+
+ + onCanvasSizeChange && onCanvasSizeChange('width', event.target.value)} + /> +
+
+ + onCanvasSizeChange && onCanvasSizeChange('height', event.target.value)} + /> +
+
{projectTreeItems && projectTreeItems.length > 0 ? ( projectTreeItems.map(item => { if (item.type === 'project') { @@ -1772,7 +2486,7 @@ ); }; - const RightPanel = ({ selectedNode, selectedEdge, technologyManifest, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { + const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { const [componentData, setComponentData] = useState(null); const [loading, setLoading] = useState(false); const [enlarged, setEnlarged] = useState(null); @@ -1790,7 +2504,7 @@ return; } const compName = selectedNode?.data?.componentName; - if (selectedNode?.data?.elementType) { + if (selectedNode?.data?.elementType || isBasicComponent(compName)) { setComponentData(null); setLoading(false); return; @@ -1808,13 +2522,14 @@ if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return; setLoading(true); - fetch(`/api/component/${encodeURIComponent(compName)}`) + fetch(`/api/component/${encodeURIComponent(compName)}?project=${encodeURIComponent(projectName || '')}`) .then(r => r.json()) .then(data => { setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name }); onUpdateNode(nodeId, { data: { ports: data.ports || {}, + boxSize: normalizeBoxSize(data), foundry: data.foundry || '', process: data.process || '' } @@ -1822,7 +2537,7 @@ setLoading(false); }) .catch(() => setLoading(false)); - }, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, onUpdateNode]); + }, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, projectName, onUpdateNode]); useEffect(() => { if (selectedNode) { @@ -1835,11 +2550,25 @@ } }, [selectedNode?.position.x, selectedNode?.position.y, selectedNode?.data?.rotation, selectedNode?.data?.angle, selectedNode?.id]); + const selectedPositionNodes = useMemo( + () => (selectedNodes.length > 0 ? selectedNodes : (selectedNode ? [selectedNode] : [])).filter(node => node && node.position), + [selectedNodes, selectedNode] + ); + const updatePosition = useCallback((id, axis, value) => { const val = parseFloat(value); if (isNaN(val)) return; + if (selectedPositionNodes.length > 1 && selectedPositionNodes.some(node => node.id === id)) { + const baseNode = selectedPositionNodes.find(node => node.id === id) || selectedPositionNodes[0]; + const delta = val - Number((baseNode.position && baseNode.position[axis]) || 0); + selectedPositionNodes.forEach(node => { + const currentValue = Number((node.position && node.position[axis]) || 0); + onUpdateNode(node.id, { position: { [axis]: currentValue + delta } }); + }); + return; + } onUpdateNode(id, { position: { [axis]: val } }); - }, [onUpdateNode]); + }, [onUpdateNode, selectedPositionNodes]); const updateRotation = useCallback((id, value, isPortNode = false) => { const val = parseFloat(value); @@ -1849,9 +2578,15 @@ onUpdateNode(id, { data: dataField }); }, [onUpdateNode]); + const toggleComponentTransform = useCallback((key) => { + if (!selectedNode) return; + onUpdateNode(selectedNode.id, { data: { [key]: !Boolean(selectedNode.data?.[key]) } }); + }, [onUpdateNode, selectedNode]); + const formatPort = (port) => { if (!port) return '-'; - return `x:${port.x ?? '?'}, y:${port.y ?? '?'}, a:${port.a ?? '?'}, w:${port.width ?? '?'}`; + const description = port.description ? ` ${port.description}` : ''; + return `(${port.x ?? '?'}, ${port.y ?? '?'}, ${port.a ?? '?'})${description}`; }; const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || ''; @@ -1863,12 +2598,30 @@ const selectedIsVirtualElement = selectedNode?.data?.elementType === 'port' || selectedNode?.data?.elementType === 'anchor'; const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0; const forgeSelected = isForgeComponent(selectedComponentName); + const basicSelected = isBasicComponent(selectedComponentName); + const basicMetadata = basicSelected ? getBasicComponentMetadata(selectedComponentName, selectedNode?.data?.basicArguments) : null; + const basicArguments = basicSelected ? createBasicSettings(selectedComponentName, selectedNode?.data?.basicArguments) : {}; const forgeArguments = createForgeArguments(selectedNode?.data?.forgeArguments); const selectedIsPort = selectedNode && (selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port'); const selectedIsAnchor = selectedNode?.data?.elementType === 'anchor'; + const selectedNodeBoxSize = selectedNode?.data?.componentName && !selectedNode?.data?.elementType + ? normalizeBoxSize({ box_size: selectedNode.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE) + : null; - if (selectedEdge) { - const route = createRouteSettings(technologyManifest, selectedEdge.data?.route); + const selectedRouteEdges = selectedEdges.length > 0 ? selectedEdges : (selectedEdge ? [selectedEdge] : []); + if (selectedRouteEdges.length > 0) { + const routes = selectedRouteEdges.map(edge => createRouteSettings(technologyManifest, edge.data?.route)); + const selectedEdgeIds = selectedRouteEdges.map(edge => edge.id); + const firstRoute = routes[0]; + const mixedValue = (key) => routes.every(route => String(route[key]) === String(firstRoute[key])) ? firstRoute[key] : '__mixed__'; + const route = { + ...firstRoute, + xsection: mixedValue('xsection'), + family: mixedValue('family'), + width: mixedValue('width'), + radius: mixedValue('radius'), + routing_type: mixedValue('routing_type') + }; const xsections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {}); const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend']; return ( @@ -1881,13 +2634,14 @@
Route Editor
- {route.family} / {route.xsection} + {selectedRouteEdges.length} selected / {route.family === '__mixed__' ? '--' : route.family}
onUpdateEdgeRoute(selectedEdge.id, updateRouteField(route, 'width', event.target.value, technologyManifest))} + value={route.width === '__mixed__' ? '--' : route.width} + onChange={(event) => { + if (event.target.value === '--') return; + onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'width', event.target.value, technologyManifest)); + }} />

onUpdateEdgeRoute(selectedEdge.id, updateRouteField(route, 'radius', event.target.value, technologyManifest))} + value={route.radius === '__mixed__' ? '--' : route.radius} + onChange={(event) => { + if (event.target.value === '--') return; + onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'radius', event.target.value, technologyManifest)); + }} />

setLocalX(e.target.value)} - onBlur={() => { - const val = parseFloat(localX); - if (!isNaN(val) && selectedNode) { - updatePosition(selectedNode.id, 'x', val); - setLocalX(val.toFixed(3)); - } else if (selectedNode) { - setLocalX(selectedNode.position.x.toFixed(3)); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur(); - }} - /> -

- - setLocalY(e.target.value)} - onBlur={() => { - const val = parseFloat(localY); - if (!isNaN(val) && selectedNode) { - updatePosition(selectedNode.id, 'y', val); - setLocalY(val.toFixed(3)); - } else if (selectedNode) { - setLocalY(selectedNode.position.y.toFixed(3)); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur(); - }} - /> -

- - setLocalRotation(e.target.value)} - onBlur={() => { - const val = parseFloat(localRotation); - if (!isNaN(val) && selectedNode) { - updateRotation(selectedNode.id, val, selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port'); - setLocalRotation(val.toFixed(3)); - } else if (selectedNode) { - const rot = selectedNode.id === 'page-port' - ? (selectedNode.data?.angle ?? 0) - : (selectedNode.data?.rotation ?? 0); - setLocalRotation(rot.toFixed(3)); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur(); - }} - /> +
+ + + +
+ {selectedPositionNodes.length > 1 && ( +
+ {selectedPositionNodes.length} selected +
+ )} + {selectedNode?.data?.componentName && !selectedNode?.data?.elementType && ( +
+ + +
+ )}
) : (

Select a node to inspect

@@ -2127,7 +2934,8 @@ data: { componentName, label: componentName, - ports: forge ? {} : selectedNode.data.ports, + ports: forge ? {} : undefined, + boxSize: forge ? DEFAULT_COMPONENT_BOX_SIZE : undefined, forgeArguments: forge ? createForgeArguments(selectedNode.data.forgeArguments) : selectedNode.data.forgeArguments } }); @@ -2139,7 +2947,77 @@
)} - {forgeSelected ? ( + {selectedNodeBoxSize && ( +
+ +
+
W {selectedNodeBoxSize.width.toFixed(3)} um
+
H {selectedNodeBoxSize.height.toFixed(3)} um
+
+
+ )} + {basicSelected && basicMetadata ? ( + <> +
+ + {editingComponentName ? ( + setTempComponentName(e.target.value)} + onBlur={handleSaveName} + onKeyDown={handleKeyDown} + autoFocus + /> + ) : ( +
+ {currentComponentDisplayName || selectedComponentName} + Edit +
+ )} +
+

Nazca Primitive:

+
+ {selectedComponentName} / {basicMetadata.process} +
+
+ {Object.entries(basicArguments).map(([key, value]) => ( + + ))} +
+

Ports:

+
    + {Object.entries(basicMetadata.ports || {}).map(([portName, portInfo]) => ( +
  • + {portName} : {formatPort(portInfo)} +
  • + ))} +
+ + ) : forgeSelected ? ( <>
@@ -2260,10 +3138,10 @@

Ports:

-
    +
      {componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => ( -
    • - {portName}: {formatPort(portInfo)} +
    • + {portName} : {formatPort(portInfo)}
    • ))}
    @@ -2276,11 +3154,11 @@ overflow: 'hidden', }}> Component layout setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image`)} + onClick={() => setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image?project=${encodeURIComponent(projectName || '')}`)} onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentElement.innerHTML = 'No preview'; @@ -2335,24 +3213,23 @@ ); function findComponentPath(lib, compName) { - const path = []; function walk(obj, currentPath) { if (obj && obj.__type__ === 'component' && obj.__name__ === compName) { - path.push(...currentPath); - return true; + if (obj.__path__) { + return obj.__path__.split('/').filter(Boolean); + } + return currentPath; } if (typeof obj === 'object') { for (const [key, val] of Object.entries(obj)) { - if (walk(val, [...currentPath, key])) return true; + const result = walk(val, [...currentPath, key]); + if (result) return result; } } - return false; + return null; } - walk(lib, []); - return path; + return walk(lib, []) || []; } - - function buildCompInstanceTree(compNodes, library) { const tree = {}; compNodes.forEach(node => { @@ -2398,6 +3275,7 @@ const [pages, setPages] = useState([]); const [activePageId, setActivePageId] = useState(null); const reactFlowInstance = useReactFlow(); + const spaceRotateNodeIdRef = useRef(null); const [library, setLibrary] = useState(null); const [treeKey, setTreeKey] = useState(0); @@ -2415,18 +3293,214 @@ const [gridSnap, setGridSnap] = useState(false); const [themeMode, setThemeMode] = useState(() => localStorage.getItem('mxpic-theme') || 'dark'); const [logs, setLogs] = useState([{ time: new Date().toLocaleTimeString(), message: 'Editor ready.' }]); + const [buildLayoutBusy, setBuildLayoutBusy] = useState(false); const [buildGdsBusy, setBuildGdsBusy] = useState(false); + const [saveProjectBusy, setSaveProjectBusy] = useState(false); + const [buildProgress, setBuildProgress] = useState({ active: false, label: '', value: 0 }); + const [rulerMode, setRulerMode] = useState(false); + const [rulerStartPoint, setRulerStartPoint] = useState(null); + const [rulerEndPoint, setRulerEndPoint] = useState(null); + const [rulerPreviewPoint, setRulerPreviewPoint] = useState(null); const [projectTechnology, setProjectTechnology] = useState(''); const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST); + const [currentLinkXsection, setCurrentLinkXsection] = useState('strip'); const [clipboard, setClipboard] = useState({ nodes: [] }); const initializedRef = useRef(false); + const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []); const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]); const currentNodes = activePage && Array.isArray(activePage.nodes) ? activePage.nodes : []; const currentEdges = activePage && Array.isArray(activePage.edges) ? activePage.edges : []; - const selectedEdge = useMemo(() => currentEdges.find(edge => edge.selected) || null, [currentEdges]); + const activeCanvasSize = useMemo(() => normalizeCanvasSize(activePage?.canvasSize), [activePage?.canvasSize]); + const selectedEdges = useMemo(() => currentEdges.filter(edge => edge.selected), [currentEdges]); + const selectedEdge = selectedEdges[0] || null; + const selectedNodes = useMemo(() => currentNodes.filter(n => n.selected), [currentNodes]); + const selectedNode = selectedNodes[0] || null; + const linkXsectionChoices = useMemo(() => { + const manifestSections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {}); + const preferred = ['strip', 'rib_low', 'metal_1', 'metal_2']; + const ordered = preferred.filter(xsection => manifestSections.includes(xsection)); + manifestSections.forEach(xsection => { + if (!ordered.includes(xsection)) ordered.push(xsection); + }); + return ordered.length ? ordered : preferred; + }, [technologyManifest]); + const currentLinkRoute = useMemo( + () => createRouteSettings(technologyManifest, { xsection: currentLinkXsection }), + [technologyManifest, currentLinkXsection] + ); + useEffect(() => { + if (!linkXsectionChoices.includes(currentLinkXsection)) { + setCurrentLinkXsection(linkXsectionChoices[0] || 'strip'); + } + }, [linkXsectionChoices, currentLinkXsection]); + const canvasNodeExtent = useMemo(() => [[0, 0], [activeCanvasSize.width, activeCanvasSize.height]], [activeCanvasSize.width, activeCanvasSize.height]); + const rulerActiveEndPoint = rulerEndPoint || rulerPreviewPoint; + const rulerMeasurement = useMemo( + () => createRulerMeasurement(rulerStartPoint, rulerActiveEndPoint), + [rulerStartPoint, rulerActiveEndPoint] + ); + const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null; + const compactRoutePoints = useCallback((points) => { + return (points || []) + .map(point => ({ + x: Number(Number(point.x).toFixed(3)), + y: Number(Number(point.y).toFixed(3)) + })) + .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) + .filter((point, index, list) => index === 0 || point.x !== list[index - 1].x || point.y !== list[index - 1].y); + }, []); + const routeEndpointNodeId = useCallback((edgeId, endpoint) => `__free_route_${edgeId}_${endpoint}__`, []); + const makeFreeRouteEdge = useCallback((edgeId, points, route, selected = false) => { + const view = routeStyleForSettings(route, selected); + return { + id: edgeId, + source: routeEndpointNodeId(edgeId, 'start'), + target: routeEndpointNodeId(edgeId, 'end'), + sourceHandle: 'route', + targetHandle: 'route', + type: 'parallelRoute', + selectable: true, + style: view.style, + data: { route, points: compactRoutePoints(points), freeRoute: true }, + }; + }, [compactRoutePoints, routeEndpointNodeId]); + const rulerNodes = useMemo(() => { + if (!activePage || activePage.type === 'layoutPreview' || !rulerStartPoint) return []; + const nodes = [{ + id: '__ruler_start__', + type: 'rulerPointNode', + position: { x: rulerStartPoint.x - 6, y: rulerStartPoint.y - 6 }, + data: { label: `Start (${rulerStartPoint.x.toFixed(3)}, ${rulerStartPoint.y.toFixed(3)})` }, + draggable: false, + selectable: false, + deletable: false, + focusable: false + }]; + if (rulerActiveEndPoint) { + const isPreviewPoint = !rulerEndPoint; + nodes.push({ + id: isPreviewPoint ? '__ruler_preview__' : '__ruler_end__', + type: 'rulerPointNode', + position: { x: rulerActiveEndPoint.x - 6, y: rulerActiveEndPoint.y - 6 }, + data: { label: `${isPreviewPoint ? 'Preview' : 'End'} (${rulerActiveEndPoint.x.toFixed(3)}, ${rulerActiveEndPoint.y.toFixed(3)})` }, + draggable: false, + selectable: false, + deletable: false, + focusable: false + }); + } + if (rulerMeasurement) { + nodes.push({ + id: '__ruler_measurement__', + type: 'rulerMeasurementNode', + position: { x: rulerMeasurement.midpoint.x, y: rulerMeasurement.midpoint.y }, + data: { + label: rulerMeasurement.label, + title: `Distance ${rulerMeasurement.distance.toFixed(3)} um; dx ${rulerMeasurement.dx.toFixed(3)}; dy ${rulerMeasurement.dy.toFixed(3)}` + }, + draggable: false, + selectable: false, + deletable: false, + focusable: false + }); + } + return nodes; + }, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]); + const rulerEdges = useMemo(() => { + if (!rulerMeasurement) return []; + return [{ + id: '__ruler_edge__', + source: '__ruler_start__', + target: rulerPreviewMeasurement ? '__ruler_preview__' : '__ruler_end__', + type: 'straight', + selectable: false, + focusable: false, + data: { ruler: true }, + style: { + stroke: '#2dd4bf', + strokeWidth: 2, + strokeDasharray: rulerPreviewMeasurement ? undefined : '8 6' + } + }]; + }, [rulerMeasurement, rulerPreviewMeasurement]); + const freeRouteEndpointNodes = useMemo(() => { + if (!activePage || activePage.type === 'layoutPreview') return []; + return currentEdges.flatMap(edge => { + const points = edge.data?.freeRoute && Array.isArray(edge.data?.points) ? compactRoutePoints(edge.data.points) : []; + if (points.length < 2) return []; + const first = points[0]; + const last = points[points.length - 1]; + return [ + { + id: routeEndpointNodeId(edge.id, 'start'), + type: 'rulerPointNode', + position: { x: first.x - 6, y: first.y - 6 }, + data: { label: 'route start' }, + draggable: false, + selectable: false, + deletable: false, + focusable: false, + style: { opacity: 0.001, pointerEvents: 'none' } + }, + { + id: routeEndpointNodeId(edge.id, 'end'), + type: 'rulerPointNode', + position: { x: last.x - 6, y: last.y - 6 }, + data: { label: 'route end' }, + draggable: false, + selectable: false, + deletable: false, + focusable: false, + style: { opacity: 0.001, pointerEvents: 'none' } + } + ]; + }); + }, [activePage, currentEdges, compactRoutePoints, routeEndpointNodeId]); + const renderNodes = useMemo(() => { + if (!activePage || activePage.type === 'layoutPreview') return currentNodes; + return [{ + id: '__canvas-boundary__', + type: 'canvasBoundaryNode', + position: { x: 0, y: 0 }, + data: { size: activeCanvasSize }, + draggable: false, + selectable: false, + deletable: false, + focusable: false, + style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' } + }, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes]; + }, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]); + const renderEdges = useMemo(() => { + const groups = new Map(); + currentEdges.forEach(edge => { + const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`; + const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`; + const key = [sourceEndpoint, targetEndpoint].sort().join('<>'); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(edge.id); + }); + const separatedEdges = currentEdges.map(edge => { + const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`; + const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`; + const key = [sourceEndpoint, targetEndpoint].sort().join('<>'); + const group = groups.get(key) || []; + if (group.length <= 1 && !(edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2)) return edge; + const index = group.indexOf(edge.id); + const offset = (index - (group.length - 1) / 2) * 18; + return { + ...edge, + type: 'parallelRoute', + data: { + ...(edge.data || {}), + parallelOffset: offset + } + }; + }); + return [...separatedEdges, ...rulerEdges]; + }, [currentEdges, rulerEdges]); const [projectCompositeMap, setProjectCompositeMap] = useState({}); const [standaloneComposites, setStandaloneComposites] = useState([]); @@ -2441,6 +3515,45 @@ setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]); }, []); + useEffect(() => { + if (!buildProgress.active || buildProgress.value >= 100) return; + const timer = window.setInterval(() => { + setBuildProgress(prev => { + if (!prev.active || prev.value >= 94) return prev; + return { ...prev, value: Math.min(94, prev.value + Math.max(1, Math.round((96 - prev.value) / 9))) }; + }); + }, 360); + return () => window.clearInterval(timer); + }, [buildProgress.active, buildProgress.value]); + + const startBuildProgress = useCallback((label) => { + setBuildProgress({ active: true, label, value: 8 }); + }, []); + + const completeBuildProgress = useCallback((label) => { + setBuildProgress({ active: true, label, value: 100 }); + window.setTimeout(() => { + setBuildProgress(prev => prev.value === 100 ? { active: false, label: '', value: 0 } : prev); + }, 900); + }, []); + + const stopBuildProgress = useCallback(() => { + setBuildProgress({ active: false, label: '', value: 0 }); + }, []); + + const toBooleanFlag = useCallback((value) => ( + value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true' + ), []); + + const normalizeRoutePoints = useCallback((points, usesGdsYUp = false) => ( + (Array.isArray(points) ? points : []) + .map(point => ({ + x: Number(point && point.x), + y: usesGdsYUp ? layoutToCanvasY(point && point.y) : Number(point && point.y) + })) + .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) + ), []); + const loadTechnologyManifest = useCallback(async (technologyId) => { if (!technologyId || !technologyId.includes('/')) { setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); @@ -2472,12 +3585,12 @@ if (componentDataCacheRef.current.has(componentName)) { return componentDataCacheRef.current.get(componentName); } - const response = await fetch(`/api/component/${encodeURIComponent(componentName)}`); + const response = await fetch(`/api/component/${encodeURIComponent(componentName)}?project=${encodeURIComponent(currentProjectName)}`); if (!response.ok) return null; const data = await response.json(); componentDataCacheRef.current.set(componentName, data); return data; - }, []); + }, [currentProjectName]); const recordUserAction = useCallback((action, payload = {}) => { fetch('/api/logs', { @@ -2531,9 +3644,17 @@ const onNodesChange = useCallback((changes) => { if (!activePageId) return; + const relevantChanges = changes.filter(change => change.id !== '__canvas-boundary__'); + if (relevantChanges.length === 0) return; setPages(prev => prev.map(p => { if (p.id !== activePageId) return p; - const newNodes = applyNodeChanges(changes, p.nodes); + const newNodes = applyNodeChanges(relevantChanges, p.nodes).map(node => { + if (!node.position || node.id === 'page-port') return node; + const boxSize = node.type === 'rotatableNode' + ? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE) + : { width: 30, height: 30 }; + return { ...node, position: clampPositionToCanvas(node.position, p.canvasSize || activeCanvasSize, boxSize) }; + }); const portNode = newNodes.find(n => n.id === 'page-port'); let newPort = p.port; if (portNode) { @@ -2546,7 +3667,7 @@ } return { ...p, nodes: newNodes, port: newPort }; })); - }, [activePageId]); + }, [activePageId, activeCanvasSize]); const onEdgesChange = useCallback((changes) => { if (!activePageId) return; @@ -2555,7 +3676,8 @@ const styledEdges = applyEdgeChanges(changes, p.edges).map(edge => { const route = createRouteSettings(technologyManifest, edge.data?.route); const view = routeStyleForSettings(route, edge.selected); - return { ...edge, type: view.type, style: view.style, data: { ...edge.data, route } }; + const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2; + return { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } }; }); return { ...p, edges: styledEdges }; })); @@ -2567,10 +3689,15 @@ if (p.id !== activePageId) return p; const newNodes = p.nodes.map(n => { if (n.id === nodeId) { + const nextData = { ...n.data, ...update.data }; + const nextPosition = update.position != null ? { ...n.position, ...update.position } : n.position; + const boxSize = n.type === 'rotatableNode' + ? normalizeBoxSize({ box_size: nextData.boxSize }, DEFAULT_COMPONENT_BOX_SIZE) + : { width: 30, height: 30 }; return { ...n, - position: update.position != null ? { ...n.position, ...update.position } : n.position, - data: { ...n.data, ...update.data } + position: update.position != null ? clampPositionToCanvas(nextPosition, p.canvasSize || activeCanvasSize, boxSize) : nextPosition, + data: nextData }; } return n; @@ -2584,24 +3711,85 @@ } return { ...p, nodes: newNodes, port: newPort }; })); + }, [activePageId, activeCanvasSize]); + + const handleCanvasSizeChange = useCallback((axis, value) => { + if (!activePageId) return; + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) return; + setPages(prev => prev.map(page => { + if (page.id !== activePageId) return page; + const nextCanvasSize = normalizeCanvasSize({ + ...(page.canvasSize || DEFAULT_CANVAS_SIZE), + [axis]: numericValue + }); + return { + ...page, + canvasSize: nextCanvasSize, + nodes: page.nodes.map(node => { + if (!node.position || node.id === 'page-port') return node; + const boxSize = node.type === 'rotatableNode' + ? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE) + : { width: 30, height: 30 }; + return { ...node, position: clampPositionToCanvas(node.position, nextCanvasSize, boxSize) }; + }) + }; + })); }, [activePageId]); - const handleUpdateEdgeRoute = useCallback((edgeId, nextRoute) => { + const rotateComponentByNinety = useCallback((nodeId) => { + if (!activePageId || !nodeId) return; + setPages(prev => prev.map(p => { + if (p.id !== activePageId) return p; + return { + ...p, + nodes: p.nodes.map(node => { + if (node.id !== nodeId || node.type !== 'rotatableNode' || node.data?.elementType) return node; + const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90); + return { ...node, data: { ...node.data, rotation } }; + }) + }; + })); + }, [activePageId]); + + const getSpaceRotationTarget = useCallback(() => { + if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current; + const selectedSpaceNode = selectedNode; + if (!selectedSpaceNode || selectedSpaceNode.type !== 'rotatableNode' || selectedSpaceNode.data?.elementType) return null; + return selectedSpaceNode.id; + }, [selectedNode]); + + const onNodeMouseDown = useCallback((event, node) => { + if (event.button !== 0 || node.type !== 'rotatableNode' || node.data?.elementType) return; + spaceRotateNodeIdRef.current = node.id; + }, []); + + const clearSpaceRotateNode = useCallback(() => { + spaceRotateNodeIdRef.current = null; + }, []); + + const handleUpdateEdgeRoute = useCallback((edgeIds, routeUpdate) => { if (!activePageId) return; + const targetEdgeIds = new Set(Array.isArray(edgeIds) ? edgeIds : [edgeIds]); setPages(prev => prev.map(p => { if (p.id !== activePageId) return p; const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node])); let rejected = false; const nextEdges = p.edges.map(edge => { - if (edge.id !== edgeId) return edge; - const route = createRouteSettings(technologyManifest, nextRoute); + if (!targetEdgeIds.has(edge.id)) return edge; + const currentRoute = createRouteSettings(technologyManifest, edge.data?.route); + const route = createRouteSettings( + technologyManifest, + typeof routeUpdate === 'function' ? routeUpdate(currentRoute, edge) : routeUpdate + ); const view = routeStyleForSettings(route, edge.selected); - const candidate = { ...edge, type: view.type, style: view.style, data: { ...edge.data, route } }; - const conflict = findSameFamilyRouteCrossing(candidate, p.edges, nodeMap, technologyManifest); + const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2; + const candidate = { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } }; + const conflict = findSameTypeRouteCrossing(candidate, p.edges, nodeMap, technologyManifest); if (conflict) { const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; - addLog(`Route update rejected: ${route.family} route crosses ${source} to ${target}.`); + addLog(`Route update rejected: ${route.xsection} route crosses ${source} to ${target}.`); rejected = true; return edge; } @@ -2685,7 +3873,11 @@ const cmdOrCtrl = e.ctrlKey || e.metaKey; - if (cmdOrCtrl && e.key.toLowerCase() === 'c') { + const spaceRotationTarget = getSpaceRotationTarget(); + if ((e.code === 'Space' || e.key === ' ') && spaceRotationTarget) { + e.preventDefault(); + rotateComponentByNinety(spaceRotationTarget); + } else if (cmdOrCtrl && e.key.toLowerCase() === 'c') { e.preventDefault(); handleCopy(); } else if (cmdOrCtrl && e.key.toLowerCase() === 'x') { @@ -2700,8 +3892,12 @@ }; window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleCopy, handleCut, handlePaste, handleDelete]); + window.addEventListener('mouseup', clearSpaceRotateNode); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('mouseup', clearSpaceRotateNode); + }; + }, [handleCopy, handleCut, handlePaste, handleDelete, rotateComponentByNinety, getSpaceRotationTarget, clearSpaceRotateNode]); const componentCounterRef = useRef(1); @@ -2724,13 +3920,13 @@ const fetchLibrary = useCallback(async () => { try { - const res = await fetch('/api/library'); + const res = await fetch(`/api/library?project=${encodeURIComponent(currentProjectName)}`); const data = await res.json(); setLibrary(data); } catch (err) { console.error('Failed to fetch library', err); } - }, []); + }, [currentProjectName]); useEffect(() => { fetchLibrary(); }, [fetchLibrary]); const collectComponentNames = useCallback((lib) => { @@ -2757,6 +3953,7 @@ try { const text = await file.text(); const doc = jsyaml.load(text); + const usesGdsYUp = doc.coordinate_system === 'gds_y_up'; if (!doc.instances) { alert('no instances found'); return; @@ -2771,7 +3968,9 @@ const compPath = inst.component || ''; const compName = compPath.split('/').pop(); const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName); - const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : compName; + const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName); + const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName); + const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null; let category = ''; if (!isProject && displayCompName && library && !instIsForge) { @@ -2796,18 +3995,22 @@ type: 'rotatableNode', position: { x: parseFloat(inst.x) || 0, - y: parseFloat(inst.y) || 0, + y: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0), }, data: { label: isProject ? instName : displayCompName, componentName: isProject ? instName : displayCompName, category: isProject ? '' : category, rotation: parseFloat(inst.rotation) || 0, + flip: toBooleanFlag(inst.flip ?? inst.mirror), + flop: toBooleanFlag(inst.flop), componentDisplayName: instName, type: isProject ? 'composite' : undefined, availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined, - ports: instIsForge ? {} : undefined, + ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined), + boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined, forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined, + basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined, }, }); } @@ -2817,13 +4020,14 @@ if (links) { const linkArray = Array.isArray(links) ? links : [links]; linkArray.forEach(link => { + const route = createRouteSettings(technologyManifest, link); + const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up'); if (link.from && link.to) { const [fromInst, fromPort] = link.from.split(':'); const [toInst, toPort] = link.to.split(':'); const sourceId = nodeNameMap[fromInst]; const targetId = nodeNameMap[toInst]; if (sourceId && targetId) { - const route = createRouteSettings(technologyManifest, link); const view = routeStyleForSettings(route, false); newEdges.push({ id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`, @@ -2833,9 +4037,12 @@ targetHandle: toPort, type: view.type, style: view.style, - data: { route }, + data: { route, points: routePoints }, }); } + } else if (routePoints.length >= 2) { + const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route)); } }); } @@ -2844,12 +4051,13 @@ const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5); const newPageName = file.name.replace(/\.(yaml|yml)$/i, ''); const importedPort = Array.isArray(doc.ports) && doc.ports[0] - ? { x: Number(doc.ports[0].x || 0), y: Number(doc.ports[0].y || 0), a: Number(doc.ports[0].angle ?? doc.ports[0].a ?? 0), width: Number(doc.ports[0].width || 0.5) } + ? { x: Number(doc.ports[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.ports[0].y) : Number(doc.ports[0].y || 0), a: Number(doc.ports[0].angle ?? doc.ports[0].a ?? 0), width: Number(doc.ports[0].width || 0.5) } : { x: 50, y: 150, a: 0, width: 0.5 }; const newPage = { id: newPageId, name: newPageName, type: isProject ? 'project' : 'composite', + canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize), nodes: isProject ? newNodes : [ { id: 'page-port', @@ -2914,7 +4122,7 @@ input.addEventListener('change', handleFile); return () => input.removeEventListener('change', handleFile); - }, [library, technologyManifest]); + }, [library, technologyManifest, makeFreeRouteEdge]); useEffect(() => { setProjectCompositeMap(prev => { @@ -2941,6 +4149,7 @@ type: 'project', nodes: [], edges: [], + canvasSize: DEFAULT_CANVAS_SIZE, port: { x: 0, y: 0, a: 0 } }); @@ -2962,9 +4171,10 @@ const pageFromYaml = (cellName, content, manifest) => { const doc = jsyaml.load(content) || {}; + const usesGdsYUp = doc.coordinate_system === 'gds_y_up'; const firstPort = Array.isArray(doc.ports) ? doc.ports[0] : null; const pagePort = firstPort - ? { x: Number(firstPort.x || 0), y: Number(firstPort.y || 0), a: Number(firstPort.angle ?? firstPort.a ?? 0), width: Number(firstPort.width || 0.5) } + ? { x: Number(firstPort.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPort.y) : Number(firstPort.y || 0), a: Number(firstPort.angle ?? firstPort.a ?? 0), width: Number(firstPort.width || 0.5) } : { x: 50, y: 150, a: 0, width: 0.5 }; const nodeNameMap = {}; const nodes = [ @@ -2984,7 +4194,9 @@ const compPath = inst.component || ''; const compName = compPath.split('/').pop(); const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName); - const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : compName; + const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName); + const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName); + const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null; const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; nodeNameMap[instName] = nodeId; nodes.push({ @@ -2992,17 +4204,21 @@ type: 'rotatableNode', position: { x: parseFloat(inst.x) || 0, - y: parseFloat(inst.y) || 0, + y: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0), }, data: { label: displayCompName, componentName: displayCompName, category: instIsForge ? '' : findCategory(displayCompName), rotation: parseFloat(inst.rotation) || 0, + flip: toBooleanFlag(inst.flip ?? inst.mirror), + flop: toBooleanFlag(inst.flop), componentDisplayName: instName, availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined, - ports: instIsForge ? {} : undefined, + ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined), + boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined, forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined, + basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined, }, }); }); @@ -3011,24 +4227,29 @@ if (links) { const linkArray = Array.isArray(links) ? links : [links]; linkArray.forEach(link => { - if (!link.from || !link.to) return; - const [fromInst, fromPort] = link.from.split(':'); - const [toInst, toPort] = link.to.split(':'); - const sourceId = nodeNameMap[fromInst]; - const targetId = nodeNameMap[toInst]; - if (!sourceId || !targetId) return; const route = createRouteSettings(manifest, link); - const view = routeStyleForSettings(route, false); - edges.push({ - id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`, - source: sourceId, - target: targetId, - sourceHandle: fromPort, - targetHandle: toPort, - type: view.type, - style: view.style, - data: { route }, - }); + const routePoints = normalizeRoutePoints(link.points, usesGdsYUp); + if (link.from && link.to) { + const [fromInst, fromPort] = link.from.split(':'); + const [toInst, toPort] = link.to.split(':'); + const sourceId = nodeNameMap[fromInst]; + const targetId = nodeNameMap[toInst]; + if (!sourceId || !targetId) return; + const view = routeStyleForSettings(route, false); + edges.push({ + id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`, + source: sourceId, + target: targetId, + sourceHandle: fromPort, + targetHandle: toPort, + type: view.type, + style: view.style, + data: { route, points: routePoints }, + }); + } else if (routePoints.length >= 2) { + const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + edges.push(makeFreeRouteEdge(edgeId, routePoints, route)); + } }); } @@ -3036,6 +4257,7 @@ id: `cell-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`, name: doc.name || cellName, type: doc.type === 'project' ? 'project' : 'composite', + canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize), nodes, edges, port: pagePort @@ -3060,12 +4282,15 @@ setProjectTechnology(technology); const manifest = await loadTechnologyManifest(technology); const cellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest)); - setPages([projectPage, ...cellPages]); - setActivePageId(projectPage.id); - setProjectCompositeMap({ [currentProjectName]: cellPages.map(page => page.name) }); + const loadedProjectPage = cellPages.find(page => page.type === 'project' && page.name === currentProjectName); + const nonProjectPages = cellPages.filter(page => page !== loadedProjectPage); + const resolvedProjectPage = loadedProjectPage || projectPage; + setPages([resolvedProjectPage, ...nonProjectPages]); + setActivePageId(resolvedProjectPage.id); + setProjectCompositeMap({ [currentProjectName]: nonProjectPages.map(page => page.name) }); setStandaloneComposites([]); const nextTrees = {}; - cellPages.forEach(page => { + nonProjectPages.forEach(page => { nextTrees[page.name] = buildCompInstanceTree(page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library); }); setCompositeTrees(nextTrees); @@ -3079,13 +4304,22 @@ }; loadProject(); - }, [library, currentProjectName, loadTechnologyManifest]); + }, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge]); useEffect(() => { - if (activePage && reactFlowInstance) { - reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 1 }); + if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) { + reactFlowInstance.fitBounds( + { x: 0, y: 0, width: activeCanvasSize.width, height: activeCanvasSize.height }, + { padding: 0.12, duration: 0 } + ); } - }, [activePage?.id]); + }, [activePage?.id, activeCanvasSize.width, activeCanvasSize.height, reactFlowInstance]); + + useEffect(() => { + setRulerStartPoint(null); + setRulerEndPoint(null); + setRulerPreviewPoint(null); + }, [activePageId]); useEffect(() => { if (!library) return; @@ -3121,7 +4355,16 @@ pages.forEach(page => { page.nodes.forEach(node => { const componentName = node.data?.componentName; - if (node.data?.elementType || !componentName || isForgeComponent(componentName) || node.data?.type === 'composite' || node.data?.ports) return; + if (node.data?.elementType || !componentName || isForgeComponent(componentName) || node.data?.type === 'composite') return; + if (isBasicComponent(componentName)) { + if (node.data?.ports && node.data?.boxSize) return; + const metadata = getBasicComponentMetadata(componentName, node.data?.basicArguments); + if (metadata) { + missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName, metadata }); + } + return; + } + if (node.data?.ports && node.data?.boxSize) return; missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName }); }); }); @@ -3130,7 +4373,7 @@ let cancelled = false; Promise.all(missingPortNodes.map(async item => ({ ...item, - metadata: await loadComponentMetadata(item.componentName) + metadata: item.metadata || await loadComponentMetadata(item.componentName) }))).then(results => { if (cancelled) return; const metadataByNode = new Map(results.filter(item => item.metadata).map(item => [item.nodeId, item.metadata])); @@ -3140,11 +4383,14 @@ nodes: page.nodes.map(node => { const metadata = metadataByNode.get(node.id); if (!metadata) return node; + const boxSize = normalizeBoxSize(metadata); return { ...node, + position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, boxSize), data: { ...node.data, ports: metadata.ports || {}, + boxSize, foundry: metadata.foundry || '', process: metadata.process || '' } @@ -3158,7 +4404,6 @@ }; }, [pages, loadComponentMetadata]); - const selectedNode = useMemo(() => currentNodes.find(n => n.selected), [currentNodes]); const openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]); const selectInstanceInPage = useCallback((pageName, instanceName) => { @@ -3196,6 +4441,7 @@ type: 'project', nodes: [], edges: [], + canvasSize: DEFAULT_CANVAS_SIZE, port: { x: 0, y: 0, a: 0 } }; setActivePageId(newProjectPage.id); @@ -3220,6 +4466,7 @@ name: name, type: 'composite', isClosed: false, + canvasSize: DEFAULT_CANVAS_SIZE, nodes: [ { id: 'page-port', @@ -3309,6 +4556,7 @@ name: cellName, type: 'composite', isClosed: false, + canvasSize: DEFAULT_CANVAS_SIZE, nodes: [ { id: 'page-port', @@ -3437,7 +4685,11 @@ parsedData = { name: rawData, category: 'default' }; } if (parsedData.type === 'standaloneComposite') { - const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + const position = clampPositionToCanvas( + reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }), + activePage?.canvasSize || activeCanvasSize, + DEFAULT_COMPONENT_BOX_SIZE + ); const newNode = { id: Date.now().toString(), type: 'rotatableNode', @@ -3482,7 +4734,11 @@ addLog(`Skipped self-reference: "${parsedData.name}" cannot be placed inside itself.`); return; } - const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + const position = clampPositionToCanvas( + reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }), + activePage?.canvasSize || activeCanvasSize, + DEFAULT_COMPONENT_BOX_SIZE + ); const newNode = { id: Date.now().toString(), type: 'rotatableNode', @@ -3523,7 +4779,41 @@ alert('Please open a composite page first.'); return; } - const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + const position = clampPositionToCanvas( + reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }), + activePage?.canvasSize || activeCanvasSize, + parsedData.type === 'element' ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : DEFAULT_COMPONENT_BOX_SIZE + ); + if (parsedData.type === 'basic') { + const componentName = parsedData.componentName || parsedData.name; + const basicArguments = createBasicSettings(componentName, parsedData.settings); + const metadata = getBasicComponentMetadata(componentName, basicArguments); + const componentDisplayName = `${componentName.replace(/\s+/g, '_')}_${componentCounterRef.current}`; + componentCounterRef.current += 1; + const newNode = { + id: Date.now().toString(), + type: 'rotatableNode', + position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, normalizeBoxSize(metadata)), + data: { + label: componentName, + componentName, + componentDisplayName, + libraryCategory: 'basic', + category: 'basic', + rotation: 0, + ports: metadata?.ports || {}, + boxSize: metadata ? normalizeBoxSize(metadata) : DEFAULT_COMPONENT_BOX_SIZE, + basicArguments + }, + }; + setPages(prev => prev.map(p => p.id === activePageId ? { ...p, nodes: p.nodes.concat(newNode) } : p)); + recordUserAction('component.create', { + project: currentProjectName, + cell: activePage?.name, + detail: { component: componentName, instance: componentDisplayName, category: 'basic' } + }); + return; + } if (parsedData.type === 'element') { const elementName = parsedData.elementType === 'anchor' ? `anchor_${componentCounterRef.current}` : `port_${componentCounterRef.current}`; componentCounterRef.current += 1; @@ -3546,8 +4836,8 @@ } : { id: Date.now().toString(), - type: 'rotatableNode', - position, + type: 'anchorNode', + position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE }), data: { label: elementName, componentName: 'Anchor', @@ -3559,6 +4849,7 @@ layer: 'WG_CORE', description: '', hideIcon: true, + boxSize: { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE }, ports: buildElementPorts('anchor') }, }; @@ -3579,7 +4870,7 @@ .map(component => typeof component === 'string' ? component : component?.name) .filter(Boolean) : []; - const selectedComponent = parsedData.name || availableComponents[0] || parsedData.category; + const selectedComponent = chooseCategoryComponent(parsedData.name, availableComponents, parsedData.category); if (!selectedComponent) { addLog('Skipped category placement: no components were found in this library category.'); return; @@ -3635,32 +4926,7 @@ cell: activePage?.name, detail: { component: parsedData.name, instance: componentDisplayName, category: parsedData.category } }); - }, [activePageId, activePage, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName]); - - const onConnect = useCallback((connection) => { - if (!activePageId) return; - setPages(prev => prev.map(p => { - if (p.id !== activePageId) return p; - const route = createRouteSettings(technologyManifest); - const view = routeStyleForSettings(route, false); - const candidate = { - ...connection, - id: `edge-${connection.source}-${connection.sourceHandle || 'port'}-${connection.target}-${connection.targetHandle || 'port'}-${Date.now()}`, - type: view.type, - style: view.style, - data: { route }, - }; - const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node])); - const conflict = findSameFamilyRouteCrossing(candidate, p.edges, nodeMap, technologyManifest); - if (conflict) { - const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; - const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; - addLog(`Connection rejected: ${route.family} route crosses ${source} to ${target}.`); - return p; - } - return { ...p, edges: addEdge(candidate, p.edges) }; - })); - }, [activePageId, technologyManifest, addLog]); + }, [activePageId, activePage, activeCanvasSize, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName, toBooleanFlag]); const expandAll = useCallback(() => { if (treeContainerRef.current) { @@ -3711,6 +4977,119 @@ setGridSnap(prev => !prev); }, []); + const toggleRulerMode = useCallback(() => { + setRulerMode(prev => { + const next = !prev; + if (!next) { + setRulerStartPoint(null); + setRulerEndPoint(null); + setRulerPreviewPoint(null); + } + return next; + }); + }, []); + + const eventToRulerPoint = useCallback((event) => { + const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + return { + x: Number(Math.min(activeCanvasSize.width, Math.max(0, rawPoint.x)).toFixed(3)), + y: Number(Math.min(activeCanvasSize.height, Math.max(0, rawPoint.y)).toFixed(3)) + }; + }, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]); + + const handleRulerPaneClick = useCallback((event) => { + if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return; + event.preventDefault(); + event.stopPropagation(); + const nextPoint = eventToRulerPoint(event); + if (!rulerStartPoint || rulerEndPoint) { + setRulerStartPoint(nextPoint); + setRulerEndPoint(null); + setRulerPreviewPoint(null); + addLog(`Ruler start: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`); + return; + } + const measurement = createRulerMeasurement(rulerStartPoint, nextPoint); + setRulerEndPoint(nextPoint); + setRulerPreviewPoint(null); + if (measurement) { + addLog(`Ruler distance: ${measurement.label}`); + } + }, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]); + + const handleRulerMouseMove = useCallback((event) => { + if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return; + if (!rulerStartPoint || rulerEndPoint) return; + setRulerPreviewPoint(eventToRulerPoint(event)); + }, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]); + + const selectEdgeById = useCallback((edgeId, additive = false) => { + if (!activePageId || !edgeId) return; + setPages(prev => prev.map(p => { + if (p.id !== activePageId) return p; + return { + ...p, + nodes: additive ? p.nodes : p.nodes.map(node => ({ ...node, selected: false })), + edges: p.edges.map(edge => { + const route = createRouteSettings(technologyManifest, edge.data?.route); + const selected = edge.id === edgeId ? (additive ? !edge.selected : true) : (additive ? edge.selected : false); + const view = routeStyleForSettings(route, selected); + const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2; + return { ...edge, selected, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } }; + }) + }; + })); + }, [activePageId, technologyManifest]); + + const handleBasicConnection = useCallback((connection) => { + if (!activePageId || !activePage || activePage.type === 'layoutPreview' || rulerMode) return; + if (!connection?.source || !connection?.target || !connection?.sourceHandle || !connection?.targetHandle) return; + if (connection.source === connection.target && connection.sourceHandle === connection.targetHandle) return; + const route = currentLinkRoute; + const view = routeStyleForSettings(route, false); + const edgeId = `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`; + const candidate = { + id: edgeId, + source: connection.source, + target: connection.target, + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle, + type: view.type, + selectable: true, + style: view.style, + data: { route } + }; + setPages(prev => prev.map(p => ( + p.id === activePageId + ? { ...p, edges: addEdge(candidate, p.edges) } + : p + ))); + addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`); + }, [activePageId, activePage, rulerMode, currentLinkRoute, addLog]); + + const handleRouteEdgeMouseDown = useCallback((event) => { + if (rulerMode) return false; + const target = event.target?.closest?.('[data-route-edge-id]'); + if (!target) return false; + const edgeId = target.getAttribute('data-route-edge-id'); + if (!edgeId) return false; + event.preventDefault(); + event.stopPropagation(); + selectEdgeById(edgeId, event.shiftKey); + return true; + }, [rulerMode, selectEdgeById]); + + const handleReactFlowEdgeMouseDown = useCallback((event, edge) => { + if (rulerMode || !edge || edge.data?.draft || edge.data?.ruler) return; + event.preventDefault(); + event.stopPropagation(); + selectEdgeById(edge.id, event.shiftKey); + }, [rulerMode, selectEdgeById]); + + const handleCanvasMouseDown = useCallback((event) => { + handleRouteEdgeMouseDown(event); + }, [handleRouteEdgeMouseDown]); + const projectTreeItems = useMemo(() => { const items = []; const projectPagesByName = new Map(); @@ -3792,27 +5171,40 @@ __ports__: buildPageComponentPorts(page.port, page.nodes) }; }); - return { - ...cellEntries, - Elements: { - Port: { - __type__: 'component', - __name__: 'Port', - __category__: 'element', - __element__: true, - __elementType__: 'port', - __ports__: ELEMENT_COMPONENTS.Port.ports - }, - Anchor: { - __type__: 'component', - __name__: 'Anchor', - __category__: 'element', - __element__: true, - __elementType__: 'anchor', - __ports__: ELEMENT_COMPONENTS.Anchor.ports - } + const basicEntries = { + Port: { + __type__: 'component', + __name__: 'Port', + __category__: 'element', + __element__: true, + __elementType__: 'port', + __ports__: ELEMENT_COMPONENTS.Port.ports }, - ...(library || {}) + Anchor: { + __type__: 'component', + __name__: 'Anchor', + __category__: 'element', + __element__: true, + __elementType__: 'anchor', + __ports__: ELEMENT_COMPONENTS.Anchor.ports + }, + ...Object.fromEntries(Object.entries(BASIC_COMPONENTS) + .filter(([, definition]) => !definition.hidden) + .map(([name, definition]) => ([ + name, + { + __type__: 'component', + __name__: name, + __category__: 'basic', + __basic__: true, + __ports__: getBasicComponentMetadata(name, definition.settings)?.ports || {} + } + ]))) + }; + return { + Cells: cellEntries, + Basic: basicEntries, + PDK: library || {} }; }, [pages, library]); @@ -3824,28 +5216,69 @@ if (!page || !Array.isArray(page.edges)) return true; const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node])); for (const edge of page.edges) { - const conflict = findSameFamilyRouteCrossing(edge, page.edges, nodeMap, technologyManifest); + const conflict = findSameTypeRouteCrossing(edge, page.edges, nodeMap, technologyManifest); if (conflict) { const route = createRouteSettings(technologyManifest, edge.data?.route); const source = nodeMap[edge.source]?.data?.componentDisplayName || edge.source; const target = nodeMap[edge.target]?.data?.componentDisplayName || edge.target; const conflictSource = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; const conflictTarget = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; - addLog(`Build blocked: ${route.family} route ${source} to ${target} crosses ${conflictSource} to ${conflictTarget}.`); + addLog(`Build blocked: ${route.xsection} route ${source} to ${target} crosses ${conflictSource} to ${conflictTarget}.`); return false; } } return true; }, [technologyManifest, addLog]); - const openLayoutPreview = useCallback((cellName, svgUrl) => { + const buildYamlForPage = useCallback((page) => { + if (!page) return ''; + const header = `# ============================================= +# mxPIC Cell/Project Definition File +# ============================================= +schema_version: "2.0.0" +kind: cell +coordinate_system: gds_y_up +canvas_size: + width: ${Number(page.canvasSize?.width || DEFAULT_CANVAS_SIZE.width)} + height: ${Number(page.canvasSize?.height || DEFAULT_CANVAS_SIZE.height)} +project: ${currentProjectName} +name: ${page.name} +type: ${page.type === 'project' ? 'project' : 'composite'} +version: "1.0.0" + +# 1. External Ports (How this cell connects to the outside world) +${buildCanvasPortsYaml(page.nodes)} + +# 2. Instances (The sub-components dropped onto this canvas) +instances:`; + + const resolveComponentPath = (compName) => { + if (!library || !compName || isForgeComponent(compName) || isBasicComponent(compName)) return compName; + const pathArr = findComponentPath(library, compName); + return pathArr.length > 0 ? pathArr.join('/') : compName; + }; + const instancesBlock = buildInstancesYaml({ + nodes: page.nodes, + resolveComponentPath + }); + const elementsBlock = buildElementsYaml(page.nodes); + const bundlesBlock = buildBundlesYaml(page); + return `${header} +${instancesBlock} + +${elementsBlock} + +${bundlesBlock}`; + }, [currentProjectName, library, buildBundlesYaml]); + + const openLayoutPreview = useCallback((cellName, svgUrl, layoutBounds) => { if (!cellName || !svgUrl) return; const layoutTabId = `layout-${currentProjectName}-${cellName}`; setPages(prev => { const existing = prev.find(page => page.id === layoutTabId); if (existing) { return prev.map(page => page.id === layoutTabId - ? { ...page, name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, nodes: [], edges: [], isClosed: false } + ? { ...page, name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, layoutBounds, nodes: [], edges: [], isClosed: false } : page ); } @@ -3854,6 +5287,7 @@ name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, + layoutBounds, nodes: [], edges: [], isClosed: false @@ -3864,41 +5298,11 @@ const handleBuildLayout = useCallback(async () => { if (!activePage) return; + if (buildLayoutBusy) return; if (!validateRouteCrossings(activePage)) return; - const header = `# ============================================= -# mxPIC Cell/Project Definition File -# ============================================= -schema_version: "2.0.0" -kind: cell -project: ${currentProjectName} -name: ${activePage.name} -type: ${activePage.type === 'project' ? 'project' : 'composite'} -version: "1.0.0" - -# 1. External Ports (How this cell connects to the outside world) -${buildCanvasPortsYaml(activePage.nodes)} - -# 2. Instances (The sub-components dropped onto this canvas) -instances:`; - - const resolveComponentPath = (compName) => { - if (!library || !compName || isForgeComponent(compName)) return compName; - const pathArr = findComponentPath(library, compName); - return pathArr.length > 0 ? pathArr.join('/') : compName; - }; - const instancesBlock = buildInstancesYaml({ - nodes: activePage.nodes, - resolveComponentPath - }); - - const bundlesBlock = buildBundlesYaml(activePage); - const elementsBlock = buildElementsYaml(activePage.nodes); - const yamlContent = `${header} -${instancesBlock} - -${elementsBlock} - -${bundlesBlock}`; + setBuildLayoutBusy(true); + startBuildProgress('Building layout'); + const yamlContent = buildYamlForPage(activePage); // send to backend try { @@ -3915,24 +5319,65 @@ ${bundlesBlock}`; if (!response.ok) { const errData = await response.json(); addLog(errData.error || 'Save failed, unknown error'); + stopBuildProgress(); return; } const result = await response.json(); addLog('Successfully saved: ' + result.path); if (result.svg_url) { - openLayoutPreview(activePage.name, result.svg_url); + completeBuildProgress('Layout ready'); + openLayoutPreview(activePage.name, result.svg_url, calculateLayoutBounds(activePage)); + } else { + completeBuildProgress('Layout saved'); } } catch (err) { addLog('Save error: ' + err.message); + stopBuildProgress(); + } finally { + setBuildLayoutBusy(false); } - }, [activePage, library, buildBundlesYaml, findComponentPath, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings]); + }, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); + + const handleSaveProjectLayouts = useCallback(async () => { + if (saveProjectBusy) return; + const savePages = pages.filter(page => page.type !== 'layoutPreview'); + if (savePages.length === 0) { + addLog('No canvas YAML to save.'); + return; + } + setSaveProjectBusy(true); + try { + for (const page of savePages) { + const response = await fetch('/api/save-layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: currentProjectName, + cell: page.name, + content: buildYamlForPage(page), + preview: false, + }), + }); + if (!response.ok) { + const errData = await response.json().catch(() => ({})); + throw new Error(errData.error || `Save failed for ${page.name}`); + } + } + addLog(`Saved YAML for ${savePages.length} canvas${savePages.length === 1 ? '' : 'es'}.`); + } catch (err) { + addLog('Project save error: ' + err.message); + } finally { + setSaveProjectBusy(false); + } + }, [saveProjectBusy, pages, currentProjectName, buildYamlForPage, addLog]); const handleBuildGds = useCallback(async () => { if (buildGdsBusy) return; const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page)); if (invalidPage) return; setBuildGdsBusy(true); + startBuildProgress('Building GDS'); try { const response = await fetch('/api/build-gds', { method: 'POST', @@ -3943,6 +5388,7 @@ ${bundlesBlock}`; if (!response.ok) { const detail = result.error ? `: ${result.error}` : ` (HTTP ${response.status})`; addLog(`Build GDS failed${detail}`); + stopBuildProgress(); return; } const warningText = result.warnings && result.warnings.length > 0 @@ -3958,12 +5404,14 @@ ${bundlesBlock}`; link.remove(); } addLog(`GDS built with ${result.engine}: ${result.filename || result.path}${warningText}`); + completeBuildProgress('GDS ready'); } catch (err) { addLog(`Build GDS network error: ${err.message}. Check that the Flask server is running from the same host and Python environment.`); + stopBuildProgress(); } finally { setBuildGdsBusy(false); } - }, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings]); + }, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]); const onNodeDoubleClick = useCallback((event, node) => { if (node.data?.type === 'composite') { @@ -3973,6 +5421,14 @@ ${bundlesBlock}`; return (
    +
    + + +
    @@ -4006,14 +5466,12 @@ ${bundlesBlock}`;
    ))}
-
-
- Snap to Grid +
+
+ Snap to Grid
+
+ + Link + + {currentLinkXsection} + + +
+ {linkXsectionChoices.map(xsection => { + const route = createRouteSettings(technologyManifest, { xsection }); + const color = routeStyleForSettings(route, false).style.stroke; + return ( + + ); + })} +
+
- -
+ {buildProgress.active && ( +
+
+ {buildProgress.label} + {Math.round(buildProgress.value)}% +
+
+
+
+
+ )} + + {rulerMode && activePage?.type !== 'layoutPreview' && ( +
+ {rulerMeasurement + ? rulerMeasurement.label + : (rulerStartPoint && !rulerEndPoint ? 'Move mouse, click second point' : 'Click the first point')} +
+ )} + {activePage && activePage.type !== 'layoutPreview' && ( )} @@ -4052,24 +5556,42 @@ ${bundlesBlock}`; ) : ( - + )}
@@ -4083,8 +5605,11 @@ ${bundlesBlock}`; childData.__element__ === true);'), - 'Elements folder should bypass category-card grouping and render separate virtual component leaves' + canvasHtml.includes('isDirectLeafGrid') && + canvasHtml.includes('childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true') && + canvasHtml.includes('
'), + 'Cells and Basic folders should bypass category grouping and render direct draggable leaves in a 2D grid' ); assert( canvasHtml.includes('element-card-icon port-icon') && canvasHtml.includes('element-card-icon anchor-icon'), @@ -46,7 +50,7 @@ assert( 'virtual elements should not show PDK or generate_with_forge component selection' ); assert( - canvasHtml.includes('buildElementsYaml(activePage.nodes)'), + canvasHtml.includes('buildElementsYaml(page.nodes)'), 'canvas layout export should include an elements section' ); assert( diff --git a/tests/canvas-helpers.test.js b/tests/canvas-helpers.test.js index d5ced1e..dba05d7 100644 --- a/tests/canvas-helpers.test.js +++ b/tests/canvas-helpers.test.js @@ -21,9 +21,169 @@ assert.strictEqual(handles.find(handle => handle.name === 'a1').style.top, '15%' assert.strictEqual(handles.find(handle => handle.name === 'a2').style.top, '85%'); assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.left, '50%'); +const uniformLeftHandles = helpers.buildPortHandles({ + p_top: { x: -10, y: 300, a: 180 }, + p_mid: { x: -10, y: 20, a: 180 }, + p_bottom: { x: -10, y: -5, a: 180 }, +}); +assert.deepStrictEqual( + uniformLeftHandles.map(handle => handle.style.top), + ['15%', '50%', '85%'], + 'ports on the same side should be uniformly spaced after sorting' +); + +assert.deepStrictEqual( + helpers.normalizeBoxSize({ box_size: [946, 75] }), + { width: 946, height: 75 }, + 'component box size should load from YAML box_size arrays' +); +assert.deepStrictEqual( + helpers.normalizeBoxSize({ box_size: ['946.0', '75.0'] }), + { width: 946, height: 75 }, + 'component box size should accept numeric strings from YAML/JSON metadata' +); +assert.deepStrictEqual( + helpers.normalizeBoxSize({ box_sz: { width: 1200, height: 85 } }), + { width: 1200, height: 85 }, + 'component box size should also accept box_sz objects' +); +assert.strictEqual( + helpers.PORT_NODE_SIZE, + 30, + 'Port and Anchor virtual elements should use the same 30 um canvas footprint' +); +assert.deepStrictEqual( + helpers.calculateLayoutBounds({ + nodes: [{ + position: { x: 100, y: 200 }, + data: { componentName: 'rotated_component', boxSize: { width: 50, height: 20 }, rotation: 90 } + }] + }), + { + minX: 80, + minY: 200, + maxX: 100, + maxY: 250, + width: 20, + height: 50, + bottomLeft: { x: 80, y: 200 }, + topRight: { x: 100, y: 250 } + }, + 'layout preview bounds should use component box_size and rotation to find device corners' +); +assert.strictEqual( + helpers.chooseCategoryComponent('generate with mxpic_forge', [ + 'generate with mxpic_forge', + 'EC_SiN400_1310_0p5dB_L935_A0_QY_202604' + ], 'edge_couplers'), + 'EC_SiN400_1310_0p5dB_L935_A0_QY_202604', + 'dropping an EC category should prefer the real PDK component so its YAML box_size is loaded' +); +assert.deepStrictEqual( + helpers.clampPositionToCanvas({ x: 4990, y: 5010 }, { width: 5000, height: 5000 }, { width: 946, height: 75 }), + { x: 4054, y: 4925 }, + 'component drag position should keep the full component box inside the canvas boundary' +); +const rulerMeasurement = helpers.createRulerMeasurement({ x: 10, y: 20 }, { x: 40, y: 60 }); +assert.deepStrictEqual( + rulerMeasurement, + { + start: { x: 10, y: 20 }, + end: { x: 40, y: 60 }, + dx: 30, + dy: 40, + distance: 50, + midpoint: { x: 25, y: 40 }, + label: '50.000 um dx 30.000 dy 40.000' + }, + 'ruler measurement should calculate point-to-point distance in canvas um coordinates' +); +assert.strictEqual( + helpers.createRulerMeasurement({ x: 1 }, null), + null, + 'ruler measurement should wait until both points are available' +); +assert.deepStrictEqual( + helpers.createComponentSymbolMetrics({ width: 946, height: 75 }), + { width: 898.7, height: 51 }, + 'large edge-coupler symbols should scale close to the YAML box width instead of being capped near 300 um' +); +assert.deepStrictEqual( + helpers.createComponentSymbolMetrics({ width: 132, height: 82 }), + { width: 118.8, height: 55.76 }, + 'default symbols should still scale proportionally inside normal component boxes' +); + +const rotatedHandles = helpers.buildPortHandles({ + left_port: { x: -50, y: 0, a: 180 }, + top_port: { x: 0, y: 20, a: 90 }, +}, { rotation: 180 }); +assert.strictEqual( + rotatedHandles.find(handle => handle.name === 'left_port').position, + 'right', + 'rotating a component should rotate the React Flow handle side' +); +assert.strictEqual( + rotatedHandles.find(handle => handle.name === 'top_port').position, + 'bottom', + 'rotating a component should rotate vertical port handle sides' +); + const args = helpers.createForgeArguments(); assert(Object.keys(args).length >= 10); assert.strictEqual(helpers.isForgeComponent('generate with mxpic_forge'), true); +assert.strictEqual(helpers.isBasicComponent('waveguide'), true); +assert.strictEqual(helpers.isBasicComponent('circle'), true); +assert.strictEqual(helpers.isBasicComponent('cricle'), true); +assert.strictEqual( + helpers.buildElementPorts('port').port.a, + 0, + 'Port objects should default to 0 degree angle' +); +assert.deepStrictEqual( + { + left: helpers.buildElementPorts('anchor').left.a, + right: helpers.buildElementPorts('anchor').right.a, + }, + { left: 180, right: 0 }, + 'Anchor objects should default to 180 degree left port and 0 degree right port' +); +assert.deepStrictEqual( + { + left: helpers.buildElementPorts('anchor').left, + right: helpers.buildElementPorts('anchor').right, + }, + { + left: { x: 0, y: -15, a: 180, width: 0.5 }, + right: { x: 30, y: -15, a: 0, width: 0.5 } + }, + 'Anchor ports should sit on the left and right edges of a port-sized circle' +); +assert.deepStrictEqual( + helpers.buildBasicComponentPorts('waveguide', { length: 120, width: 0.6 }).b1, + { x: 120, y: 0, a: 0, width: 0.6, xsection: 'strip', description: 'Optical power output' }, + 'basic waveguide ports should be generated from editable settings' +); +assert.deepStrictEqual( + helpers.getBasicComponentMetadata('waveguide', { length: 120, width: 0.5 }).box_size, + [120, 4], + 'basic waveguide symbol should use a narrow default height' +); +assert.deepStrictEqual( + helpers.getBasicComponentMetadata('90 bend', { radius: 15 }).box_size, + [15, 15], + '90 bend symbol should be square with side length equal to radius' +); +assert.deepStrictEqual( + helpers.getBasicComponentMetadata('180 bend', { radius: 15 }).box_size, + [15, 30], + '180 bend symbol should be one radius wide and two radii tall' +); +assert.deepStrictEqual( + helpers.getBasicComponentMetadata('taper', { length: 80, width1: 0.4, width2: 1.2 }).ports.a1.description, + 'Optical power input', + 'basic component metadata should include human-readable port descriptions' +); const yaml = helpers.buildInstanceYaml({ instanceName: 'component_1', @@ -31,14 +191,32 @@ const yaml = helpers.buildInstanceYaml({ componentPath: 'ignored/path', position: { x: 12.34, y: -5 }, rotation: 90, + flip: true, + flop: true, forgeArguments: { function_name: 'mmi1x2', length: 25.5, include_heater: true } }); assert(yaml.includes('component: generate_with_forge')); +assert(yaml.includes('flip: 1')); +assert(yaml.includes('flop: 1')); assert(yaml.includes('function_name: "mmi1x2"')); assert(yaml.includes('length: 25.5')); assert(yaml.includes('include_heater: true')); +const basicYaml = helpers.buildInstanceYaml({ + instanceName: 'wg_1', + componentName: 'waveguide', + componentPath: 'ignored', + position: { x: 0, y: 0 }, + rotation: 0, + flip: false, + flop: false, + basicArguments: { length: 88, width: 0.7, xsection: 'strip' } +}); +assert(basicYaml.includes('component: waveguide')); +assert(basicYaml.includes('length: 88')); +assert(basicYaml.includes('width: 0.7')); + const projectInstancesYaml = helpers.buildInstancesYaml({ nodes: [ { @@ -74,7 +252,7 @@ assert(projectInstancesYaml.includes('component: canvas_1')); const pagePortsYaml = helpers.buildPortsYaml({ x: 50, y: 150, a: 90 }); assert(pagePortsYaml.includes('- name: port')); assert(pagePortsYaml.includes('x: 50.0')); -assert(pagePortsYaml.includes('y: 150.0')); +assert(pagePortsYaml.includes('y: -150.0')); assert(pagePortsYaml.includes('angle: 90.0')); const componentPorts = helpers.buildPageComponentPorts({ x: 12, y: -6, a: 180 }); @@ -128,12 +306,14 @@ const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes); assert(canvasPortsYaml.includes('name: in0')); assert(canvasPortsYaml.includes('description: "input port"')); assert(canvasPortsYaml.includes('width: 0.7')); +assert(canvasPortsYaml.includes('y: -20.0')); const elementsYaml = helpers.buildElementsYaml(elementNodes); assert(elementsYaml.includes('in0:')); assert(elementsYaml.includes('type: port')); assert(elementsYaml.includes('anchor_1:')); assert(elementsYaml.includes('type: anchor')); +assert(elementsYaml.includes('y: -20.0')); const instancesWithoutElements = helpers.buildInstancesYaml({ nodes: elementNodes, @@ -142,6 +322,7 @@ const instancesWithoutElements = helpers.buildInstancesYaml({ assert(!instancesWithoutElements.includes('anchor_1:')); assert(!instancesWithoutElements.includes('in0:')); assert(instancesWithoutElements.includes('component_1:')); +assert(instancesWithoutElements.includes('y: -60.0')); const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes); assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 180, width: 0.7 }); @@ -193,13 +374,80 @@ const routeYaml = helpers.buildBundlesYaml({ target: 'b', sourceHandle: 'out', targetHandle: 'in', - data: { route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' } } + data: { + route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' }, + points: [{ x: 0, y: 0 }, { x: 40, y: 20 }] + } }] }, technologyManifest); assert(routeYaml.includes('xsection: metal_1')); assert(routeYaml.includes('family: electrical')); assert(routeYaml.includes('radius: 20')); assert(routeYaml.includes('routing_type: standard_bend')); +assert(routeYaml.includes('points:')); +assert(routeYaml.includes('x: 40.0')); +assert(routeYaml.includes('y: -20.0')); + +const anchoredRouteYaml = helpers.buildBundlesYaml({ + nodes: [ + { + id: 'src-node', + type: 'rotatableNode', + position: { x: 10, y: 20 }, + data: { + componentDisplayName: 'src_inst', + boxSize: [100, 40], + ports: { out: { x: -10, y: 0, a: 180 } } + } + }, + { + id: 'dst-node', + type: 'rotatableNode', + position: { x: 120, y: 20 }, + data: { + componentDisplayName: 'dst_inst', + boxSize: [100, 40], + ports: { in: { x: 10, y: 0, a: 0 } } + } + } + ], + edges: [{ + id: 'edge-src-dst', + source: 'src-node', + target: 'dst-node', + sourceHandle: 'out', + targetHandle: 'in', + data: { + route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' }, + points: [{ x: 0, y: 0 }, { x: 80, y: 0 }, { x: 80, y: 60 }] + } + }] +}, technologyManifest); +assert(anchoredRouteYaml.includes('from: src_inst:out')); +assert(anchoredRouteYaml.includes('to: dst_inst:in')); +assert(anchoredRouteYaml.includes('x: 0.0')); +assert(anchoredRouteYaml.includes('y: -20.0')); +assert(anchoredRouteYaml.includes('x: 130.0')); + +const freeRouteYaml = helpers.buildBundlesYaml({ + nodes: [], + edges: [{ + id: 'route-free-1', + source: '__free_route_route-free-1_start__', + target: '__free_route_route-free-1_end__', + data: { + freeRoute: true, + route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' }, + points: [{ x: 10, y: 20 }, { x: 80, y: 20 }, { x: 80, y: 120 }] + } + }] +}, technologyManifest); +assert(freeRouteYaml.includes('id: "route-free-1"')); +assert(!freeRouteYaml.includes('from:')); +assert(!freeRouteYaml.includes('to:')); +assert(freeRouteYaml.includes('points:')); +assert(freeRouteYaml.includes('x: 80.0')); +assert(freeRouteYaml.includes('y: -120.0')); const edgeA = { id: 'edge-a-b', @@ -227,5 +475,16 @@ const crossingNodes = { e: { position: { x: 0, y: 100 } }, f: { position: { x: 100, y: 0 } } }; +edgeA.data.route.xsection = 'strip'; +edgeB.data.route.xsection = 'strip'; +edgeC.data.route.xsection = 'metal_1'; +const edgeD = { + id: 'edge-g-h', + source: 'e', + target: 'f', + data: { route: { xsection: 'rib_low', family: 'optical' } } +}; +assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b'); +assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeC, [edgeA], crossingNodes), null); +assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeD, [edgeA], crossingNodes), null); assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b'); -assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeC, [edgeA], crossingNodes), null); diff --git a/tests/layout-backend-static.test.js b/tests/layout-backend-static.test.js index a898efa..f95c142 100644 --- a/tests/layout-backend-static.test.js +++ b/tests/layout-backend-static.test.js @@ -30,6 +30,12 @@ assert( serverPy.includes('create_routed_layout_svg'), 'save-layout route should use routed preview generation when links exist' ); +assert( + serverPy.includes('cell_routes_path') && + serverPy.includes('write_route_points_sidecar') && + serverPy.includes('.routes.yml'), + 'save-layout should write a sidecar route-points file for manually drawn link guidance' +); assert( serverPy.includes('svg_url'), 'save-layout response should include an svg_url for the new layout tab' @@ -78,3 +84,20 @@ assert( gdsBuilderPy.includes('_cells_have_links') && gdsBuilderPy.includes('Routed Build GDS requires mxpic_router'), 'Build GDS should not silently fall back to unrouted gdstk when links are present' ); + +assert( + serverPy.includes('def scoped_pdk_root_for_project') && + serverPy.includes('read_project_meta(project_name).get("technology")') && + serverPy.includes('os.path.join(base_root, foundry, technology)'), + 'backend should resolve a project-scoped PDK root from selected foundry/technology' +); +assert( + serverPy.includes('request.args.get(\'project\')') && + serverPy.includes('scoped_pdk_root_for_project(project)'), + 'library/component APIs should accept ?project= and search inside the selected technology folder' +); +assert( + serverPy.includes('__path__') && + serverPy.includes('os.path.relpath(root, path_root)'), + 'library tree leaves should preserve component paths relative to the role PDK root' +); diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js index 2b6a8d0..c4b5b7a 100644 --- a/tests/layout-ui-wiring.test.js +++ b/tests/layout-ui-wiring.test.js @@ -4,11 +4,16 @@ const path = require('path'); const root = path.resolve(__dirname, '..'); const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8'); +const canvasHelpers = fs.readFileSync(path.join(root, 'frontend', 'canvas-helpers.js'), 'utf8'); assert( canvasHtml.includes('Build GDS'), 'Project Tree header should include a Build GDS button' ); +assert( + canvasHtml.includes('Save YAML for all canvases') && canvasHtml.includes('handleSaveProjectLayouts'), + 'Project Tree should include a save button that writes YAML for all canvases' +); assert( canvasHtml.includes('/api/build-gds'), 'Build GDS button should call the backend build-gds API' @@ -70,6 +75,280 @@ assert( 'route editor should offer standard_bend as a routing type' ); assert( - canvasHtml.includes('findSameFamilyRouteCrossing'), - 'canvas should validate same-family route crossings' + canvasHtml.includes('findSameTypeRouteCrossing'), + 'canvas should validate same-xsection route crossings' +); +assert( + canvasHtml.includes('link-mode-tabs') && + canvasHtml.includes('link-mode-summary') && + canvasHtml.includes('link-mode-menu') && + canvasHtml.includes('currentLinkXsection') && + canvasHtml.includes('setCurrentLinkXsection') && + canvasHtml.includes("['strip', 'rib_low', 'metal_1', 'metal_2']"), + 'canvas should expose a collapsed route-type selector for new links' +); +assert( + canvasHtml.includes('handleBasicConnection') && + canvasHtml.includes('onConnect={handleBasicConnection}') && + canvasHtml.includes('nodesConnectable={true}') && + canvasHtml.includes('connectionMode="loose"') && + canvasHtml.includes('data: { route }') && + canvasHtml.includes('addEdge(candidate, p.edges)'), + 'canvas should use React Flow native pin-to-pin connections for new links' +); +assert( + !canvasHtml.includes('linkDraft') && + !canvasHtml.includes('routingMode') && + !canvasHtml.includes('toggleRoutingMode') && + !canvasHtml.includes('handleLinkCanvasMouseDown') && + !canvasHtml.includes('handleLinkCanvasMouseMove') && + !canvasHtml.includes('finalizeLinkDraft') && + !canvasHtml.includes('__link_draft_edge__') && + !canvasHtml.includes('Routing mode: click anywhere to start a point route.'), + 'current interactive point-link drawing mode should be removed from the canvas' +); +assert( + canvasHtml.includes('= 2') && + canvasHtml.includes('points.slice(1).map(point => `L ${point.x},${point.y}`)') && + canvasHtml.includes('position: { x: first.x - 6, y: first.y - 6 }') && + canvasHtml.includes('position: { x: last.x - 6, y: last.y - 6 }'), + 'free routes should render all saved point-to-point line segments and keep hidden endpoints aligned' +); +assert( + !canvasHtml.includes('buildOrthogonalPoints') && + !canvasHtml.includes('buildManhattanRoutePoints') && + !canvasHtml.includes('findNearestPort') && + !canvasHtml.includes('linkPreviewSnapPort'), + 'custom link drawing geometry helpers should be deleted from the current canvas code' +); +const routePointNodeBlock = canvasHtml.slice( + canvasHtml.indexOf('const RulerPointNode'), + canvasHtml.indexOf('const RulerMeasurementNode') +); +assert( + routePointNodeBlock.includes('