updated with github #5
Binary file not shown.
+16
-2
@@ -6,7 +6,7 @@ import json
|
|||||||
import yaml
|
import yaml
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import Flask, jsonify, send_from_directory, request, redirect, url_for, session, render_template
|
from flask import Flask, jsonify, send_from_directory, request, redirect, url_for, session, render_template, make_response
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
import database
|
import database
|
||||||
from flask import Response
|
from flask import Response
|
||||||
@@ -36,6 +36,14 @@ app.json.sort_keys = False
|
|||||||
database.init_db()
|
database.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def no_cache_response(response):
|
||||||
|
"""Prevent stale editor assets while canvas features are being revised."""
|
||||||
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def login_required_json(view_func):
|
def login_required_json(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
@@ -237,7 +245,13 @@ def canvas():
|
|||||||
return redirect(url_for('home'))
|
return redirect(url_for('home'))
|
||||||
|
|
||||||
# Note: Ensure your old index.html is renamed to canvas.html in the frontend folder
|
# Note: Ensure your old index.html is renamed to canvas.html in the frontend folder
|
||||||
return render_template('canvas.html')
|
return no_cache_response(make_response(render_template('canvas.html')))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/canvas-helpers.js')
|
||||||
|
def canvas_helpers():
|
||||||
|
"""Serve the shared canvas helper script used by canvas.html."""
|
||||||
|
return no_cache_response(send_from_directory(FRONTEND_DIR, 'canvas-helpers.js'))
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# =============================================
|
||||||
|
# mxPIC Cell/Project Definition File
|
||||||
|
# =============================================
|
||||||
|
schema_version: "2.0.0"
|
||||||
|
kind: cell
|
||||||
|
project: mxpic_project_1
|
||||||
|
name: mxpic_project_1
|
||||||
|
type: project
|
||||||
|
version: "1.0.0"
|
||||||
|
|
||||||
|
# 1. External Ports (How this cell connects to the outside world)
|
||||||
|
ports:
|
||||||
|
- name: port_3
|
||||||
|
layer: WG_CORE
|
||||||
|
x: 359.0
|
||||||
|
y: 447.0
|
||||||
|
angle: 0.0
|
||||||
|
width: 0.5
|
||||||
|
- name: component_4
|
||||||
|
layer: WG_CORE
|
||||||
|
x: 366.0
|
||||||
|
y: 615.0
|
||||||
|
angle: 0.0
|
||||||
|
width: 0.5
|
||||||
|
|
||||||
|
# 2. Instances (The sub-components dropped onto this canvas)
|
||||||
|
instances:
|
||||||
|
component_2:
|
||||||
|
component: EMO1_2ML_CU_Al_RDL/composite/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303
|
||||||
|
x: 799.0
|
||||||
|
y: 420.0
|
||||||
|
rotation: 0.0
|
||||||
|
mirror: false
|
||||||
|
settings:
|
||||||
|
length:
|
||||||
|
|
||||||
|
elements:
|
||||||
|
anchor_1:
|
||||||
|
type: anchor
|
||||||
|
x: 479.0
|
||||||
|
y: 503.0
|
||||||
|
angle: 0.0
|
||||||
|
layer: WG_CORE
|
||||||
|
width: 0.5
|
||||||
|
description: ""
|
||||||
|
port_3:
|
||||||
|
type: port
|
||||||
|
x: 359.0
|
||||||
|
y: 447.0
|
||||||
|
angle: 0.0
|
||||||
|
layer: WG_CORE
|
||||||
|
width: 0.5
|
||||||
|
description: ""
|
||||||
|
component_4:
|
||||||
|
type: port
|
||||||
|
x: 366.0
|
||||||
|
y: 615.0
|
||||||
|
angle: 0.0
|
||||||
|
layer: WG_CORE
|
||||||
|
width: 0.5
|
||||||
|
description: ""
|
||||||
|
|
||||||
|
# 3. Bundles (Grouped links for multi-bus/parallel routing)
|
||||||
|
bundles:
|
||||||
|
output_bus:
|
||||||
|
routing_type: euler_bend
|
||||||
|
links:
|
||||||
|
- from: anchor_1:right
|
||||||
|
to: component_2:s1b
|
||||||
|
- from: anchor_1:left
|
||||||
|
to: port_3:port_3
|
||||||
|
- from: component_2:s1b
|
||||||
|
to: component_2:s1b
|
||||||
|
- from: component_2:g2b
|
||||||
|
to: component_4:component_4
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mxpic_project_1",
|
|
||||||
"technology": "Silterra/EMO1_2ML_CU_Al_RDL"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "test_proj",
|
|
||||||
"technology": "Silterra/EMO1_2ML_CU_Al_RDL"
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -0,0 +1,295 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
const helpers = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) {
|
||||||
|
module.exports = helpers;
|
||||||
|
}
|
||||||
|
root.MxpicCanvasHelpers = helpers;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||||
|
const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge';
|
||||||
|
const FORGE_COMPONENT_TYPE = 'generate_with_forge';
|
||||||
|
const ELEMENT_COMPONENTS = {
|
||||||
|
Port: {
|
||||||
|
name: 'Port',
|
||||||
|
elementType: 'port',
|
||||||
|
ports: {
|
||||||
|
port: { x: 0, y: 0, a: 0, width: 0.5 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Anchor: {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORGE_ARGUMENTS = {
|
||||||
|
function_name: 'straight',
|
||||||
|
component_name: '',
|
||||||
|
pdk: 'Silterra/EMO1_2ML_CU_Al_RDL',
|
||||||
|
layer: 'WG_CORE',
|
||||||
|
length: 100,
|
||||||
|
width: 0.5,
|
||||||
|
radius: 10,
|
||||||
|
gap: 0.2,
|
||||||
|
spacing: 10,
|
||||||
|
angle: 0,
|
||||||
|
wavelength: 1310,
|
||||||
|
port_count: 2,
|
||||||
|
include_heater: false,
|
||||||
|
include_electrical_ports: false,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const createForgeArguments = (overrides) => ({
|
||||||
|
...DEFAULT_FORGE_ARGUMENTS,
|
||||||
|
...(overrides || {})
|
||||||
|
});
|
||||||
|
|
||||||
|
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
|
||||||
|
|
||||||
|
const normalizeAngle = (angle) => {
|
||||||
|
const value = Number(angle);
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
let normalized = ((value % 360) + 360) % 360;
|
||||||
|
if (normalized > 180) normalized -= 360;
|
||||||
|
return Object.is(normalized, -0) ? 0 : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const portSideFromAngle = (angle) => {
|
||||||
|
const normalized = normalizeAngle(angle);
|
||||||
|
if (normalized === 0) return 'right';
|
||||||
|
if (normalized === 180 || normalized === -180) return 'left';
|
||||||
|
if (normalized === 90) return 'top';
|
||||||
|
if (normalized === -90) return 'bottom';
|
||||||
|
return Math.abs(normalized) < 90 ? 'right' : 'left';
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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 percentValue = `${percent}%`;
|
||||||
|
const style = vertical
|
||||||
|
? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' }
|
||||||
|
: { left: percentValue, transform: side === 'top' ? 'translate(-50%, -50%)' : 'translate(-50%, 50%)' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: port.name,
|
||||||
|
position: side,
|
||||||
|
style,
|
||||||
|
port: port.info
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPortHandles = (ports) => {
|
||||||
|
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 || {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(grouped).forEach(sidePorts => {
|
||||||
|
sidePorts.sort((a, b) => {
|
||||||
|
const sideA = portSideFromAngle(a.info.a);
|
||||||
|
const vertical = sideA === 'left' || sideA === 'right';
|
||||||
|
const primary = vertical ? Number(b.info.y || 0) - Number(a.info.y || 0) : Number(a.info.x || 0) - Number(b.info.x || 0);
|
||||||
|
return primary || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...buildSideHandles(grouped.left, 'left'),
|
||||||
|
...buildSideHandles(grouped.right, 'right'),
|
||||||
|
...buildSideHandles(grouped.top, 'top'),
|
||||||
|
...buildSideHandles(grouped.bottom, 'bottom')
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toYamlScalar = (value) => {
|
||||||
|
if (value === null || value === undefined) return '""';
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
const numericValue = Number(value);
|
||||||
|
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(numericValue) && String(numericValue) === value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSettingsYaml = (settings, indent) => {
|
||||||
|
const pad = ' '.repeat(indent);
|
||||||
|
const entries = Object.entries(settings || {});
|
||||||
|
if (entries.length === 0) return `${pad}{}`;
|
||||||
|
return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, forgeArguments }) => {
|
||||||
|
const forge = isForgeComponent(componentName);
|
||||||
|
const componentValue = forge ? FORGE_COMPONENT_TYPE : componentPath;
|
||||||
|
const settings = forge ? createForgeArguments(forgeArguments) : null;
|
||||||
|
const settingsYaml = forge ? `\n settings:\n${buildSettingsYaml(settings, 6)}` : '\n settings:\n length:';
|
||||||
|
|
||||||
|
return ` ${instanceName}:
|
||||||
|
component: ${componentValue}
|
||||||
|
x: ${Number(position.x || 0).toFixed(1)}
|
||||||
|
y: ${Number(position.y || 0).toFixed(1)}
|
||||||
|
rotation: ${Number(rotation || 0).toFixed(1)}
|
||||||
|
mirror: false${settingsYaml}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInstancesYaml = ({ nodes, resolveComponentPath }) => {
|
||||||
|
return (nodes || [])
|
||||||
|
.filter(node => node.data && node.data.componentName && !node.data.elementType)
|
||||||
|
.map(node => {
|
||||||
|
const data = node.data;
|
||||||
|
const componentName = data.componentName || '';
|
||||||
|
const componentPath = isForgeComponent(componentName)
|
||||||
|
? FORGE_COMPONENT_TYPE
|
||||||
|
: (resolveComponentPath ? resolveComponentPath(componentName) : componentName);
|
||||||
|
|
||||||
|
return buildInstanceYaml({
|
||||||
|
instanceName: data.componentDisplayName || node.id,
|
||||||
|
componentName,
|
||||||
|
componentPath,
|
||||||
|
position: node.position || { x: 0, y: 0 },
|
||||||
|
rotation: data.rotation || 0,
|
||||||
|
forgeArguments: data.forgeArguments
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodePortName = (node) => {
|
||||||
|
const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label);
|
||||||
|
return name || (node && node.id) || 'port';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
|
||||||
|
const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor');
|
||||||
|
|
||||||
|
const buildElementPorts = (elementType, data) => {
|
||||||
|
const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port'];
|
||||||
|
if (!element) return {};
|
||||||
|
if (element.elementType === 'port') {
|
||||||
|
return {
|
||||||
|
port: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
a: Number((data && (data.angle ?? data.a)) ?? 0),
|
||||||
|
width: Number((data && data.width) || 0.5)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(element.ports));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPageComponentPorts = (port, nodes) => {
|
||||||
|
const portNodes = (nodes || []).filter(isPortElementNode);
|
||||||
|
if (portNodes.length > 0) {
|
||||||
|
return portNodes.reduce((ports, node) => {
|
||||||
|
const data = node.data || {};
|
||||||
|
ports[getNodePortName(node)] = {
|
||||||
|
x: Number((node.position && node.position.x) || 0),
|
||||||
|
y: Number((node.position && node.position.y) || 0),
|
||||||
|
a: Number(data.angle ?? data.a ?? 0),
|
||||||
|
width: Number(data.width || 0.5)
|
||||||
|
};
|
||||||
|
return ports;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
if (!port) return {};
|
||||||
|
return {
|
||||||
|
port: {
|
||||||
|
x: Number(port.x || 0),
|
||||||
|
y: Number(port.y || 0),
|
||||||
|
a: Number(port.a || 0),
|
||||||
|
width: Number(port.width || 0.5)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
|
||||||
|
const ports = buildPageComponentPorts(fallbackPort, nodes);
|
||||||
|
const entries = Object.entries(ports);
|
||||||
|
if (entries.length === 0) return 'ports: []';
|
||||||
|
const sourceNodes = new Map((nodes || []).filter(isPortElementNode).map(node => [getNodePortName(node), node]));
|
||||||
|
const lines = entries.map(([name, info]) => {
|
||||||
|
const data = (sourceNodes.get(name) && sourceNodes.get(name).data) || {};
|
||||||
|
const description = data.description ? `\n description: ${toYamlScalar(data.description)}` : '';
|
||||||
|
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)}
|
||||||
|
angle: ${Number(info.a || 0).toFixed(1)}
|
||||||
|
width: ${Number(info.width || 0.5)}${description}`;
|
||||||
|
});
|
||||||
|
return `ports:\n${lines.join('\n')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
|
||||||
|
|
||||||
|
const buildElementsYaml = (nodes) => {
|
||||||
|
const elementNodes = (nodes || []).filter(isElementNode);
|
||||||
|
if (elementNodes.length === 0) return 'elements: {}';
|
||||||
|
const lines = elementNodes.map(node => {
|
||||||
|
const data = node.data || {};
|
||||||
|
const name = data.componentDisplayName || data.portName || node.id;
|
||||||
|
const angle = data.elementType === 'port' ? data.angle : data.rotation;
|
||||||
|
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)}
|
||||||
|
angle: ${Number(angle || 0).toFixed(1)}
|
||||||
|
layer: ${data.layer || 'WG_CORE'}
|
||||||
|
width: ${Number(data.width || 0.5)}
|
||||||
|
description: ${toYamlScalar(data.description || '')}`;
|
||||||
|
});
|
||||||
|
return `elements:\n${lines.join('\n')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
FORGE_COMPONENT_LABEL,
|
||||||
|
FORGE_COMPONENT_TYPE,
|
||||||
|
ELEMENT_COMPONENTS,
|
||||||
|
DEFAULT_FORGE_ARGUMENTS,
|
||||||
|
createForgeArguments,
|
||||||
|
isForgeComponent,
|
||||||
|
normalizeAngle,
|
||||||
|
portSideFromAngle,
|
||||||
|
buildPortHandles,
|
||||||
|
buildElementPorts,
|
||||||
|
buildInstanceYaml,
|
||||||
|
buildInstancesYaml,
|
||||||
|
buildPageComponentPorts,
|
||||||
|
buildCanvasPortsYaml,
|
||||||
|
buildPortsYaml,
|
||||||
|
buildElementsYaml,
|
||||||
|
buildSettingsYaml,
|
||||||
|
toYamlScalar
|
||||||
|
};
|
||||||
|
});
|
||||||
+596
-131
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('buildInstancesYaml'),
|
||||||
|
'canvas.html should use buildInstancesYaml for layout instance export'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('buildCanvasPortsYaml(activePage.nodes)'),
|
||||||
|
'canvas.html should export ports from active canvas port nodes'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('buildPageComponentPorts(page.port, page.nodes)'),
|
||||||
|
'canvas library entries should expose ports from their page-port data'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!canvasHtml.includes("activePage.nodes.filter(n => n.type === 'rotatableNode' && n.data?.type === 'composite')"),
|
||||||
|
'project layout export should not filter out regular PDK instances'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('Elements: {'),
|
||||||
|
'library tree should add an Elements folder'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes("__name__: 'Port'") && canvasHtml.includes("__name__: 'Anchor'"),
|
||||||
|
'Elements folder should expose Port and Anchor as separate virtual components'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('const isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true);'),
|
||||||
|
'Elements folder should bypass category-card grouping and render separate virtual component leaves'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('element-card-icon port-icon') && canvasHtml.includes('element-card-icon anchor-icon'),
|
||||||
|
'virtual element cards should render distinct generated icons for Port and Anchor'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('const selectedIsVirtualElement = selectedNode?.data?.elementType ==='),
|
||||||
|
'right inspector should classify virtual elements separately from PDK/forge components'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0;'),
|
||||||
|
'virtual elements should not show PDK or generate_with_forge component selection'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('buildElementsYaml(activePage.nodes)'),
|
||||||
|
'canvas layout export should include an elements section'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!canvasHtml.includes("activePage.nodes.filter(n => n.selected && n.id !== 'page-port')"),
|
||||||
|
'copy/delete should not exclude port nodes'
|
||||||
|
);
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const helpers = require('../frontend/canvas-helpers.js');
|
||||||
|
|
||||||
|
const handles = helpers.buildPortHandles({
|
||||||
|
a0: { x: 0, y: 0, a: 180 },
|
||||||
|
b0: { x: 0, y: 0, a: 0 },
|
||||||
|
a1: { x: -91.7, y: 4.475, a: 180 },
|
||||||
|
a2: { x: -91.7, y: -4.475, a: 180 },
|
||||||
|
b1: { x: 91.7, y: 4.475, a: 0 },
|
||||||
|
b2: { x: 91.7, y: -4.475, a: 0 },
|
||||||
|
ep2a: { x: -37.8, y: -20, a: 270 },
|
||||||
|
ep2b: { x: -37.8, y: 20, a: 90 },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepStrictEqual(handles.map(handle => handle.name), ['a1', 'a2', 'b1', 'b2', 'ep2b', 'ep2a']);
|
||||||
|
assert.deepStrictEqual(handles.filter(handle => handle.position === 'left').map(handle => handle.name), ['a1', 'a2']);
|
||||||
|
assert.deepStrictEqual(handles.filter(handle => handle.position === 'right').map(handle => handle.name), ['b1', 'b2']);
|
||||||
|
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2b').position, 'top');
|
||||||
|
assert.deepStrictEqual(handles.find(handle => handle.name === 'ep2a').position, 'bottom');
|
||||||
|
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 args = helpers.createForgeArguments();
|
||||||
|
assert(Object.keys(args).length >= 10);
|
||||||
|
assert.strictEqual(helpers.isForgeComponent('generate with mxpic_forge'), true);
|
||||||
|
|
||||||
|
const yaml = helpers.buildInstanceYaml({
|
||||||
|
instanceName: 'component_1',
|
||||||
|
componentName: 'generate with mxpic_forge',
|
||||||
|
componentPath: 'ignored/path',
|
||||||
|
position: { x: 12.34, y: -5 },
|
||||||
|
rotation: 90,
|
||||||
|
forgeArguments: { function_name: 'mmi1x2', length: 25.5, include_heater: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(yaml.includes('component: generate_with_forge'));
|
||||||
|
assert(yaml.includes('function_name: "mmi1x2"'));
|
||||||
|
assert(yaml.includes('length: 25.5'));
|
||||||
|
assert(yaml.includes('include_heater: true'));
|
||||||
|
|
||||||
|
const projectInstancesYaml = helpers.buildInstancesYaml({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node-1',
|
||||||
|
type: 'rotatableNode',
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
data: {
|
||||||
|
componentDisplayName: 'component_1',
|
||||||
|
componentName: 'PDK_A',
|
||||||
|
rotation: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node-2',
|
||||||
|
type: 'rotatableNode',
|
||||||
|
position: { x: 30, y: 40 },
|
||||||
|
data: {
|
||||||
|
componentDisplayName: 'cell_1',
|
||||||
|
componentName: 'canvas_1',
|
||||||
|
type: 'composite',
|
||||||
|
rotation: 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resolveComponentPath: name => name === 'PDK_A' ? 'foundry/path/PDK_A' : name
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(projectInstancesYaml.includes('component_1:'));
|
||||||
|
assert(projectInstancesYaml.includes('component: foundry/path/PDK_A'));
|
||||||
|
assert(projectInstancesYaml.includes('cell_1:'));
|
||||||
|
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('angle: 90.0'));
|
||||||
|
|
||||||
|
const componentPorts = helpers.buildPageComponentPorts({ x: 12, y: -6, a: 180 });
|
||||||
|
assert.deepStrictEqual(componentPorts, {
|
||||||
|
port: { x: 12, y: -6, a: 180, width: 0.5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const elementNodes = [
|
||||||
|
{
|
||||||
|
id: 'port-1',
|
||||||
|
type: 'portNode',
|
||||||
|
position: { x: 10, y: 20 },
|
||||||
|
data: {
|
||||||
|
componentDisplayName: 'in0',
|
||||||
|
elementType: 'port',
|
||||||
|
angle: 180,
|
||||||
|
width: 0.7,
|
||||||
|
layer: 'WG_CORE',
|
||||||
|
description: 'input port'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anchor-1',
|
||||||
|
type: 'rotatableNode',
|
||||||
|
position: { x: 30, y: 40 },
|
||||||
|
data: {
|
||||||
|
componentDisplayName: 'anchor_1',
|
||||||
|
componentName: 'Anchor',
|
||||||
|
elementType: 'anchor',
|
||||||
|
rotation: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mmi-1',
|
||||||
|
type: 'rotatableNode',
|
||||||
|
position: { x: 50, y: 60 },
|
||||||
|
data: {
|
||||||
|
componentDisplayName: 'component_1',
|
||||||
|
componentName: 'MMI',
|
||||||
|
rotation: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(helpers.buildElementPorts('port', { angle: 90, width: 0.8 }), {
|
||||||
|
port: { x: 0, y: 0, a: 90, width: 0.8 }
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor')), ['left', 'right']);
|
||||||
|
|
||||||
|
const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes);
|
||||||
|
assert(canvasPortsYaml.includes('name: in0'));
|
||||||
|
assert(canvasPortsYaml.includes('description: "input port"'));
|
||||||
|
assert(canvasPortsYaml.includes('width: 0.7'));
|
||||||
|
|
||||||
|
const elementsYaml = helpers.buildElementsYaml(elementNodes);
|
||||||
|
assert(elementsYaml.includes('in0:'));
|
||||||
|
assert(elementsYaml.includes('type: port'));
|
||||||
|
assert(elementsYaml.includes('anchor_1:'));
|
||||||
|
assert(elementsYaml.includes('type: anchor'));
|
||||||
|
|
||||||
|
const instancesWithoutElements = helpers.buildInstancesYaml({
|
||||||
|
nodes: elementNodes,
|
||||||
|
resolveComponentPath: name => name
|
||||||
|
});
|
||||||
|
assert(!instancesWithoutElements.includes('anchor_1:'));
|
||||||
|
assert(!instancesWithoutElements.includes('in0:'));
|
||||||
|
assert(instancesWithoutElements.includes('component_1:'));
|
||||||
|
|
||||||
|
const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes);
|
||||||
|
assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 180, width: 0.7 });
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
|
||||||
|
const serverPy = fs.readFileSync(path.join(root, 'backend', 'server.py'), 'utf8');
|
||||||
|
|
||||||
|
assert(
|
||||||
|
canvasHtml.includes('src="/canvas-helpers.js"'),
|
||||||
|
'canvas.html should request the canvas helper script from /canvas-helpers.js'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
serverPy.includes("@app.route('/canvas-helpers.js')"),
|
||||||
|
'backend/server.py should serve /canvas-helpers.js'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
serverPy.includes("send_from_directory(FRONTEND_DIR, 'canvas-helpers.js')") ||
|
||||||
|
serverPy.includes('send_from_directory(FRONTEND_DIR, "canvas-helpers.js")'),
|
||||||
|
'the /canvas-helpers.js route should serve frontend/canvas-helpers.js'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
serverPy.includes('def no_cache_response(response):'),
|
||||||
|
'canvas assets should use an explicit no-cache response helper'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
serverPy.includes('Cache-Control') && serverPy.includes('no-store'),
|
||||||
|
'canvas routes should prevent stale browser caches while the editor is changing'
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user