diff --git a/README.md b/README.md
index 5a83dbc..2f92598 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,144 @@
-# SCHEMETA (MVP)
+# SCHEMETA
-AI-Native Schematic Compiler & Visual Teaching Platform.
+AI-Native schematic compiler and visual teaching workspace.
-This MVP implements:
-- Schemeta JSON Model validation
-- ERC checks (power/output conflicts, floating pins, ground-net checks)
-- Deterministic component placement + Manhattan routing
-- SVG rendering with interactive data attributes
-- HTTP API: `POST /compile`, `POST /analyze`
+This version includes:
+- Deterministic JSON validation, ERC, topology extraction
+- Auto-generic symbol synthesis for unknown components/pins (deterministic fallback)
+- Auto-template synthesis for common passives/connectors (`R/C/L/D/LED/J/P`)
+- Constraint-aware placement, orthogonal routing, net-stub schematic mode
+- SVG rendering with bus grouping, tie symbols, junctions, and legend
+- Workspace UI for navigation, drag/lock placement, isolate/highlight, diagnostics focus, and direct graph editing
+- JSON power tools: validate, format, sort keys, copy minimal repro
+- REST + MCP integration (compile/analyze + UI bundle metadata)
## Run
```bash
-npm install
-npm run dev
+npm run start
```
-Server defaults to `http://localhost:8787`.
+Open:
+- Workspace: `http://localhost:8787/`
+- Health: `http://localhost:8787/health`
+- MCP UI bundle descriptor: `http://localhost:8787/mcp/ui-bundle`
-## API
-
-### `POST /analyze`
-Input: SJM JSON
-Output: validation/ERC errors + warnings + topology summary
+## REST API
### `POST /compile`
-Input: SJM JSON
-Output: all `analyze` fields + rendered `svg`
-
-## Example
-
-```bash
-curl -sS -X POST http://localhost:8787/compile \
- -H 'content-type: application/json' \
- --data-binary @examples/esp32-audio.json
+Input:
+```json
+{
+ "payload": { "...": "SJM" },
+ "options": {
+ "render_mode": "schematic_stub",
+ "show_labels": true,
+ "generic_symbols": true
+ }
+}
```
-## Notes
+`payload` can also be posted directly (backward compatible).
-- Deterministic rendering is guaranteed by stable sorting (`ref`, `net.name`) and fixed layout constants.
-- Wires are derived from net truth; nets remain source-of-truth.
-- Current layout is constraint-aware only at basic group ordering level in this MVP.
+Output includes:
+- `errors`, `warnings` with `id` + `suggestion`
+- `focus_map` for issue-to-canvas targeting
+- `layout` (resolved placements)
+- `layout_metrics` (crossings, overlaps, label collisions, tie points, bus groups)
+- `bus_groups`
+- `render_mode_used`
+- `svg`
+
+### `POST /analyze`
+Input: same payload model format
+Output: ERC + topology summary (power domains, clock source/sink, buses, paths)
+
+`generic_symbols` defaults to `true`. When enabled, missing symbols or missing pins on generic symbols are synthesized from net usage and surfaced as warnings:
+- `auto_template_symbol_created` (for recognized common parts like resistor/capacitor/etc.)
+- `auto_template_symbol_hydrated` (template shorthand expanded to full runtime symbol)
+- `auto_generic_symbol_created`
+- `auto_generic_symbol_hydrated` (generic shorthand expanded to full runtime symbol)
+- `auto_generic_pin_created`
+
+Shorthand symbols are supported for concise AI output:
+
+```json
+{
+ "symbols": {
+ "r_std": { "template_name": "resistor" },
+ "x_generic": { "category": "generic" }
+ }
+}
+```
+
+`symbol_id`, `category`, `body`, and `pins` are auto-filled as needed during compile/analyze.
+
+Instance-level built-in parts are also supported (no symbol definition required):
+
+```json
+{
+ "instances": [
+ {
+ "ref": "R1",
+ "part": "resistor",
+ "properties": { "value": "10k" },
+ "placement": { "x": null, "y": null, "rotation": 0, "locked": false }
+ }
+ ]
+}
+```
+
+Supported `part` values: `resistor`, `capacitor`, `inductor`, `diode`, `led`, `connector`, `generic`.
+
+Per-pin editor hints are supported through instance properties:
+
+```json
+{
+ "instances": [
+ {
+ "ref": "R1",
+ "part": "resistor",
+ "properties": {
+ "value": "10k",
+ "pin_ui": {
+ "1": { "show_net_label": true },
+ "2": { "show_net_label": false }
+ }
+ },
+ "placement": { "x": null, "y": null, "rotation": 0, "locked": false }
+ }
+ ]
+}
+```
+
+### `POST /layout/auto`
+Computes fresh placement (ignores current locks), returns:
+- updated `model`
+- compiled result in `compile`
+
+### `POST /layout/tidy`
+Computes placement tidy while respecting locks, returns:
+- updated `model`
+- compiled result in `compile`
+
+## MCP
+
+Start stdio MCP server:
+
+```bash
+npm run mcp
+```
+
+Tools:
+- `schemeta_compile`
+- `schemeta_analyze`
+- `schemeta_ui_bundle`
+
+## Workspace behavior highlights
+
+- Fit-to-view default on load/import/apply
+- Space + drag pan, wheel zoom, fit button
+- Net/component/pin selection with dimming + isolate toggles
+- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
+- Click diagnostics to jump/flash focused net/component/pin
+- Auto Layout and Auto Tidy actions
diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..cef2ed9
--- /dev/null
+++ b/frontend/app.js
@@ -0,0 +1,2372 @@
+const GRID = 20;
+const SNAPSHOTS_KEY = "schemeta:snapshots:v2";
+const SCHEMA_URL = "/schemeta.schema.json";
+const NET_CLASSES = ["power", "ground", "signal", "analog", "differential", "clock", "bus"];
+const PIN_SIDES = ["left", "right", "top", "bottom"];
+const PIN_TYPES = ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"];
+
+const state = {
+ model: null,
+ compile: null,
+ selectedRef: null,
+ selectedRefs: [],
+ selectedNet: null,
+ selectedPin: null,
+ scale: 1,
+ panX: 40,
+ panY: 40,
+ isPanning: false,
+ dragComponent: null,
+ draggingComponentRef: null,
+ dragPointerId: null,
+ dragPreviewNode: null,
+ dragMoved: false,
+ showLabels: true,
+ isolateNet: false,
+ isolateComponent: false,
+ renderMode: "schematic_stub",
+ userAdjustedView: false,
+ spacePan: false,
+ schemaText: "",
+ boxSelecting: false,
+ boxStartX: 0,
+ boxStartY: 0,
+ boxMoved: false,
+ suppressCanvasClick: false,
+ compileDebounceId: null
+};
+
+const el = {
+ instanceList: document.getElementById("instanceList"),
+ netList: document.getElementById("netList"),
+ instanceFilter: document.getElementById("instanceFilter"),
+ netFilter: document.getElementById("netFilter"),
+ canvasViewport: document.getElementById("canvasViewport"),
+ canvasInner: document.getElementById("canvasInner"),
+ selectionBox: document.getElementById("selectionBox"),
+ compileStatus: document.getElementById("compileStatus"),
+ selectedSummary: document.getElementById("selectedSummary"),
+ componentEditor: document.getElementById("componentEditor"),
+ symbolEditor: document.getElementById("symbolEditor"),
+ pinEditor: document.getElementById("pinEditor"),
+ netEditor: document.getElementById("netEditor"),
+ instRefInput: document.getElementById("instRefInput"),
+ instValueInput: document.getElementById("instValueInput"),
+ instNotesInput: document.getElementById("instNotesInput"),
+ xInput: document.getElementById("xInput"),
+ yInput: document.getElementById("yInput"),
+ rotationInput: document.getElementById("rotationInput"),
+ lockedInput: document.getElementById("lockedInput"),
+ rotateSelectedBtn: document.getElementById("rotateSelectedBtn"),
+ updatePlacementBtn: document.getElementById("updatePlacementBtn"),
+ symbolMeta: document.getElementById("symbolMeta"),
+ symbolCategoryInput: document.getElementById("symbolCategoryInput"),
+ symbolWidthInput: document.getElementById("symbolWidthInput"),
+ symbolHeightInput: document.getElementById("symbolHeightInput"),
+ addSymbolPinBtn: document.getElementById("addSymbolPinBtn"),
+ applySymbolBtn: document.getElementById("applySymbolBtn"),
+ symbolPinsList: document.getElementById("symbolPinsList"),
+ pinMeta: document.getElementById("pinMeta"),
+ pinNameInput: document.getElementById("pinNameInput"),
+ pinNumberInput: document.getElementById("pinNumberInput"),
+ pinSideInput: document.getElementById("pinSideInput"),
+ pinTypeInput: document.getElementById("pinTypeInput"),
+ pinOffsetInput: document.getElementById("pinOffsetInput"),
+ applyPinPropsBtn: document.getElementById("applyPinPropsBtn"),
+ showPinNetLabelInput: document.getElementById("showPinNetLabelInput"),
+ pinNetSelect: document.getElementById("pinNetSelect"),
+ connectPinBtn: document.getElementById("connectPinBtn"),
+ newNetNameInput: document.getElementById("newNetNameInput"),
+ newNetClassInput: document.getElementById("newNetClassInput"),
+ createConnectNetBtn: document.getElementById("createConnectNetBtn"),
+ pinConnections: document.getElementById("pinConnections"),
+ netNameInput: document.getElementById("netNameInput"),
+ netClassInput: document.getElementById("netClassInput"),
+ updateNetBtn: document.getElementById("updateNetBtn"),
+ netNodeRefInput: document.getElementById("netNodeRefInput"),
+ netNodePinInput: document.getElementById("netNodePinInput"),
+ addNetNodeBtn: document.getElementById("addNetNodeBtn"),
+ netNodesList: document.getElementById("netNodesList"),
+ issues: document.getElementById("issues"),
+ topology: document.getElementById("topology"),
+ jsonEditor: document.getElementById("jsonEditor"),
+ jsonFeedback: document.getElementById("jsonFeedback"),
+ loadSampleBtn: document.getElementById("loadSampleBtn"),
+ newProjectBtn: document.getElementById("newProjectBtn"),
+ importBtn: document.getElementById("importBtn"),
+ exportBtn: document.getElementById("exportBtn"),
+ fileInput: document.getElementById("fileInput"),
+ zoomInBtn: document.getElementById("zoomInBtn"),
+ zoomOutBtn: document.getElementById("zoomOutBtn"),
+ zoomResetBtn: document.getElementById("zoomResetBtn"),
+ fitViewBtn: document.getElementById("fitViewBtn"),
+ showLabelsInput: document.getElementById("showLabelsInput"),
+ applyJsonBtn: document.getElementById("applyJsonBtn"),
+ showSchemaBtn: document.getElementById("showSchemaBtn"),
+ validateJsonBtn: document.getElementById("validateJsonBtn"),
+ formatJsonBtn: document.getElementById("formatJsonBtn"),
+ sortJsonBtn: document.getElementById("sortJsonBtn"),
+ copyReproBtn: document.getElementById("copyReproBtn"),
+ autoLayoutBtn: document.getElementById("autoLayoutBtn"),
+ autoTidyBtn: document.getElementById("autoTidyBtn"),
+ renderModeSelect: document.getElementById("renderModeSelect"),
+ isolateNetBtn: document.getElementById("isolateNetBtn"),
+ isolateComponentBtn: document.getElementById("isolateComponentBtn"),
+ pinTooltip: document.getElementById("pinTooltip"),
+ schemaModal: document.getElementById("schemaModal"),
+ schemaViewer: document.getElementById("schemaViewer"),
+ closeSchemaBtn: document.getElementById("closeSchemaBtn"),
+ copySchemaBtn: document.getElementById("copySchemaBtn"),
+ downloadSchemaBtn: document.getElementById("downloadSchemaBtn")
+};
+
+function toGrid(v) {
+ return Math.round(v / GRID) * GRID;
+}
+
+function clone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+function hasSelectionModifier(evt) {
+ return Boolean(evt?.ctrlKey || evt?.metaKey || evt?.shiftKey);
+}
+
+function setSelectedRefs(refs) {
+ const uniq = [...new Set((refs ?? []).filter(Boolean))];
+ state.selectedRefs = uniq;
+ state.selectedRef = uniq.length === 1 ? uniq[0] : null;
+}
+
+function selectSingleRef(ref) {
+ setSelectedRefs(ref ? [ref] : []);
+}
+
+function toggleSelectedRef(ref) {
+ const set = new Set(state.selectedRefs);
+ if (set.has(ref)) {
+ set.delete(ref);
+ } else {
+ set.add(ref);
+ }
+ setSelectedRefs([...set]);
+}
+
+function selectedRefSet() {
+ return new Set(state.selectedRefs);
+}
+
+function instanceByRef(ref) {
+ return state.model?.instances.find((i) => i.ref === ref) ?? null;
+}
+
+function symbolForRef(ref) {
+ const inst = instanceByRef(ref);
+ if (!inst) {
+ return null;
+ }
+ return state.model?.symbols?.[inst.symbol] ?? null;
+}
+
+function pinExists(ref, pinName) {
+ const sym = symbolForRef(ref);
+ return Boolean(sym?.pins?.some((p) => p.name === pinName));
+}
+
+function normalizeRef(raw) {
+ return String(raw ?? "")
+ .trim()
+ .replace(/\s+/g, "_");
+}
+
+function normalizeNetName(raw) {
+ return String(raw ?? "")
+ .trim()
+ .replace(/\s+/g, "_");
+}
+
+function escHtml(text) {
+ return String(text ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+async function apiPost(path, payload) {
+ const res = await fetch(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data?.error?.message || "Request failed");
+ }
+
+ return data;
+}
+
+function setStatus(text, ok = true) {
+ el.compileStatus.textContent = text;
+ el.compileStatus.className = ok ? "status-ok" : "";
+}
+
+function defaultProject() {
+ return {
+ meta: { title: "Untitled Schemeta Project" },
+ symbols: {},
+ instances: [],
+ nets: [],
+ constraints: {},
+ annotations: []
+ };
+}
+
+function compileOptions() {
+ return {
+ render_mode: state.renderMode,
+ show_labels: state.showLabels,
+ generic_symbols: true
+ };
+}
+
+function applyCompileLayoutToModel(model, compileResult) {
+ const next = clone(model);
+ const placed = compileResult?.layout?.placed;
+ if (!Array.isArray(placed)) {
+ return next;
+ }
+
+ const byRef = new Map(placed.map((p) => [p.ref, p]));
+ for (const inst of next.instances) {
+ const p = byRef.get(inst.ref);
+ if (!p) {
+ continue;
+ }
+ inst.placement.x = p.x;
+ inst.placement.y = p.y;
+ inst.placement.rotation = p.rotation ?? inst.placement.rotation ?? 0;
+ inst.placement.locked = p.locked ?? inst.placement.locked ?? false;
+ }
+
+ return next;
+}
+
+function reconcileSelectionWithModel() {
+ if (!state.model) {
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ return;
+ }
+
+ const refs = new Set(state.model.instances.map((i) => i.ref));
+ setSelectedRefs(state.selectedRefs.filter((r) => refs.has(r)));
+
+ if (state.selectedPin) {
+ if (!refs.has(state.selectedPin.ref) || !pinExists(state.selectedPin.ref, state.selectedPin.pin)) {
+ state.selectedPin = null;
+ }
+ }
+
+ if (state.selectedNet) {
+ const exists = state.model.nets.some((n) => n.name === state.selectedNet);
+ if (!exists) {
+ state.selectedNet = null;
+ }
+ }
+}
+
+function refreshJsonEditor() {
+ if (!state.model) {
+ return;
+ }
+ el.jsonEditor.value = JSON.stringify(state.model, null, 2);
+}
+
+function saveSnapshot() {
+ if (!state.model) {
+ return;
+ }
+
+ const snap = {
+ id: `${Date.now()}`,
+ ts: new Date().toISOString(),
+ model: state.model
+ };
+
+ const existing = JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) ?? "[]");
+ const next = [snap, ...existing].slice(0, 20);
+ localStorage.setItem(SNAPSHOTS_KEY, JSON.stringify(next));
+}
+
+function updateTransform() {
+ el.canvasInner.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
+ el.zoomResetBtn.textContent = `${Math.round(state.scale * 100)}%`;
+}
+
+function fitView(layout) {
+ const w = layout?.width ?? 0;
+ const h = layout?.height ?? 0;
+ if (!w || !h) {
+ return;
+ }
+
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ const byRef = new Map((layout?.placed ?? []).map((p) => [p.ref, p]));
+ if (state.model?.instances?.length) {
+ for (const inst of state.model.instances) {
+ const p = byRef.get(inst.ref);
+ const sym = state.model.symbols?.[inst.symbol];
+ if (!p || !sym) {
+ continue;
+ }
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x + sym.body.width);
+ maxY = Math.max(maxY, p.y + sym.body.height);
+ }
+ }
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
+ minX = 0;
+ minY = 0;
+ maxX = w;
+ maxY = h;
+ }
+
+ const pad = 80;
+ const bbox = {
+ x: Math.max(0, minX - pad),
+ y: Math.max(0, minY - pad),
+ w: Math.min(w, maxX - minX + pad * 2),
+ h: Math.min(h, maxY - minY + pad * 2)
+ };
+
+ const viewport = el.canvasViewport.getBoundingClientRect();
+ const sx = (viewport.width * 0.98) / Math.max(1, bbox.w);
+ const sy = (viewport.height * 0.98) / Math.max(1, bbox.h);
+ state.scale = Math.max(0.2, Math.min(4, Math.min(sx, sy)));
+ state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
+ state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
+ state.userAdjustedView = false;
+ updateTransform();
+}
+
+function zoomToBBox(bbox) {
+ if (!bbox) {
+ return;
+ }
+
+ const viewport = el.canvasViewport.getBoundingClientRect();
+ const scaleX = (viewport.width * 0.75) / Math.max(1, bbox.w);
+ const scaleY = (viewport.height * 0.75) / Math.max(1, bbox.h);
+ state.scale = Math.max(0.3, Math.min(4, Math.min(scaleX, scaleY)));
+ state.panX = viewport.width / 2 - (bbox.x + bbox.w / 2) * state.scale;
+ state.panY = viewport.height / 2 - (bbox.y + bbox.h / 2) * state.scale;
+ state.userAdjustedView = true;
+ updateTransform();
+}
+
+function canvasToSvgPoint(clientX, clientY) {
+ const rect = el.canvasViewport.getBoundingClientRect();
+ return {
+ x: (clientX - rect.left - state.panX) / state.scale,
+ y: (clientY - rect.top - state.panY) / state.scale
+ };
+}
+
+function viewportPoint(clientX, clientY) {
+ const rect = el.canvasViewport.getBoundingClientRect();
+ return {
+ x: clientX - rect.left,
+ y: clientY - rect.top
+ };
+}
+
+function beginBoxSelection(clientX, clientY) {
+ const p = viewportPoint(clientX, clientY);
+ state.boxSelecting = true;
+ state.boxMoved = false;
+ state.boxStartX = p.x;
+ state.boxStartY = p.y;
+ el.selectionBox.classList.remove("hidden");
+ el.selectionBox.style.left = `${p.x}px`;
+ el.selectionBox.style.top = `${p.y}px`;
+ el.selectionBox.style.width = "0px";
+ el.selectionBox.style.height = "0px";
+}
+
+function updateBoxSelection(clientX, clientY) {
+ if (!state.boxSelecting) {
+ return;
+ }
+ const p = viewportPoint(clientX, clientY);
+ const x = Math.min(state.boxStartX, p.x);
+ const y = Math.min(state.boxStartY, p.y);
+ const w = Math.abs(p.x - state.boxStartX);
+ const h = Math.abs(p.y - state.boxStartY);
+ if (w > 4 || h > 4) {
+ state.boxMoved = true;
+ }
+ el.selectionBox.style.left = `${x}px`;
+ el.selectionBox.style.top = `${y}px`;
+ el.selectionBox.style.width = `${w}px`;
+ el.selectionBox.style.height = `${h}px`;
+}
+
+function finishBoxSelection() {
+ if (!state.boxSelecting) {
+ return;
+ }
+
+ const box = el.selectionBox.getBoundingClientRect();
+ el.selectionBox.classList.add("hidden");
+ const moved = state.boxMoved;
+ state.boxSelecting = false;
+ state.boxMoved = false;
+ if (!moved) {
+ return;
+ }
+
+ state.suppressCanvasClick = true;
+ const svg = el.canvasInner.querySelector("svg");
+ if (!svg) {
+ return;
+ }
+
+ const hits = [];
+ svg.querySelectorAll("[data-ref]").forEach((node) => {
+ const r = node.getBoundingClientRect();
+ const intersects = r.left < box.right && r.right > box.left && r.top < box.bottom && r.bottom > box.top;
+ if (intersects) {
+ const ref = node.getAttribute("data-ref");
+ if (ref) {
+ hits.push(ref);
+ }
+ }
+ });
+
+ if (hits.length) {
+ setSelectedRefs(hits);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ renderAll();
+ } else {
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ state.isolateNet = false;
+ state.isolateComponent = false;
+ renderAll();
+ }
+}
+
+function incidentNetsForRef(ref) {
+ if (!state.model) {
+ return new Set();
+ }
+
+ return new Set(
+ state.model.nets
+ .filter((net) => net.nodes.some((n) => n.ref === ref))
+ .map((net) => net.name)
+ );
+}
+
+function refsConnectedToNet(netName) {
+ const net = state.model?.nets.find((n) => n.name === netName);
+ return new Set((net?.nodes ?? []).map((n) => n.ref));
+}
+
+function activeNetSet() {
+ if (state.selectedNet) {
+ return new Set([state.selectedNet]);
+ }
+
+ if (state.selectedRefs.length) {
+ const nets = new Set();
+ for (const ref of state.selectedRefs) {
+ for (const net of incidentNetsForRef(ref)) {
+ nets.add(net);
+ }
+ }
+ return nets;
+ }
+
+ return null;
+}
+
+function setLabelLayerVisibility() {
+ const svg = el.canvasInner.querySelector("svg");
+ if (!svg) {
+ return;
+ }
+
+ const layer = svg.querySelector('[data-layer="net-labels"]');
+ if (layer) {
+ layer.style.display = state.showLabels ? "" : "none";
+ }
+}
+
+function renderInstances() {
+ if (!state.model) {
+ el.instanceList.innerHTML = "";
+ return;
+ }
+
+ const q = el.instanceFilter.value.trim().toLowerCase();
+ const items = state.model.instances.filter((i) => i.ref.toLowerCase().includes(q));
+
+ el.instanceList.innerHTML = items
+ .map((inst) => {
+ const cls = state.selectedRefs.includes(inst.ref) ? "active" : "";
+ return `
${inst.ref} · ${inst.symbol}`;
+ })
+ .join("");
+}
+
+function renderNets() {
+ if (!state.model) {
+ el.netList.innerHTML = "";
+ return;
+ }
+
+ const q = el.netFilter.value.trim().toLowerCase();
+ const items = state.model.nets.filter((n) => n.name.toLowerCase().includes(q));
+
+ el.netList.innerHTML = items
+ .map((net) => {
+ const cls = net.name === state.selectedNet ? "active" : "";
+ return `${net.name} (${net.class})`;
+ })
+ .join("");
+}
+
+function netByName(name) {
+ return state.model?.nets.find((n) => n.name === name) ?? null;
+}
+
+function pinUi(inst, pinName) {
+ const ui = inst?.properties?.pin_ui;
+ if (!ui || typeof ui !== "object") {
+ return {};
+ }
+ const pinObj = ui[pinName];
+ if (!pinObj || typeof pinObj !== "object") {
+ return {};
+ }
+ return pinObj;
+}
+
+function inferClassForPin(ref, pinName) {
+ const sym = symbolForRef(ref);
+ const pin = sym?.pins?.find((p) => p.name === pinName);
+ const pinType = String(pin?.type ?? "").toLowerCase();
+ if (pinType === "ground") {
+ return "ground";
+ }
+ if (pinType === "power_in" || pinType === "power_out") {
+ return "power";
+ }
+ if (pinType === "analog") {
+ return "analog";
+ }
+ return "signal";
+}
+
+function nextAutoNetName() {
+ const names = new Set((state.model?.nets ?? []).map((n) => n.name));
+ let n = 1;
+ while (names.has(`NET_${n}`)) {
+ n += 1;
+ }
+ return `NET_${n}`;
+}
+
+function renamePinAcrossSymbolInstances(symbolId, oldPinName, newPinName) {
+ if (!state.model || !oldPinName || !newPinName || oldPinName === newPinName) {
+ return;
+ }
+ const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === symbolId).map((i) => i.ref));
+ for (const net of state.model.nets ?? []) {
+ for (const node of net.nodes ?? []) {
+ if (refs.has(node.ref) && node.pin === oldPinName) {
+ node.pin = newPinName;
+ }
+ }
+ }
+ if (state.selectedPin && refs.has(state.selectedPin.ref) && state.selectedPin.pin === oldPinName) {
+ state.selectedPin.pin = newPinName;
+ }
+}
+
+function symbolPinRowHtml(pin) {
+ const sideOptions = PIN_SIDES.map((s) => ``).join("");
+ const typeOptions = PIN_TYPES.map((t) => ``).join("");
+ return `
+
+
+
+
+
+
+
`;
+}
+
+function renderSymbolEditorForRef(ref) {
+ const inst = instanceByRef(ref);
+ const sym = symbolForRef(ref);
+ if (!inst || !sym) {
+ el.symbolEditor.classList.add("hidden");
+ return;
+ }
+ el.symbolMeta.textContent = `Symbol ${inst.symbol} (${(sym.pins ?? []).length} pins)`;
+ el.symbolCategoryInput.value = String(sym.category ?? "");
+ el.symbolWidthInput.value = String(Number(sym.body?.width ?? 120));
+ el.symbolHeightInput.value = String(Number(sym.body?.height ?? 80));
+ el.symbolPinsList.innerHTML = (sym.pins ?? []).map((pin) => symbolPinRowHtml(pin)).join("");
+ el.symbolEditor.classList.remove("hidden");
+}
+
+function renderPinEditor() {
+ if (!state.selectedPin || !state.model) {
+ el.pinEditor.classList.add("hidden");
+ return;
+ }
+
+ const { ref, pin } = state.selectedPin;
+ const inst = instanceByRef(ref);
+ const sym = symbolForRef(ref);
+ const pinDef = sym?.pins?.find((p) => p.name === pin);
+ if (!inst) {
+ el.pinEditor.classList.add("hidden");
+ return;
+ }
+
+ const nets = [...new Set((state.model.nets ?? []).filter((n) => n.nodes.some((x) => x.ref === ref && x.pin === pin)).map((n) => n.name))];
+ const ui = pinUi(inst, pin);
+ el.pinMeta.textContent = `${ref}.${pin} | Nets: ${nets.length ? nets.join(", ") : "(unconnected)"}`;
+ el.pinNameInput.value = String(pinDef?.name ?? pin);
+ el.pinNumberInput.value = String(pinDef?.number ?? "");
+ el.pinSideInput.value = PIN_SIDES.includes(String(pinDef?.side ?? "")) ? String(pinDef.side) : "left";
+ el.pinTypeInput.value = PIN_TYPES.includes(String(pinDef?.type ?? "")) ? String(pinDef.type) : "passive";
+ el.pinOffsetInput.value = String(Number(pinDef?.offset ?? 0));
+ el.showPinNetLabelInput.checked = Boolean(ui.show_net_label ?? inst.properties?.show_net_labels);
+ el.pinNetSelect.innerHTML = (state.model.nets ?? [])
+ .map((n) => ``)
+ .join("");
+ el.newNetNameInput.placeholder = nextAutoNetName();
+ el.newNetClassInput.value = inferClassForPin(ref, pin);
+ el.pinConnections.innerHTML = nets.length
+ ? nets
+ .map(
+ (name) =>
+ `${name}
`
+ )
+ .join("")
+ : `No net connections yet.
`;
+
+ el.pinEditor.classList.remove("hidden");
+}
+
+function renderNetEditor() {
+ if (!state.selectedNet || !state.model) {
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+ const net = netByName(state.selectedNet);
+ if (!net) {
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+
+ el.netNameInput.value = net.name;
+ el.netClassInput.value = NET_CLASSES.includes(net.class) ? net.class : "signal";
+ el.netNodeRefInput.value = state.selectedPin?.ref ?? "";
+ el.netNodePinInput.value = state.selectedPin?.pin ?? "";
+ el.netNodesList.innerHTML = (net.nodes ?? [])
+ .map(
+ (node) =>
+ `${node.ref}.${node.pin}
`
+ )
+ .join("");
+ el.netEditor.classList.remove("hidden");
+}
+
+function renderSelected() {
+ if (!state.model) {
+ el.selectedSummary.textContent = "Click a component, net, or pin to inspect it.";
+ el.componentEditor.classList.add("hidden");
+ el.symbolEditor.classList.add("hidden");
+ el.pinEditor.classList.add("hidden");
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+
+ if (state.selectedPin) {
+ const nets = (state.model.nets ?? [])
+ .filter((n) => n.nodes.some((x) => x.ref === state.selectedPin.ref && x.pin === state.selectedPin.pin))
+ .map((n) => n.name);
+ el.selectedSummary.textContent = `${state.selectedPin.ref}.${state.selectedPin.pin}\nNets: ${nets.length ? nets.join(", ") : "(no net)"}`;
+ el.componentEditor.classList.add("hidden");
+ el.symbolEditor.classList.add("hidden");
+ renderPinEditor();
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+
+ if (state.selectedRefs.length > 1) {
+ el.selectedSummary.textContent = `${state.selectedRefs.length} components selected\n${state.selectedRefs.slice(0, 10).join(", ")}${state.selectedRefs.length > 10 ? "..." : ""}`;
+ el.componentEditor.classList.add("hidden");
+ el.symbolEditor.classList.add("hidden");
+ el.pinEditor.classList.add("hidden");
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+
+ const inst = state.model.instances.find((i) => i.ref === state.selectedRef);
+ if (inst) {
+ el.selectedSummary.textContent = `${inst.ref} (${inst.symbol})`;
+ el.componentEditor.classList.remove("hidden");
+ el.xInput.value = String(inst.placement.x ?? 0);
+ el.yInput.value = String(inst.placement.y ?? 0);
+ el.rotationInput.value = String((Math.round(Number(inst.placement.rotation ?? 0) / 90) * 90 + 360) % 360);
+ el.lockedInput.checked = Boolean(inst.placement.locked);
+ el.instRefInput.value = inst.ref;
+ el.instValueInput.value = String(inst.properties?.value ?? "");
+ el.instNotesInput.value = String(inst.properties?.notes ?? "");
+ renderSymbolEditorForRef(inst.ref);
+ el.pinEditor.classList.add("hidden");
+ el.netEditor.classList.add("hidden");
+ return;
+ }
+
+ if (state.selectedNet) {
+ const net = state.model.nets.find((n) => n.name === state.selectedNet);
+ if (net) {
+ el.selectedSummary.textContent = `${net.name} (${net.class})\nNodes: ${net.nodes.map((n) => `${n.ref}.${n.pin}`).join(", ")}`;
+ el.componentEditor.classList.add("hidden");
+ el.symbolEditor.classList.add("hidden");
+ el.pinEditor.classList.add("hidden");
+ renderNetEditor();
+ return;
+ }
+ }
+
+ el.selectedSummary.textContent = "Click a component, net, or pin to inspect it.";
+ el.componentEditor.classList.add("hidden");
+ el.symbolEditor.classList.add("hidden");
+ el.pinEditor.classList.add("hidden");
+ el.netEditor.classList.add("hidden");
+}
+
+function renderIssues() {
+ const errors = state.compile?.errors ?? [];
+ const warnings = state.compile?.warnings ?? [];
+
+ if (!errors.length && !warnings.length) {
+ el.issues.innerHTML = "No issues.\nClick a net/component to inspect relationships.";
+ return;
+ }
+
+ const rows = [
+ ...errors.map(
+ (issue) =>
+ `[E] ${issue.message}
${issue.code} · ${issue.path ?? "-"}
${issue.suggestion ?? ""}
`
+ ),
+ ...warnings.map(
+ (issue) =>
+ `[W] ${issue.message}
${issue.code} · ${issue.path ?? "-"}
${issue.suggestion ?? ""}
`
+ )
+ ];
+
+ el.issues.innerHTML = rows.join("");
+}
+
+function renderTopology() {
+ const t = state.compile?.topology;
+ if (!t) {
+ el.topology.textContent = "No topology.";
+ return;
+ }
+
+ const lines = [];
+ lines.push("Power domains:");
+ for (const pd of t.power_domain_consumers ?? []) {
+ lines.push(`- ${pd.name}: ${pd.count} consumers`);
+ }
+
+ lines.push(`Clock sources: ${(t.clock_sources ?? []).join(", ") || "-"}`);
+ lines.push(`Clock sinks: ${(t.clock_sinks ?? []).join(", ") || "-"}`);
+ lines.push("Buses:");
+ if (t.buses?.length) {
+ for (const b of t.buses) {
+ lines.push(`- ${b.name}: ${b.nets.join(", ")}`);
+ }
+ } else {
+ lines.push("- none");
+ }
+
+ lines.push("Signal paths:");
+ if (t.signal_paths?.length) {
+ for (const p of t.signal_paths) {
+ lines.push(`- ${p.join(" -> ")}`);
+ }
+ } else {
+ lines.push("- none");
+ }
+
+ el.topology.textContent = lines.join("\n");
+}
+
+function parsePinNets(node) {
+ const raw = node.getAttribute("data-pin-nets") ?? "";
+ if (!raw) {
+ return [];
+ }
+ return raw.split(",").filter(Boolean);
+}
+
+function applyVisualHighlight() {
+ const svg = el.canvasInner.querySelector("svg");
+ if (!svg) {
+ return;
+ }
+
+ const activeNets = activeNetSet();
+ const isolateByNet = state.isolateNet && state.selectedNet;
+ const selectedRefs = selectedRefSet();
+ const isolateByComp = state.isolateComponent && selectedRefs.size > 0;
+ const compIncident = new Set();
+ for (const ref of selectedRefs) {
+ for (const net of incidentNetsForRef(ref)) {
+ compIncident.add(net);
+ }
+ }
+ const netRefs = state.selectedNet ? refsConnectedToNet(state.selectedNet) : new Set();
+
+ svg.querySelectorAll("[data-net], [data-net-label], [data-net-junction], [data-net-tie]").forEach((node) => {
+ const net =
+ node.getAttribute("data-net") ??
+ node.getAttribute("data-net-label") ??
+ node.getAttribute("data-net-junction") ??
+ node.getAttribute("data-net-tie");
+
+ let on = true;
+ if (isolateByNet) {
+ on = net === state.selectedNet;
+ } else if (isolateByComp) {
+ on = compIncident.has(net);
+ } else if (activeNets) {
+ on = activeNets.has(net);
+ }
+
+ node.style.opacity = on ? "1" : activeNets || isolateByNet || isolateByComp ? "0.12" : "1";
+ });
+
+ svg.querySelectorAll("[data-ref]").forEach((node) => {
+ const ref = node.getAttribute("data-ref");
+ let on = true;
+ if (isolateByComp) {
+ on = selectedRefs.has(ref);
+ } else if (isolateByNet) {
+ on = netRefs.has(ref);
+ }
+
+ node.style.opacity = on ? "1" : "0.1";
+ if (selectedRefs.has(ref)) {
+ const rect = node.querySelector("rect");
+ if (rect) {
+ rect.setAttribute("stroke", "#155eef");
+ rect.setAttribute("stroke-width", "2.6");
+ }
+ } else {
+ const rect = node.querySelector("rect");
+ if (rect) {
+ rect.setAttribute("stroke", "#1f2937");
+ rect.setAttribute("stroke-width", "2");
+ }
+ }
+ });
+
+ svg.querySelectorAll("[data-pin-ref]").forEach((node) => {
+ const ref = node.getAttribute("data-pin-ref");
+ const pinNets = parsePinNets(node);
+
+ let on = true;
+ if (isolateByComp) {
+ on = selectedRefs.has(ref);
+ } else if (isolateByNet) {
+ on = pinNets.includes(state.selectedNet);
+ } else if (activeNets) {
+ on = pinNets.some((n) => activeNets.has(n));
+ }
+
+ if (state.selectedPin && state.selectedPin.ref === ref && state.selectedPin.pin === node.getAttribute("data-pin-name")) {
+ node.setAttribute("r", "4.2");
+ node.setAttribute("fill", "#155eef");
+ node.style.opacity = "1";
+ } else {
+ node.setAttribute("r", "3.2");
+ node.setAttribute("fill", "#111827");
+ node.style.opacity = on ? "1" : activeNets || isolateByNet || isolateByComp ? "0.16" : "1";
+ }
+ });
+
+ setLabelLayerVisibility();
+}
+
+function flashElements(selector) {
+ const nodes = [...el.canvasInner.querySelectorAll(selector)];
+ for (const n of nodes) {
+ n.classList.add("flash");
+ }
+ setTimeout(() => {
+ for (const n of nodes) {
+ n.classList.remove("flash");
+ }
+ }, 1200);
+}
+
+function focusIssue(issueId) {
+ const target = state.compile?.focus_map?.[issueId];
+ if (!target) {
+ return;
+ }
+
+ if (target.type === "net" && target.net) {
+ state.selectedNet = target.net;
+ setSelectedRefs([]);
+ state.selectedPin = null;
+ renderAll();
+ flashElements(`[data-net="${target.net}"], [data-net-label="${target.net}"], [data-net-junction="${target.net}"], [data-net-tie="${target.net}"]`);
+ } else if (target.type === "component" && target.ref) {
+ selectSingleRef(target.ref);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ renderAll();
+ flashElements(`[data-ref="${target.ref}"]`);
+ } else if (target.type === "pin" && target.ref && target.pin) {
+ selectSingleRef(target.ref);
+ state.selectedNet = null;
+ state.selectedPin = {
+ ref: target.ref,
+ pin: target.pin,
+ nets: []
+ };
+ renderAll();
+ flashElements(`[data-pin-ref="${target.ref}"][data-pin-name="${target.pin}"]`);
+ }
+
+ if (target.bbox) {
+ zoomToBBox(target.bbox);
+ }
+}
+
+function showPinTooltip(clientX, clientY, ref, pin, nets) {
+ el.pinTooltip.innerHTML = `${ref}.${pin}
${nets.length ? `Net: ${nets.join(", ")}` : "Net: (unconnected)"}`;
+ el.pinTooltip.classList.remove("hidden");
+ const rect = el.canvasViewport.getBoundingClientRect();
+ el.pinTooltip.style.left = `${clientX - rect.left + 14}px`;
+ el.pinTooltip.style.top = `${clientY - rect.top + 14}px`;
+}
+
+function hidePinTooltip() {
+ el.pinTooltip.classList.add("hidden");
+}
+
+function bindSvgInteractions() {
+ const svg = el.canvasInner.querySelector("svg");
+ if (!svg) {
+ return;
+ }
+
+ svg.querySelectorAll("[data-ref]").forEach((node) => {
+ node.addEventListener("pointerdown", (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+ const ref = node.getAttribute("data-ref");
+ if (!ref || !state.model) {
+ return;
+ }
+
+ if (hasSelectionModifier(evt)) {
+ toggleSelectedRef(ref);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ renderAll();
+ return;
+ }
+
+ if (!state.selectedRefs.includes(ref)) {
+ selectSingleRef(ref);
+ }
+ state.selectedNet = null;
+ state.selectedPin = null;
+ renderInstances();
+ renderNets();
+ renderSelected();
+ applyVisualHighlight();
+
+ const inst = state.model.instances.find((x) => x.ref === ref);
+ if (!inst) {
+ return;
+ }
+
+ const pt = canvasToSvgPoint(evt.clientX, evt.clientY);
+ const dragRefs = state.selectedRefs.length ? [...state.selectedRefs] : [ref];
+ const baseByRef = {};
+ for (const r of dragRefs) {
+ const ii = state.model.instances.find((x) => x.ref === r);
+ if (!ii) {
+ continue;
+ }
+ baseByRef[r] = {
+ x: Number(ii.placement.x ?? 0),
+ y: Number(ii.placement.y ?? 0)
+ };
+ }
+
+ state.draggingComponentRef = ref;
+ state.dragPointerId = evt.pointerId;
+ state.dragComponent = {
+ startPointerX: pt.x,
+ startPointerY: pt.y,
+ refs: dragRefs,
+ baseByRef,
+ pendingByRef: clone(baseByRef)
+ };
+ state.dragPreviewNode = node;
+ state.dragMoved = false;
+
+ node.setPointerCapture(evt.pointerId);
+ });
+ });
+
+ svg.querySelectorAll("[data-net], [data-net-label], [data-net-junction], [data-net-tie]").forEach((node) => {
+ node.addEventListener("click", (evt) => {
+ evt.stopPropagation();
+ const net =
+ node.getAttribute("data-net") ??
+ node.getAttribute("data-net-label") ??
+ node.getAttribute("data-net-junction") ??
+ node.getAttribute("data-net-tie");
+ state.selectedNet = net;
+ setSelectedRefs([]);
+ state.selectedPin = null;
+ renderAll();
+ });
+ });
+
+ svg.querySelectorAll("[data-pin-ref]").forEach((node) => {
+ node.addEventListener("mouseenter", (evt) => {
+ const ref = node.getAttribute("data-pin-ref");
+ const pin = node.getAttribute("data-pin-name");
+ const nets = parsePinNets(node);
+ showPinTooltip(evt.clientX, evt.clientY, ref, pin, nets);
+ });
+
+ node.addEventListener("mousemove", (evt) => {
+ const ref = node.getAttribute("data-pin-ref");
+ const pin = node.getAttribute("data-pin-name");
+ const nets = parsePinNets(node);
+ showPinTooltip(evt.clientX, evt.clientY, ref, pin, nets);
+ });
+
+ node.addEventListener("mouseleave", hidePinTooltip);
+
+ node.addEventListener("click", (evt) => {
+ evt.stopPropagation();
+ const ref = node.getAttribute("data-pin-ref");
+ const pin = node.getAttribute("data-pin-name");
+ const nets = parsePinNets(node);
+ state.selectedPin = { ref, pin, nets };
+ selectSingleRef(ref);
+ state.selectedNet = null;
+ renderAll();
+ });
+ });
+}
+
+function renderCanvas() {
+ if (!state.compile?.svg) {
+ el.canvasInner.innerHTML = "";
+ return;
+ }
+
+ el.canvasInner.innerHTML = state.compile.svg;
+ bindSvgInteractions();
+ applyVisualHighlight();
+ updateTransform();
+}
+
+function renderAll() {
+ renderInstances();
+ renderNets();
+ renderSelected();
+ renderIssues();
+ renderTopology();
+ renderCanvas();
+
+ el.isolateNetBtn.classList.toggle("activeChip", state.isolateNet);
+ el.isolateComponentBtn.classList.toggle("activeChip", state.isolateComponent);
+}
+
+async function compileModel(model, opts = {}) {
+ if (state.compileDebounceId != null) {
+ clearTimeout(state.compileDebounceId);
+ state.compileDebounceId = null;
+ }
+ const source = opts.source ?? "manual";
+ const fit = opts.fit ?? false;
+ const keepView = opts.keepView ?? false;
+
+ setStatus(source === "drag" ? "Compiling after drag..." : "Compiling...");
+ try {
+ const result = await apiPost("/compile", {
+ payload: model,
+ options: compileOptions()
+ });
+
+ state.model = applyCompileLayoutToModel(model, result);
+ state.compile = result;
+ reconcileSelectionWithModel();
+ refreshJsonEditor();
+ saveSnapshot();
+ renderAll();
+
+ if (fit) {
+ fitView(result.layout);
+ } else if (!keepView && !state.userAdjustedView) {
+ fitView(result.layout);
+ }
+
+ const m = result.layout_metrics;
+ setStatus(
+ `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings} crossings, ${m.overlap_edges} overlaps)`
+ );
+ } catch (err) {
+ setStatus(`Compile failed: ${err.message}`, false);
+ el.issues.textContent = `Compile error: ${err.message}`;
+ }
+}
+
+function clearDragPreview() {
+ if (state.dragComponent?.refs?.length) {
+ for (const ref of state.dragComponent.refs) {
+ const n = el.canvasInner.querySelector(`[data-ref="${ref}"]`);
+ if (n) {
+ n.removeAttribute("transform");
+ }
+ }
+ } else if (state.dragPreviewNode) {
+ state.dragPreviewNode.removeAttribute("transform");
+ }
+ state.dragPreviewNode = null;
+}
+
+function queueCompile(keepView = true, source = "edit") {
+ if (!state.model) {
+ return;
+ }
+ if (state.compileDebounceId != null) {
+ clearTimeout(state.compileDebounceId);
+ }
+ state.compileDebounceId = setTimeout(() => {
+ state.compileDebounceId = null;
+ compileModel(state.model, { keepView, source });
+ }, 150);
+}
+
+function updateInstance(ref, patch) {
+ const inst = instanceByRef(ref);
+ if (!inst) {
+ return false;
+ }
+ Object.assign(inst, patch);
+ return true;
+}
+
+function setPinUi(ref, pinName, patch) {
+ const inst = instanceByRef(ref);
+ if (!inst) {
+ return false;
+ }
+ inst.properties = inst.properties ?? {};
+ const pinUiMap =
+ inst.properties.pin_ui && typeof inst.properties.pin_ui === "object" && !Array.isArray(inst.properties.pin_ui)
+ ? inst.properties.pin_ui
+ : {};
+ const pinUiEntry =
+ pinUiMap[pinName] && typeof pinUiMap[pinName] === "object" && !Array.isArray(pinUiMap[pinName]) ? pinUiMap[pinName] : {};
+ pinUiMap[pinName] = {
+ ...pinUiEntry,
+ ...patch
+ };
+ inst.properties.pin_ui = pinUiMap;
+ return true;
+}
+
+function hasNodeOnNet(net, ref, pin) {
+ return (net.nodes ?? []).some((n) => n.ref === ref && n.pin === pin);
+}
+
+function connectPinToNet(ref, pin, netName, opts = {}) {
+ if (!state.model) {
+ return { ok: false, message: "No model loaded." };
+ }
+ const inst = instanceByRef(ref);
+ if (!inst) {
+ return { ok: false, message: `Unknown component '${ref}'.` };
+ }
+ if (!pinExists(ref, pin)) {
+ const sym = symbolForRef(ref);
+ const category = String(sym?.category ?? "").toLowerCase();
+ const genericLike = sym?.auto_generated === true || category.includes("generic") || inst.part === "generic";
+ if (!genericLike) {
+ return { ok: false, message: `Unknown pin ${ref}.${pin}.` };
+ }
+ }
+ const normalized = normalizeNetName(netName);
+ if (!normalized) {
+ return { ok: false, message: "Net name is required." };
+ }
+ let net = netByName(normalized);
+ if (!net) {
+ const guessedClass = opts.netClass && NET_CLASSES.includes(opts.netClass) ? opts.netClass : inferClassForPin(ref, pin);
+ net = {
+ name: normalized,
+ class: guessedClass,
+ nodes: []
+ };
+ state.model.nets.push(net);
+ }
+
+ if (!hasNodeOnNet(net, ref, pin)) {
+ net.nodes.push({ ref, pin });
+ }
+ return { ok: true, net: normalized };
+}
+
+function removeNetIfOrphaned(netName) {
+ if (!state.model) {
+ return;
+ }
+ const net = netByName(netName);
+ if (!net) {
+ return;
+ }
+ if ((net.nodes?.length ?? 0) < 2) {
+ state.model.nets = state.model.nets.filter((n) => n.name !== netName);
+ if (state.selectedNet === netName) {
+ state.selectedNet = null;
+ }
+ }
+}
+
+function disconnectPinFromNet(ref, pin, netName) {
+ const net = netByName(netName);
+ if (!net) {
+ return { ok: false, message: `Net '${netName}' not found.` };
+ }
+ const before = net.nodes.length;
+ net.nodes = net.nodes.filter((n) => !(n.ref === ref && n.pin === pin));
+ if (net.nodes.length === before) {
+ return { ok: false, message: `${ref}.${pin} is not connected to ${netName}.` };
+ }
+ removeNetIfOrphaned(netName);
+ return { ok: true };
+}
+
+function renameNet(oldName, newName) {
+ if (!state.model) {
+ return { ok: false, message: "No model loaded." };
+ }
+ const current = netByName(oldName);
+ if (!current) {
+ return { ok: false, message: `Net '${oldName}' not found.` };
+ }
+ const normalized = normalizeNetName(newName);
+ if (!normalized) {
+ return { ok: false, message: "Net name is required." };
+ }
+ if (normalized !== oldName && netByName(normalized)) {
+ return { ok: false, message: `Net '${normalized}' already exists.` };
+ }
+ current.name = normalized;
+ if (state.selectedNet === oldName) {
+ state.selectedNet = normalized;
+ }
+ return { ok: true, name: normalized };
+}
+
+function setNetClass(netName, netClass) {
+ const net = netByName(netName);
+ if (!net) {
+ return { ok: false, message: `Net '${netName}' not found.` };
+ }
+ if (!NET_CLASSES.includes(netClass)) {
+ return { ok: false, message: `Invalid net class '${netClass}'.` };
+ }
+ net.class = netClass;
+ return { ok: true };
+}
+
+function isTypingContext(target) {
+ if (!target || !(target instanceof HTMLElement)) {
+ return false;
+ }
+ const tag = target.tagName.toLowerCase();
+ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
+}
+
+function parseJsonPositionError(text, err) {
+ const msg = String(err?.message ?? "Invalid JSON");
+ const m = /position\s+(\d+)/i.exec(msg);
+ if (!m) {
+ return { message: msg, line: null, col: null };
+ }
+
+ const pos = Number(m[1]);
+ let line = 1;
+ let col = 1;
+ for (let i = 0; i < Math.min(pos, text.length); i += 1) {
+ if (text[i] === "\n") {
+ line += 1;
+ col = 1;
+ } else {
+ col += 1;
+ }
+ }
+
+ return { message: msg, line, col };
+}
+
+function sortKeysDeep(value) {
+ if (Array.isArray(value)) {
+ return value.map(sortKeysDeep);
+ }
+ if (value && typeof value === "object") {
+ const out = {};
+ for (const key of Object.keys(value).sort()) {
+ out[key] = sortKeysDeep(value[key]);
+ }
+ return out;
+ }
+ return value;
+}
+
+async function loadSchemaText() {
+ if (state.schemaText) {
+ return state.schemaText;
+ }
+
+ const res = await fetch(SCHEMA_URL);
+ if (!res.ok) {
+ throw new Error("Schema file unavailable.");
+ }
+
+ const text = await res.text();
+ state.schemaText = text;
+ return text;
+}
+
+async function openSchemaModal() {
+ try {
+ const raw = await loadSchemaText();
+ let pretty = raw;
+ try {
+ pretty = JSON.stringify(JSON.parse(raw), null, 2);
+ } catch {}
+ el.schemaViewer.value = pretty;
+ el.schemaModal.classList.remove("hidden");
+ el.schemaViewer.focus();
+ el.jsonFeedback.textContent = "Schema loaded.";
+ } catch (err) {
+ el.jsonFeedback.textContent = `Schema load failed: ${err.message}`;
+ }
+}
+
+function closeSchemaModal() {
+ el.schemaModal.classList.add("hidden");
+}
+
+function buildMinimalRepro(model) {
+ if (!state.selectedRefs.length && !state.selectedNet) {
+ return model;
+ }
+
+ const refs = new Set();
+ const nets = [];
+
+ if (state.selectedNet) {
+ const net = model.nets.find((n) => n.name === state.selectedNet);
+ if (net) {
+ nets.push(net);
+ net.nodes.forEach((n) => refs.add(n.ref));
+ }
+ }
+
+ if (state.selectedRefs.length) {
+ for (const ref of state.selectedRefs) {
+ refs.add(ref);
+ for (const net of model.nets) {
+ if (net.nodes.some((n) => n.ref === ref)) {
+ nets.push(net);
+ net.nodes.forEach((n) => refs.add(n.ref));
+ }
+ }
+ }
+ }
+
+ const uniqNet = new Map(nets.map((n) => [n.name, n]));
+ const instances = model.instances.filter((i) => refs.has(i.ref));
+ const symbols = {};
+ for (const inst of instances) {
+ symbols[inst.symbol] = model.symbols[inst.symbol];
+ }
+
+ return {
+ meta: {
+ ...(model.meta ?? {}),
+ title: `${(model.meta?.title ?? "schemeta")} - minimal repro`
+ },
+ symbols,
+ instances,
+ nets: [...uniqNet.values()],
+ constraints: {},
+ annotations: []
+ };
+}
+
+function summarizeModelDelta(before, after) {
+ if (!before) {
+ return "Applied JSON (new project loaded).";
+ }
+
+ const beforeRefs = new Set((before.instances ?? []).map((i) => i.ref));
+ const afterRefs = new Set((after.instances ?? []).map((i) => i.ref));
+ const beforeNets = new Set((before.nets ?? []).map((n) => n.name));
+ const afterNets = new Set((after.nets ?? []).map((n) => n.name));
+ const beforeSymbols = new Set(Object.keys(before.symbols ?? {}));
+ const afterSymbols = new Set(Object.keys(after.symbols ?? {}));
+
+ const countOnlyIn = (a, b) => [...a].filter((x) => !b.has(x)).length;
+ const instAdded = countOnlyIn(afterRefs, beforeRefs);
+ const instRemoved = countOnlyIn(beforeRefs, afterRefs);
+ const netAdded = countOnlyIn(afterNets, beforeNets);
+ const netRemoved = countOnlyIn(beforeNets, afterNets);
+ const symAdded = countOnlyIn(afterSymbols, beforeSymbols);
+ const symRemoved = countOnlyIn(beforeSymbols, afterSymbols);
+
+ const beforeByRef = new Map((before.instances ?? []).map((i) => [i.ref, i]));
+ let moved = 0;
+ for (const inst of after.instances ?? []) {
+ const prev = beforeByRef.get(inst.ref);
+ if (!prev) {
+ continue;
+ }
+ const px = Number(prev.placement?.x ?? 0);
+ const py = Number(prev.placement?.y ?? 0);
+ const nx = Number(inst.placement?.x ?? 0);
+ const ny = Number(inst.placement?.y ?? 0);
+ if (px !== nx || py !== ny || Boolean(prev.placement?.locked) !== Boolean(inst.placement?.locked)) {
+ moved += 1;
+ }
+ }
+
+ const beforeNetByName = new Map((before.nets ?? []).map((n) => [n.name, n]));
+ let netChanged = 0;
+ for (const net of after.nets ?? []) {
+ const prev = beforeNetByName.get(net.name);
+ if (!prev) {
+ continue;
+ }
+ const prevSig = JSON.stringify({
+ class: prev.class,
+ nodes: [...(prev.nodes ?? [])].map((n) => `${n.ref}.${n.pin}`).sort()
+ });
+ const nextSig = JSON.stringify({
+ class: net.class,
+ nodes: [...(net.nodes ?? [])].map((n) => `${n.ref}.${n.pin}`).sort()
+ });
+ if (prevSig !== nextSig) {
+ netChanged += 1;
+ }
+ }
+
+ const parts = [];
+ if (instAdded || instRemoved || moved) {
+ parts.push(`instances +${instAdded}/-${instRemoved}, moved ${moved}`);
+ }
+ if (netAdded || netRemoved || netChanged) {
+ parts.push(`nets +${netAdded}/-${netRemoved}, changed ${netChanged}`);
+ }
+ if (symAdded || symRemoved) {
+ parts.push(`symbols +${symAdded}/-${symRemoved}`);
+ }
+
+ return parts.length ? `Applied JSON (${parts.join(" | ")}).` : "Applied JSON (no structural changes).";
+}
+
+async function validateJsonEditor() {
+ const text = el.jsonEditor.value;
+ try {
+ const parsed = JSON.parse(text);
+ const out = await apiPost("/analyze", { payload: parsed });
+ el.jsonFeedback.textContent = `Validation: ${out.errors.length} errors, ${out.warnings.length} warnings.`;
+ if (!out.errors.length && !out.warnings.length) {
+ return;
+ }
+
+ const first = out.errors[0] ?? out.warnings[0];
+ el.jsonFeedback.textContent += ` First issue: ${first.message}`;
+ } catch (err) {
+ const p = parseJsonPositionError(text, err);
+ if (p.line != null) {
+ el.jsonFeedback.textContent = `JSON parse error at line ${p.line}, col ${p.col}: ${p.message}`;
+ const lines = text.split("\n");
+ let idx = 0;
+ for (let i = 0; i < p.line - 1; i += 1) {
+ idx += lines[i].length + 1;
+ }
+ el.jsonEditor.focus();
+ el.jsonEditor.setSelectionRange(idx, idx + Math.max(1, lines[p.line - 1]?.length ?? 1));
+ } else {
+ el.jsonFeedback.textContent = `JSON parse error: ${p.message}`;
+ }
+ }
+}
+
+async function runLayoutAction(path) {
+ if (!state.model) {
+ return;
+ }
+
+ setStatus(path.includes("auto") ? "Auto layout..." : "Auto tidy...");
+ try {
+ const out = await apiPost(path, {
+ payload: state.model,
+ options: compileOptions()
+ });
+
+ state.model = applyCompileLayoutToModel(out.model, out.compile);
+ state.compile = out.compile;
+ refreshJsonEditor();
+ renderAll();
+ fitView(out.compile.layout);
+ saveSnapshot();
+ setStatus(
+ `Compiled (${out.compile.errors.length}E, ${out.compile.warnings.length}W | ${out.compile.layout_metrics.crossings} crossings)`
+ );
+ } catch (err) {
+ setStatus(`Layout action failed: ${err.message}`, false);
+ }
+}
+
+async function loadSample() {
+ const res = await fetch("/sample.schemeta.json");
+ if (!res.ok) {
+ setStatus("Sample missing.", false);
+ return;
+ }
+
+ const model = await res.json();
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ await compileModel(model, { fit: true });
+}
+
+function setupEvents() {
+ el.instanceFilter.addEventListener("input", renderInstances);
+ el.netFilter.addEventListener("input", renderNets);
+
+ el.instanceList.addEventListener("click", (evt) => {
+ const item = evt.target.closest("[data-ref-item]");
+ if (!item) {
+ return;
+ }
+ const ref = item.getAttribute("data-ref-item");
+ if (hasSelectionModifier(evt)) {
+ toggleSelectedRef(ref);
+ } else {
+ selectSingleRef(ref);
+ }
+ state.selectedNet = null;
+ state.selectedPin = null;
+ renderAll();
+ });
+
+ el.netList.addEventListener("click", (evt) => {
+ const item = evt.target.closest("[data-net-item]");
+ if (!item) {
+ return;
+ }
+ state.selectedNet = item.getAttribute("data-net-item");
+ setSelectedRefs([]);
+ state.selectedPin = null;
+ renderAll();
+ });
+
+ el.issues.addEventListener("click", (evt) => {
+ const row = evt.target.closest("[data-issue-id]");
+ if (!row) {
+ return;
+ }
+ focusIssue(row.getAttribute("data-issue-id"));
+ });
+
+ el.updatePlacementBtn.addEventListener("click", async () => {
+ if (state.selectedRefs.length !== 1) {
+ el.jsonFeedback.textContent = "Select one component to edit.";
+ return;
+ }
+ const inst = instanceByRef(state.selectedRef);
+ if (!inst) {
+ return;
+ }
+
+ const nextRef = normalizeRef(el.instRefInput.value);
+ if (!nextRef) {
+ el.jsonFeedback.textContent = "Ref cannot be empty.";
+ return;
+ }
+ if (nextRef !== inst.ref && instanceByRef(nextRef)) {
+ el.jsonFeedback.textContent = `Ref '${nextRef}' already exists.`;
+ return;
+ }
+
+ const oldRef = inst.ref;
+ updateInstance(oldRef, {
+ ref: nextRef,
+ placement: {
+ ...inst.placement,
+ x: toGrid(Number(el.xInput.value)),
+ y: toGrid(Number(el.yInput.value)),
+ rotation: Number(el.rotationInput.value),
+ locked: el.lockedInput.checked
+ },
+ properties: {
+ ...(inst.properties ?? {}),
+ value: el.instValueInput.value,
+ notes: el.instNotesInput.value
+ }
+ });
+
+ if (oldRef !== nextRef) {
+ for (const net of state.model.nets ?? []) {
+ for (const node of net.nodes ?? []) {
+ if (node.ref === oldRef) {
+ node.ref = nextRef;
+ }
+ }
+ }
+ if (state.selectedPin?.ref === oldRef) {
+ state.selectedPin.ref = nextRef;
+ }
+ selectSingleRef(nextRef);
+ }
+
+ el.jsonFeedback.textContent = "Component updated.";
+ queueCompile(true, "component-edit");
+ });
+
+ el.rotateSelectedBtn.addEventListener("click", () => {
+ if (state.selectedRefs.length !== 1 || !state.selectedRef) {
+ return;
+ }
+ const inst = instanceByRef(state.selectedRef);
+ if (!inst) {
+ return;
+ }
+ const current = Number(inst.placement.rotation ?? 0);
+ inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360;
+ inst.placement.locked = true;
+ renderSelected();
+ el.jsonFeedback.textContent = "Component rotated.";
+ queueCompile(true, "rotate");
+ });
+
+ el.showPinNetLabelInput.addEventListener("change", () => {
+ if (!state.selectedPin) {
+ return;
+ }
+ const { ref, pin } = state.selectedPin;
+ if (!setPinUi(ref, pin, { show_net_label: el.showPinNetLabelInput.checked })) {
+ return;
+ }
+ el.jsonFeedback.textContent = `Updated ${ref}.${pin} label visibility.`;
+ queueCompile(true, "pin-ui");
+ });
+
+ el.applyPinPropsBtn.addEventListener("click", () => {
+ if (!state.selectedPin) {
+ return;
+ }
+ const { ref, pin: oldPinName } = state.selectedPin;
+ const inst = instanceByRef(ref);
+ const sym = symbolForRef(ref);
+ if (!inst || !sym) {
+ return;
+ }
+ const idx = (sym.pins ?? []).findIndex((p) => p.name === oldPinName);
+ if (idx < 0) {
+ el.jsonFeedback.textContent = `Pin ${ref}.${oldPinName} not found on symbol.`;
+ return;
+ }
+
+ const newName = String(el.pinNameInput.value ?? "").trim();
+ const newNumber = String(el.pinNumberInput.value ?? "").trim() || String(idx + 1);
+ const newSide = el.pinSideInput.value;
+ const newType = el.pinTypeInput.value;
+ const newOffset = Number(el.pinOffsetInput.value);
+ if (!newName) {
+ el.jsonFeedback.textContent = "Pin name cannot be empty.";
+ return;
+ }
+ if (!PIN_SIDES.includes(newSide) || !PIN_TYPES.includes(newType) || !Number.isFinite(newOffset) || newOffset < 0) {
+ el.jsonFeedback.textContent = "Invalid pin side/type/offset.";
+ return;
+ }
+ const duplicate = (sym.pins ?? []).some((p, i) => i !== idx && p.name === newName);
+ if (duplicate) {
+ el.jsonFeedback.textContent = `Pin name '${newName}' already exists on symbol '${inst.symbol}'.`;
+ return;
+ }
+
+ const beforeName = sym.pins[idx].name;
+ sym.pins[idx] = {
+ ...sym.pins[idx],
+ name: newName,
+ number: newNumber,
+ side: newSide,
+ type: newType,
+ offset: Math.round(newOffset)
+ };
+ renamePinAcrossSymbolInstances(inst.symbol, beforeName, newName);
+ state.selectedPin = { ...state.selectedPin, pin: newName };
+ el.jsonFeedback.textContent = `Updated pin ${ref}.${newName}.`;
+ queueCompile(true, "pin-props");
+ });
+
+ el.connectPinBtn.addEventListener("click", () => {
+ if (!state.selectedPin) {
+ return;
+ }
+ const netName = el.pinNetSelect.value;
+ if (!netName) {
+ el.jsonFeedback.textContent = "Choose a net first.";
+ return;
+ }
+ const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, netName);
+ if (!out.ok) {
+ el.jsonFeedback.textContent = out.message;
+ return;
+ }
+ state.selectedNet = out.net;
+ el.jsonFeedback.textContent = `Connected ${state.selectedPin.ref}.${state.selectedPin.pin} to ${out.net}.`;
+ queueCompile(true, "connect-pin");
+ });
+
+ el.createConnectNetBtn.addEventListener("click", () => {
+ if (!state.selectedPin) {
+ return;
+ }
+ const name = normalizeNetName(el.newNetNameInput.value || el.newNetNameInput.placeholder || nextAutoNetName());
+ const cls = el.newNetClassInput.value;
+ const out = connectPinToNet(state.selectedPin.ref, state.selectedPin.pin, name, { netClass: cls });
+ if (!out.ok) {
+ el.jsonFeedback.textContent = out.message;
+ return;
+ }
+ state.selectedNet = out.net;
+ el.newNetNameInput.value = "";
+ el.jsonFeedback.textContent = `Created and connected net ${out.net}.`;
+ queueCompile(true, "create-net");
+ });
+
+ el.pinConnections.addEventListener("click", (evt) => {
+ const btn = evt.target.closest("[data-disconnect-net]");
+ if (!btn || !state.selectedPin) {
+ return;
+ }
+ const netName = btn.getAttribute("data-disconnect-net");
+ const out = disconnectPinFromNet(state.selectedPin.ref, state.selectedPin.pin, netName);
+ if (!out.ok) {
+ el.jsonFeedback.textContent = out.message;
+ return;
+ }
+ el.jsonFeedback.textContent = `Disconnected ${state.selectedPin.ref}.${state.selectedPin.pin} from ${netName}.`;
+ queueCompile(true, "disconnect-pin");
+ });
+
+ el.updateNetBtn.addEventListener("click", () => {
+ if (!state.selectedNet) {
+ return;
+ }
+ const oldName = state.selectedNet;
+ const renamed = renameNet(oldName, el.netNameInput.value);
+ if (!renamed.ok) {
+ el.jsonFeedback.textContent = renamed.message;
+ return;
+ }
+ const clsOut = setNetClass(renamed.name, el.netClassInput.value);
+ if (!clsOut.ok) {
+ el.jsonFeedback.textContent = clsOut.message;
+ return;
+ }
+ state.selectedNet = renamed.name;
+ el.jsonFeedback.textContent = `Updated net ${renamed.name}.`;
+ queueCompile(true, "net-edit");
+ });
+
+ el.addNetNodeBtn.addEventListener("click", () => {
+ if (!state.selectedNet) {
+ return;
+ }
+ const ref = normalizeRef(el.netNodeRefInput.value);
+ const pin = String(el.netNodePinInput.value ?? "").trim();
+ if (!ref || !pin) {
+ el.jsonFeedback.textContent = "Provide both ref and pin.";
+ return;
+ }
+ const out = connectPinToNet(ref, pin, state.selectedNet);
+ if (!out.ok) {
+ el.jsonFeedback.textContent = out.message;
+ return;
+ }
+ el.netNodePinInput.value = "";
+ el.jsonFeedback.textContent = `Added ${ref}.${pin} to ${state.selectedNet}.`;
+ queueCompile(true, "net-node-add");
+ });
+
+ el.netNodesList.addEventListener("click", (evt) => {
+ const btn = evt.target.closest("[data-remove-node]");
+ if (!btn || !state.selectedNet) {
+ return;
+ }
+ const netName = state.selectedNet;
+ const [ref, ...pinParts] = String(btn.getAttribute("data-remove-node")).split(".");
+ const pin = pinParts.join(".");
+ const out = disconnectPinFromNet(ref, pin, netName);
+ if (!out.ok) {
+ el.jsonFeedback.textContent = out.message;
+ return;
+ }
+ el.jsonFeedback.textContent = `Removed ${ref}.${pin} from ${netName}.`;
+ queueCompile(true, "net-node-remove");
+ });
+
+ el.addSymbolPinBtn.addEventListener("click", () => {
+ const row = {
+ name: `P${el.symbolPinsList.querySelectorAll(".symbolPinRow").length + 1}`,
+ number: `${el.symbolPinsList.querySelectorAll(".symbolPinRow").length + 1}`,
+ side: "left",
+ offset: 20,
+ type: "passive"
+ };
+ el.symbolPinsList.insertAdjacentHTML("beforeend", symbolPinRowHtml(row));
+ });
+
+ el.symbolPinsList.addEventListener("click", (evt) => {
+ const btn = evt.target.closest("[data-remove-symbol-pin]");
+ if (!btn) {
+ return;
+ }
+ const row = btn.closest(".symbolPinRow");
+ if (row) {
+ row.remove();
+ }
+ });
+
+ el.applySymbolBtn.addEventListener("click", () => {
+ if (!state.selectedRef) {
+ return;
+ }
+ const inst = instanceByRef(state.selectedRef);
+ const sym = symbolForRef(state.selectedRef);
+ if (!inst || !sym) {
+ return;
+ }
+ const nextCategory = String(el.symbolCategoryInput.value ?? "").trim() || String(sym.category ?? "generic");
+ const nextWidth = Number(el.symbolWidthInput.value);
+ const nextHeight = Number(el.symbolHeightInput.value);
+ if (!Number.isFinite(nextWidth) || !Number.isFinite(nextHeight) || nextWidth < 20 || nextHeight < 20) {
+ el.jsonFeedback.textContent = "Symbol width/height must be >= 20.";
+ return;
+ }
+
+ const rows = [...el.symbolPinsList.querySelectorAll(".symbolPinRow")];
+ if (!rows.length) {
+ el.jsonFeedback.textContent = "Symbol must have at least one pin.";
+ return;
+ }
+
+ const parsedPins = [];
+ for (const row of rows) {
+ const name = String(row.querySelector(".pinName")?.value ?? "").trim();
+ const number = String(row.querySelector(".pinNumber")?.value ?? "").trim();
+ const side = String(row.querySelector(".pinSide")?.value ?? "");
+ const offset = Number(row.querySelector(".pinOffset")?.value ?? 0);
+ const type = String(row.querySelector(".pinType")?.value ?? "");
+ if (!name || !number || !PIN_SIDES.includes(side) || !PIN_TYPES.includes(type) || !Number.isFinite(offset) || offset < 0) {
+ el.jsonFeedback.textContent = "Invalid symbol pin row values.";
+ return;
+ }
+ parsedPins.push({
+ oldName: row.getAttribute("data-old-pin") ?? name,
+ pin: { name, number, side, offset: Math.round(offset), type }
+ });
+ }
+
+ const unique = new Set(parsedPins.map((p) => p.pin.name));
+ if (unique.size !== parsedPins.length) {
+ el.jsonFeedback.textContent = "Duplicate pin names are not allowed.";
+ return;
+ }
+
+ for (const entry of parsedPins) {
+ if (entry.oldName && entry.oldName !== entry.pin.name) {
+ renamePinAcrossSymbolInstances(inst.symbol, entry.oldName, entry.pin.name);
+ }
+ }
+ sym.category = nextCategory;
+ sym.body = {
+ ...(sym.body ?? {}),
+ width: Math.round(nextWidth),
+ height: Math.round(nextHeight)
+ };
+ sym.pins = parsedPins.map((p) => p.pin);
+ const allowedPins = new Set(sym.pins.map((p) => p.name));
+ const refs = new Set((state.model.instances ?? []).filter((i) => i.symbol === inst.symbol).map((i) => i.ref));
+ for (const net of state.model.nets ?? []) {
+ net.nodes = (net.nodes ?? []).filter((node) => !refs.has(node.ref) || allowedPins.has(node.pin));
+ }
+ state.model.nets = (state.model.nets ?? []).filter((net) => (net.nodes ?? []).length >= 2);
+
+ if (state.selectedPin && !pinExists(state.selectedPin.ref, state.selectedPin.pin)) {
+ state.selectedPin = null;
+ }
+ el.jsonFeedback.textContent = `Updated symbol ${inst.symbol}.`;
+ queueCompile(true, "symbol-edit");
+ });
+
+ el.zoomInBtn.addEventListener("click", () => {
+ state.scale = Math.min(4, state.scale + 0.1);
+ state.userAdjustedView = true;
+ updateTransform();
+ });
+
+ el.zoomOutBtn.addEventListener("click", () => {
+ state.scale = Math.max(0.2, state.scale - 0.1);
+ state.userAdjustedView = true;
+ updateTransform();
+ });
+
+ el.zoomResetBtn.addEventListener("click", () => {
+ state.scale = 1;
+ state.panX = 40;
+ state.panY = 40;
+ state.userAdjustedView = true;
+ updateTransform();
+ });
+
+ el.fitViewBtn.addEventListener("click", () => {
+ if (state.compile?.layout) {
+ fitView(state.compile.layout);
+ }
+ });
+
+ el.showLabelsInput.addEventListener("change", () => {
+ state.showLabels = el.showLabelsInput.checked;
+ setLabelLayerVisibility();
+ });
+
+ el.renderModeSelect.addEventListener("change", async () => {
+ state.renderMode = el.renderModeSelect.value;
+ if (state.model) {
+ await compileModel(state.model, { keepView: true });
+ }
+ });
+
+ el.isolateNetBtn.addEventListener("click", () => {
+ state.isolateNet = !state.isolateNet;
+ renderAll();
+ });
+
+ el.isolateComponentBtn.addEventListener("click", () => {
+ state.isolateComponent = !state.isolateComponent;
+ renderAll();
+ });
+
+ el.canvasViewport.addEventListener(
+ "wheel",
+ (evt) => {
+ evt.preventDefault();
+ const oldScale = state.scale;
+ state.scale = Math.min(4, Math.max(0.2, state.scale + (evt.deltaY < 0 ? 0.08 : -0.08)));
+
+ const rect = el.canvasViewport.getBoundingClientRect();
+ const px = evt.clientX - rect.left;
+ const py = evt.clientY - rect.top;
+
+ state.panX = px - (px - state.panX) * (state.scale / oldScale);
+ state.panY = py - (py - state.panY) * (state.scale / oldScale);
+ state.userAdjustedView = true;
+ updateTransform();
+ },
+ { passive: false }
+ );
+
+ el.canvasViewport.addEventListener("pointerdown", (evt) => {
+ const interactive = evt.target.closest(
+ "[data-ref], [data-net], [data-net-label], [data-net-junction], [data-net-tie], [data-pin-ref]"
+ );
+
+ if (!interactive && evt.button === 0 && !state.spacePan) {
+ beginBoxSelection(evt.clientX, evt.clientY);
+ return;
+ }
+
+ const allowPan = evt.button === 1 || (evt.button === 0 && state.spacePan);
+ if (!allowPan) {
+ return;
+ }
+
+ if (state.draggingComponentRef) {
+ return;
+ }
+
+ state.isPanning = true;
+ state.panStartX = evt.clientX;
+ state.panStartY = evt.clientY;
+ state.basePanX = state.panX;
+ state.basePanY = state.panY;
+ el.canvasViewport.classList.add("dragging");
+ });
+
+ el.canvasViewport.addEventListener("click", (evt) => {
+ if (state.suppressCanvasClick) {
+ state.suppressCanvasClick = false;
+ return;
+ }
+
+ const interactive = evt.target.closest(
+ "[data-ref], [data-net], [data-net-label], [data-net-junction], [data-net-tie], [data-pin-ref]"
+ );
+ if (interactive) {
+ return;
+ }
+
+ const hadSelection = Boolean(state.selectedRefs.length || state.selectedNet || state.selectedPin);
+ const hadIsolation = Boolean(state.isolateNet || state.isolateComponent);
+ if (!hadSelection && !hadIsolation) {
+ return;
+ }
+
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ state.isolateNet = false;
+ state.isolateComponent = false;
+ renderAll();
+ });
+
+ window.addEventListener("pointermove", (evt) => {
+ if (state.boxSelecting) {
+ updateBoxSelection(evt.clientX, evt.clientY);
+ return;
+ }
+
+ if (state.draggingComponentRef && state.dragComponent && state.model) {
+ if (state.dragPointerId != null && evt.pointerId !== state.dragPointerId) {
+ return;
+ }
+
+ const pt = canvasToSvgPoint(evt.clientX, evt.clientY);
+ const dx = pt.x - state.dragComponent.startPointerX;
+ const dy = pt.y - state.dragComponent.startPointerY;
+ setSelectedRefs(state.dragComponent.refs ?? [state.draggingComponentRef]);
+ state.selectedPin = null;
+ for (const ref of state.dragComponent.refs ?? []) {
+ const base = state.dragComponent.baseByRef?.[ref];
+ if (!base) {
+ continue;
+ }
+ const nextX = toGrid(base.x + dx);
+ const nextY = toGrid(base.y + dy);
+ const moveX = nextX - base.x;
+ const moveY = nextY - base.y;
+ state.dragComponent.pendingByRef[ref] = { x: nextX, y: nextY };
+ if (moveX !== 0 || moveY !== 0) {
+ state.dragMoved = true;
+ }
+ const n = el.canvasInner.querySelector(`[data-ref="${ref}"]`);
+ if (n) {
+ n.setAttribute("transform", `translate(${moveX} ${moveY})`);
+ }
+ }
+ const primary = state.draggingComponentRef;
+ const p = state.dragComponent.pendingByRef?.[primary];
+ if (p) {
+ el.xInput.value = String(p.x);
+ el.yInput.value = String(p.y);
+ }
+ return;
+ }
+
+ if (!state.isPanning) {
+ return;
+ }
+
+ state.panX = state.basePanX + (evt.clientX - state.panStartX);
+ state.panY = state.basePanY + (evt.clientY - state.panStartY);
+ state.userAdjustedView = true;
+ updateTransform();
+ });
+
+ window.addEventListener("pointerup", async (evt) => {
+ if (state.dragPointerId != null && evt.pointerId !== state.dragPointerId) {
+ return;
+ }
+
+ finishBoxSelection();
+
+ state.isPanning = false;
+ el.canvasViewport.classList.remove("dragging");
+
+ const moved = state.dragMoved;
+ const wasDragging = Boolean(state.draggingComponentRef);
+ const dragSnapshot = state.dragComponent ? clone(state.dragComponent.pendingByRef ?? {}) : null;
+
+ clearDragPreview();
+ state.draggingComponentRef = null;
+ state.dragPointerId = null;
+ state.dragComponent = null;
+ state.dragMoved = false;
+
+ if (wasDragging && moved && state.model) {
+ for (const [ref, pos] of Object.entries(dragSnapshot ?? {})) {
+ const inst = state.model.instances.find((x) => x.ref === ref);
+ if (inst) {
+ inst.placement.x = pos.x;
+ inst.placement.y = pos.y;
+ inst.placement.locked = true;
+ }
+ }
+ await compileModel(state.model, { source: "drag", keepView: true });
+ }
+ });
+
+ window.addEventListener("pointercancel", () => {
+ finishBoxSelection();
+ clearDragPreview();
+ state.draggingComponentRef = null;
+ state.dragPointerId = null;
+ state.dragComponent = null;
+ state.dragMoved = false;
+ state.isPanning = false;
+ el.canvasViewport.classList.remove("dragging");
+ });
+
+ window.addEventListener("keydown", (evt) => {
+ if (evt.code === "Space") {
+ if (isTypingContext(evt.target)) {
+ return;
+ }
+
+ if (state.selectedRefs.length && state.model && !evt.repeat) {
+ for (const ref of state.selectedRefs) {
+ const inst = state.model.instances.find((x) => x.ref === ref);
+ if (!inst) {
+ continue;
+ }
+ const current = Number(inst.placement.rotation ?? 0);
+ inst.placement.rotation = ((Math.round(current / 90) * 90 + 90) % 360 + 360) % 360;
+ inst.placement.locked = true;
+ }
+ compileModel(state.model, { source: "rotate", keepView: true });
+ evt.preventDefault();
+ return;
+ }
+
+ state.spacePan = true;
+ el.canvasViewport.classList.add("dragging");
+ evt.preventDefault();
+ }
+ });
+
+ window.addEventListener("keyup", (evt) => {
+ if (evt.code === "Space") {
+ state.spacePan = false;
+ if (!state.isPanning) {
+ el.canvasViewport.classList.remove("dragging");
+ }
+ }
+ if (evt.code === "Escape") {
+ closeSchemaModal();
+ }
+ });
+
+ el.showSchemaBtn.addEventListener("click", openSchemaModal);
+ el.closeSchemaBtn.addEventListener("click", closeSchemaModal);
+ el.schemaModal.addEventListener("click", (evt) => {
+ if (evt.target === el.schemaModal) {
+ closeSchemaModal();
+ }
+ });
+
+ el.copySchemaBtn.addEventListener("click", async () => {
+ try {
+ const text = el.schemaViewer.value || (await loadSchemaText());
+ await navigator.clipboard.writeText(text);
+ el.jsonFeedback.textContent = "Schema copied.";
+ } catch (err) {
+ el.jsonFeedback.textContent = `Schema copy failed: ${err.message}`;
+ }
+ });
+
+ el.downloadSchemaBtn.addEventListener("click", async () => {
+ try {
+ const text = el.schemaViewer.value || (await loadSchemaText());
+ const blob = new Blob([text], { type: "application/json" });
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = "schemeta.schema.json";
+ document.body.appendChild(a);
+ a.click();
+ URL.revokeObjectURL(a.href);
+ document.body.removeChild(a);
+ el.jsonFeedback.textContent = "Schema downloaded.";
+ } catch (err) {
+ el.jsonFeedback.textContent = `Schema download failed: ${err.message}`;
+ }
+ });
+
+ el.validateJsonBtn.addEventListener("click", validateJsonEditor);
+
+ el.formatJsonBtn.addEventListener("click", () => {
+ try {
+ const parsed = JSON.parse(el.jsonEditor.value);
+ el.jsonEditor.value = JSON.stringify(parsed, null, 2);
+ el.jsonFeedback.textContent = "JSON formatted.";
+ } catch (err) {
+ const p = parseJsonPositionError(el.jsonEditor.value, err);
+ el.jsonFeedback.textContent = `Format failed: ${p.message}`;
+ }
+ });
+
+ el.sortJsonBtn.addEventListener("click", () => {
+ try {
+ const parsed = JSON.parse(el.jsonEditor.value);
+ el.jsonEditor.value = JSON.stringify(sortKeysDeep(parsed), null, 2);
+ el.jsonFeedback.textContent = "Keys sorted recursively.";
+ } catch (err) {
+ const p = parseJsonPositionError(el.jsonEditor.value, err);
+ el.jsonFeedback.textContent = `Sort failed: ${p.message}`;
+ }
+ });
+
+ el.copyReproBtn.addEventListener("click", async () => {
+ try {
+ const model = JSON.parse(el.jsonEditor.value);
+ const repro = buildMinimalRepro(model);
+ await navigator.clipboard.writeText(JSON.stringify(repro, null, 2));
+ el.jsonFeedback.textContent = "Minimal repro JSON copied.";
+ } catch (err) {
+ el.jsonFeedback.textContent = `Copy repro failed: ${err.message}`;
+ }
+ });
+
+ el.applyJsonBtn.addEventListener("click", async () => {
+ try {
+ const parsed = JSON.parse(el.jsonEditor.value);
+ const before = state.model ? clone(state.model) : null;
+ el.jsonFeedback.textContent = "Applying JSON...";
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ await compileModel(parsed, { fit: true });
+ el.jsonFeedback.textContent = summarizeModelDelta(before, state.model);
+ } catch (err) {
+ const p = parseJsonPositionError(el.jsonEditor.value, err);
+ el.jsonFeedback.textContent = `Apply failed: ${p.message}`;
+ }
+ });
+
+ el.newProjectBtn.addEventListener("click", async () => {
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ await compileModel(defaultProject(), { fit: true });
+ });
+
+ el.loadSampleBtn.addEventListener("click", loadSample);
+
+ el.autoLayoutBtn.addEventListener("click", async () => {
+ await runLayoutAction("/layout/auto");
+ });
+
+ el.autoTidyBtn.addEventListener("click", async () => {
+ await runLayoutAction("/layout/tidy");
+ });
+
+ el.importBtn.addEventListener("click", () => {
+ el.fileInput.click();
+ });
+
+ el.fileInput.addEventListener("change", async (evt) => {
+ const file = evt.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ try {
+ const content = await file.text();
+ const parsed = JSON.parse(content);
+ setSelectedRefs([]);
+ state.selectedNet = null;
+ state.selectedPin = null;
+ await compileModel(parsed, { fit: true });
+ } catch (err) {
+ setStatus(`Import failed: ${err.message}`, false);
+ }
+
+ el.fileInput.value = "";
+ });
+
+ el.exportBtn.addEventListener("click", () => {
+ if (!state.model) {
+ return;
+ }
+
+ const blob = new Blob([JSON.stringify(state.model, null, 2)], { type: "application/json" });
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = `${(state.model.meta?.title || "schemeta").toString().replace(/\s+/g, "_").toLowerCase()}.schemeta.json`;
+ document.body.appendChild(a);
+ a.click();
+ URL.revokeObjectURL(a.href);
+ document.body.removeChild(a);
+ });
+}
+
+(async function init() {
+ setupEvents();
+ updateTransform();
+
+ const snapshots = JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) ?? "[]");
+ if (snapshots.length) {
+ await compileModel(snapshots[0].model, { fit: true });
+ } else {
+ await loadSample();
+ }
+})();
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..e39c967
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,238 @@
+
+
+
+
+
+ Schemeta Workspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Schemeta JSON Schema
+
+
+
+
+
+
+
Use this schema in AI prompts/tools to generate valid Schemeta JSON deterministically.
+
+
+
+
+
+
+
diff --git a/frontend/sample.schemeta.json b/frontend/sample.schemeta.json
new file mode 100644
index 0000000..f478f22
--- /dev/null
+++ b/frontend/sample.schemeta.json
@@ -0,0 +1,91 @@
+{
+ "meta": {
+ "title": "ESP32 Audio Path"
+ },
+ "symbols": {
+ "esp32_s3_supermini": {
+ "symbol_id": "esp32_s3_supermini",
+ "category": "microcontroller",
+ "body": { "width": 160, "height": 240 },
+ "pins": [
+ { "name": "3V3", "number": "1", "side": "left", "offset": 30, "type": "power_in" },
+ { "name": "GND", "number": "2", "side": "left", "offset": 60, "type": "ground" },
+ { "name": "GPIO5", "number": "10", "side": "right", "offset": 40, "type": "output" },
+ { "name": "GPIO6", "number": "11", "side": "right", "offset": 70, "type": "output" },
+ { "name": "GPIO7", "number": "12", "side": "right", "offset": 100, "type": "output" }
+ ],
+ "graphics": {
+ "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 240 }]
+ }
+ },
+ "dac_i2s": {
+ "symbol_id": "dac_i2s",
+ "category": "audio",
+ "body": { "width": 140, "height": 180 },
+ "pins": [
+ { "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
+ { "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
+ { "name": "BCLK", "number": "3", "side": "left", "offset": 80, "type": "input" },
+ { "name": "LRCLK", "number": "4", "side": "left", "offset": 110, "type": "input" },
+ { "name": "DIN", "number": "5", "side": "left", "offset": 140, "type": "input" },
+ { "name": "AOUT", "number": "6", "side": "right", "offset": 90, "type": "analog" }
+ ],
+ "graphics": {
+ "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 180 }]
+ }
+ },
+ "amp": {
+ "symbol_id": "amp",
+ "category": "output",
+ "body": { "width": 120, "height": 120 },
+ "pins": [
+ { "name": "5V", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
+ { "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
+ { "name": "IN", "number": "3", "side": "left", "offset": 80, "type": "input" },
+ { "name": "SPK", "number": "4", "side": "right", "offset": 70, "type": "output" }
+ ],
+ "graphics": {
+ "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
+ }
+ },
+ "psu": {
+ "symbol_id": "psu",
+ "category": "power",
+ "body": { "width": 120, "height": 120 },
+ "pins": [
+ { "name": "5V_OUT", "number": "1", "side": "right", "offset": 30, "type": "power_out" },
+ { "name": "3V3_OUT", "number": "2", "side": "right", "offset": 60, "type": "power_out" },
+ { "name": "GND", "number": "3", "side": "right", "offset": 90, "type": "ground" }
+ ],
+ "graphics": {
+ "primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
+ }
+ }
+ },
+ "instances": [
+ { "ref": "U1", "symbol": "esp32_s3_supermini", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
+ { "ref": "U2", "symbol": "dac_i2s", "properties": { "value": "DAC" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
+ { "ref": "U3", "symbol": "amp", "properties": { "value": "Amp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
+ { "ref": "U4", "symbol": "psu", "properties": { "value": "Power" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
+ ],
+ "nets": [
+ { "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }] },
+ { "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] },
+ { "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }] },
+ { "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] },
+ { "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] },
+ { "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] },
+ { "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] }
+ ],
+ "constraints": {
+ "groups": [
+ { "name": "power_stage", "members": ["U4"], "layout": "cluster" },
+ { "name": "compute", "members": ["U1", "U2"], "layout": "cluster" }
+ ],
+ "alignment": [{ "left_of": "U1", "right_of": "U2" }],
+ "near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }]
+ },
+ "annotations": [
+ { "text": "I2S audio chain" }
+ ]
+}
diff --git a/frontend/schemeta.schema.json b/frontend/schemeta.schema.json
new file mode 100644
index 0000000..0307d2a
--- /dev/null
+++ b/frontend/schemeta.schema.json
@@ -0,0 +1,288 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://schemeta.dev/schema/schemeta.schema.json",
+ "title": "Schemeta JSON Model (SJM)",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["meta", "symbols", "instances", "nets", "constraints", "annotations"],
+ "properties": {
+ "meta": {
+ "type": "object",
+ "description": "Project metadata.",
+ "additionalProperties": true
+ },
+ "symbols": {
+ "type": "object",
+ "description": "Map of symbol_id -> symbol definition.",
+ "patternProperties": {
+ "^[A-Za-z_][A-Za-z0-9_]*$": {
+ "$ref": "#/$defs/symbol"
+ }
+ },
+ "additionalProperties": false
+ },
+ "instances": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/instance" }
+ },
+ "nets": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/net" }
+ },
+ "constraints": { "$ref": "#/$defs/constraints" },
+ "annotations": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/annotation" }
+ }
+ },
+ "$defs": {
+ "pinType": {
+ "type": "string",
+ "enum": ["power_in", "power_out", "input", "output", "bidirectional", "passive", "analog", "ground"]
+ },
+ "pinSide": {
+ "type": "string",
+ "enum": ["left", "right", "top", "bottom"]
+ },
+ "netClass": {
+ "type": "string",
+ "enum": ["power", "ground", "signal", "analog", "differential", "clock", "bus"]
+ },
+ "pin": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "number", "side", "offset", "type"],
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "number": { "type": "string", "minLength": 1 },
+ "side": { "$ref": "#/$defs/pinSide" },
+ "offset": { "type": "number", "minimum": 0 },
+ "type": { "$ref": "#/$defs/pinType" }
+ }
+ },
+ "symbol": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Symbol definition. Minimal shorthand is supported for template/generic symbols; compiler hydrates missing fields.",
+ "required": [],
+ "properties": {
+ "symbol_id": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Optional in shorthand; defaults to map key."
+ },
+ "category": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Optional in shorthand; inferred from template or defaults to generic."
+ },
+ "auto_generated": { "type": "boolean" },
+ "template_name": {
+ "type": "string",
+ "enum": ["resistor", "capacitor", "inductor", "diode", "led", "connector"]
+ },
+ "body": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["width", "height"],
+ "properties": {
+ "width": { "type": "number", "minimum": 20 },
+ "height": { "type": "number", "minimum": 20 }
+ }
+ },
+ "pins": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "#/$defs/pin" }
+ },
+ "graphics": {
+ "type": "object",
+ "additionalProperties": true,
+ "properties": {
+ "primitives": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": ["type"],
+ "properties": {
+ "type": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "examples": [
+ { "template_name": "resistor" },
+ { "category": "generic" },
+ {
+ "symbol_id": "opamp_generic",
+ "category": "analog",
+ "body": { "width": 160, "height": 120 },
+ "pins": [
+ { "name": "IN+", "number": "1", "side": "left", "offset": 24, "type": "analog" },
+ { "name": "IN-", "number": "2", "side": "left", "offset": 48, "type": "analog" },
+ { "name": "OUT", "number": "3", "side": "right", "offset": 36, "type": "output" }
+ ]
+ }
+ ]
+ },
+ "placement": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["x", "y", "rotation", "locked"],
+ "properties": {
+ "x": { "type": ["number", "null"] },
+ "y": { "type": ["number", "null"] },
+ "rotation": { "type": "number" },
+ "locked": { "type": "boolean" }
+ }
+ },
+ "instance": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["ref", "properties", "placement"],
+ "anyOf": [{ "required": ["symbol"] }, { "required": ["part"] }],
+ "properties": {
+ "ref": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$" },
+ "symbol": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Custom symbol id reference."
+ },
+ "part": {
+ "type": "string",
+ "description": "Built-in shorthand for common parts (no explicit symbol definition required).",
+ "enum": ["resistor", "capacitor", "inductor", "diode", "led", "connector", "generic"]
+ },
+ "properties": {
+ "type": "object",
+ "description": "Instance-level editable properties. Includes UI/editor hints such as per-pin label visibility.",
+ "properties": {
+ "pin_ui": {
+ "type": "object",
+ "description": "Per-pin UI overrides keyed by pin name.",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "show_net_label": { "type": "boolean" }
+ }
+ }
+ }
+ },
+ "additionalProperties": {
+ "type": ["string", "number", "boolean", "null", "object", "array"]
+ }
+ },
+ "placement": { "$ref": "#/$defs/placement" }
+ },
+ "examples": [
+ {
+ "ref": "R1",
+ "part": "resistor",
+ "properties": { "value": "10k", "pin_ui": { "1": { "show_net_label": true } } },
+ "placement": { "x": null, "y": null, "rotation": 0, "locked": false }
+ },
+ {
+ "ref": "U1",
+ "symbol": "esp32_s3_supermini",
+ "properties": { "value": "ESP32-S3" },
+ "placement": { "x": null, "y": null, "rotation": 0, "locked": false }
+ }
+ ]
+ },
+ "netNode": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["ref", "pin"],
+ "properties": {
+ "ref": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$" },
+ "pin": { "type": "string", "minLength": 1 }
+ }
+ },
+ "net": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "class", "nodes"],
+ "properties": {
+ "name": { "type": "string", "minLength": 1 },
+ "class": { "$ref": "#/$defs/netClass" },
+ "nodes": {
+ "type": "array",
+ "minItems": 2,
+ "items": { "$ref": "#/$defs/netNode" }
+ }
+ }
+ },
+ "constraintGroup": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "members", "layout"],
+ "properties": {
+ "name": { "type": "string" },
+ "members": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "layout": { "type": "string", "enum": ["cluster"] }
+ }
+ },
+ "alignmentConstraint": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["left_of", "right_of"],
+ "properties": {
+ "left_of": { "type": "string" },
+ "right_of": { "type": "string" }
+ }
+ },
+ "nearConstraint": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["component", "target_pin"],
+ "properties": {
+ "component": { "type": "string" },
+ "target_pin": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["ref", "pin"],
+ "properties": {
+ "ref": { "type": "string" },
+ "pin": { "type": "string" }
+ }
+ }
+ }
+ },
+ "constraints": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "groups": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/constraintGroup" }
+ },
+ "alignment": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/alignmentConstraint" }
+ },
+ "near": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/nearConstraint" }
+ }
+ },
+ "default": {}
+ },
+ "annotation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["text"],
+ "properties": {
+ "text": { "type": "string", "minLength": 1 },
+ "x": { "type": "number" },
+ "y": { "type": "number" }
+ }
+ }
+ }
+}
diff --git a/frontend/styles.css b/frontend/styles.css
new file mode 100644
index 0000000..69cd1e3
--- /dev/null
+++ b/frontend/styles.css
@@ -0,0 +1,443 @@
+:root {
+ --bg: #eef2f6;
+ --panel: #ffffff;
+ --ink: #1d2939;
+ --ink-soft: #667085;
+ --line: #d0d5dd;
+ --accent: #155eef;
+ --accent-soft: #dbe8ff;
+ --warn: #b54708;
+ --error: #b42318;
+ --ok: #067647;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Manrope", "Segoe UI", sans-serif;
+ color: var(--ink);
+ background: radial-gradient(circle at 8% 8%, #fef7e6, transparent 30%),
+ radial-gradient(circle at 88% 12%, #e0f2ff, transparent 30%), var(--bg);
+ min-height: 100vh;
+}
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line);
+ background: #f7fbffde;
+ backdrop-filter: blur(4px);
+}
+
+.brand h1 {
+ margin: 0;
+ font-size: 1.2rem;
+ letter-spacing: 0.08em;
+}
+
+.brand p {
+ margin: 2px 0 0;
+ color: var(--ink-soft);
+ font-size: 0.8rem;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+button,
+select,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ padding: 6px 10px;
+ color: var(--ink);
+ cursor: pointer;
+}
+
+button.primary {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+button.chip {
+ padding: 4px 8px;
+ font-size: 0.76rem;
+}
+
+button.activeChip {
+ background: var(--accent-soft);
+ border-color: var(--accent);
+ color: #0f3ea3;
+}
+
+.inlineSelect,
+.inlineCheck,
+.inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.inlineSelect select {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 5px 8px;
+}
+
+.workspace {
+ height: calc(100vh - 65px);
+ display: grid;
+ grid-template-columns: 270px minmax(480px, 1fr) 380px;
+ gap: 10px;
+ padding: 10px;
+}
+
+.pane {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.pane.left,
+.pane.right {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ overflow: auto;
+}
+
+.sectionHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.sectionHead h2 {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+input,
+textarea,
+select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px;
+ color: var(--ink);
+}
+
+textarea {
+ min-height: 250px;
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.list {
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+ max-height: 230px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+}
+
+.list li {
+ padding: 8px;
+ border-bottom: 1px solid var(--line);
+ cursor: pointer;
+}
+
+.list li:last-child {
+ border-bottom: none;
+}
+
+.list li.active {
+ background: var(--accent-soft);
+}
+
+.card {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px;
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+ white-space: pre-wrap;
+}
+
+.editorCard {
+ margin-top: 8px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px;
+ background: #fcfcfd;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.editorGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.editorActions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.hintText {
+ font-size: 0.8rem;
+ color: var(--ink-soft);
+}
+
+.miniList {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ max-height: 170px;
+ overflow: auto;
+}
+
+.miniRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-bottom: 1px solid var(--line);
+ font-size: 0.8rem;
+}
+
+.miniRow:last-child {
+ border-bottom: none;
+}
+
+.symbolPinRow {
+ display: grid;
+ grid-template-columns: 1fr 0.9fr 0.9fr 0.8fr 1fr auto;
+ align-items: center;
+}
+
+.pinCol {
+ min-width: 0;
+ padding: 5px 6px;
+ font-size: 0.75rem;
+}
+
+.canvasTools {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ border-bottom: 1px solid var(--line);
+ padding: 8px;
+}
+
+#compileStatus {
+ margin-left: auto;
+ color: var(--ink-soft);
+ font-size: 0.84rem;
+}
+
+.status-ok {
+ color: var(--ok);
+}
+
+.canvasViewport {
+ height: calc(100% - 52px);
+ overflow: hidden;
+ position: relative;
+ cursor: grab;
+ background-image: linear-gradient(0deg, #ebeff3 1px, transparent 1px),
+ linear-gradient(90deg, #ebeff3 1px, transparent 1px);
+ background-size: 20px 20px;
+}
+
+.canvasViewport.dragging {
+ cursor: grabbing;
+}
+
+.canvasInner {
+ transform-origin: 0 0;
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.canvasInner svg {
+ display: block;
+}
+
+.selectionBox {
+ position: absolute;
+ border: 1px solid #155eef;
+ background: rgba(21, 94, 239, 0.12);
+ pointer-events: none;
+ z-index: 15;
+}
+
+.hidden {
+ display: none;
+}
+
+.jsonActions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.jsonActions button {
+ padding: 4px 8px;
+ font-size: 0.78rem;
+}
+
+.jsonFeedback {
+ min-height: 18px;
+ color: var(--ink-soft);
+ font-size: 0.78rem;
+ margin-bottom: 6px;
+}
+
+.issueRow {
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ padding: 7px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ background: #fff;
+}
+
+.issueRow:hover {
+ background: #f8faff;
+}
+
+.issueErr {
+ border-color: #fecdca;
+ background: #fff6f5;
+}
+
+.issueWarn {
+ border-color: #fedf89;
+ background: #fffcf5;
+}
+
+.issueTitle {
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
+.issueMeta {
+ font-size: 0.72rem;
+ color: var(--ink-soft);
+}
+
+.pinTooltip {
+ position: absolute;
+ pointer-events: none;
+ padding: 6px 8px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #ffffffee;
+ color: var(--ink);
+ font-size: 0.74rem;
+ z-index: 20;
+ box-shadow: 0 6px 20px rgba(16, 24, 40, 0.12);
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.45);
+ z-index: 70;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modalCard {
+ width: min(1120px, 100%);
+ height: min(88vh, 900px);
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.modalHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+}
+
+.modalHead h3 {
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.modalHint {
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 0.8rem;
+}
+
+#schemaViewer {
+ height: 100%;
+ min-height: 0;
+}
+
+.flash {
+ animation: flashPulse 0.7s ease-in-out 0s 2;
+}
+
+@keyframes flashPulse {
+ 0% {
+ opacity: 0.2;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@media (max-width: 1300px) {
+ .workspace {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto;
+ height: auto;
+ }
+
+ .pane.center {
+ min-height: 560px;
+ }
+}
diff --git a/package.json b/package.json
index 0d81498..6db7ed3 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
- "test": "node --test"
+ "test": "node --test",
+ "mcp": "node src/mcp-server.js"
}
}
diff --git a/src/analyze.js b/src/analyze.js
index eb68b20..357ae21 100644
--- a/src/analyze.js
+++ b/src/analyze.js
@@ -87,29 +87,91 @@ function buildSignalPaths(model) {
return [...dedup.values()].sort((a, b) => a.join("/").localeCompare(b.join("/")));
}
+function detectBusGroups(model) {
+ const groups = new Map();
+ for (const net of model.nets) {
+ const match = /^([A-Za-z0-9]+)_/.exec(net.name);
+ if (!match) {
+ continue;
+ }
+ const key = match[1].toUpperCase();
+ const list = groups.get(key) ?? [];
+ list.push(net.name);
+ groups.set(key, list);
+ }
+
+ const out = [];
+ for (const [name, nets] of groups.entries()) {
+ if (nets.length >= 2) {
+ out.push({ name, nets: nets.sort() });
+ }
+ }
+
+ out.sort((a, b) => a.name.localeCompare(b.name));
+ return out;
+}
+
function extractTopology(model) {
- const powerDomains = model.nets
- .filter((n) => n.class === "power" || n.class === "ground")
- .map((n) => n.name)
- .sort();
+ const powerNets = model.nets.filter((n) => n.class === "power" || n.class === "ground");
+ const powerDomains = powerNets.map((n) => n.name).sort();
+
+ const powerDomainConsumers = powerNets
+ .map((net) => {
+ const consumers = net.nodes
+ .filter((n) => {
+ const t = pinTypeFor(model, n.ref, n.pin);
+ return t === "power_in" || t === "ground";
+ })
+ .map((n) => `${n.ref}.${n.pin}`)
+ .sort();
+
+ return {
+ name: net.name,
+ consumers,
+ count: consumers.length
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
const clockSources = new Set();
+ const clockSinks = new Set();
+
for (const net of model.nets) {
if (net.class !== "clock") {
continue;
}
+
for (const node of net.nodes) {
const type = pinTypeFor(model, node.ref, node.pin);
if (type === "output" || type === "power_out") {
clockSources.add(node.ref);
+ } else if (type === "input" || type === "analog") {
+ clockSinks.add(node.ref);
}
}
}
+ const buses = detectBusGroups(model);
+ const signalPaths = buildSignalPaths(model);
+ const namedSignalPaths = model.nets
+ .filter((n) => n.class !== "power" && n.class !== "ground")
+ .map((n) => {
+ const nodes = n.nodes.map((x) => `${x.ref}.${x.pin}`);
+ return {
+ net: n.name,
+ class: n.class,
+ nodes
+ };
+ });
+
return {
power_domains: powerDomains,
+ power_domain_consumers: powerDomainConsumers,
clock_sources: [...clockSources].sort(),
- signal_paths: buildSignalPaths(model)
+ clock_sinks: [...clockSinks].sort(),
+ buses,
+ signal_paths: signalPaths,
+ named_signal_paths: namedSignalPaths
};
}
diff --git a/src/compile.js b/src/compile.js
index 754ea86..2b70968 100644
--- a/src/compile.js
+++ b/src/compile.js
@@ -1,5 +1,6 @@
import { analyzeModel } from "./analyze.js";
-import { renderSvg } from "./render.js";
+import { layoutAndRoute } from "./layout.js";
+import { renderSvgFromLayout } from "./render.js";
import { validateModel } from "./validate.js";
function emptyTopology() {
@@ -10,36 +11,299 @@ function emptyTopology() {
};
}
-export function compile(payload) {
- const validated = validateModel(payload);
+function emptyLayout() {
+ return {
+ width: 0,
+ height: 0,
+ placed: []
+ };
+}
+
+function issueSuggestion(code) {
+ const map = {
+ ground_net_missing: "Add a GND net and connect all ground/reference pins to it.",
+ multi_power_out: "Split this net or insert power management so only one source drives the net.",
+ output_conflict: "Avoid direct output-to-output connection. Insert a buffer/selector or separate nets.",
+ required_power_unconnected: "Connect this pin to the appropriate power or ground domain.",
+ floating_input: "Drive this input from a defined source or add pull-up/pull-down network.",
+ unknown_ref_in_net: "Fix net node reference to an existing component instance.",
+ unknown_pin_in_net: "Fix net node pin name to match the selected symbol pin list.",
+ auto_template_symbol_created:
+ "A common component template was auto-selected (resistor/capacitor/etc). Replace with a custom symbol if needed.",
+ auto_template_symbol_hydrated:
+ "Template symbol fields were auto-filled. You can keep the short form or expand the symbol explicitly.",
+ auto_generic_symbol_created:
+ "A generic symbol was auto-created from net usage. You can replace it with a library symbol later.",
+ auto_generic_symbol_hydrated:
+ "Generic symbol fields were auto-filled from connectivity so minimal JSON remains valid.",
+ auto_generic_pin_created:
+ "A missing generic pin was inferred from net usage. Rename/reposition pins in symbols for cleaner diagrams.",
+ auto_symbol_id_filled:
+ "symbol_id was inferred from the symbol map key to keep the schema concise.",
+ auto_symbol_category_filled:
+ "category was inferred automatically. Set it explicitly if you need strict semantic grouping.",
+ invalid_part_type:
+ "Use a supported built-in part type or provide a full custom symbol definition.",
+ instance_symbol_or_part_missing:
+ "Each instance must define either 'symbol' (custom symbol) or 'part' (built-in shorthand)."
+ };
+
+ return map[code] ?? "Review this issue and adjust net connectivity or symbol definitions.";
+}
+
+function parseNetFromIssue(issue) {
+ if (typeof issue.path === "string") {
+ const m = /^nets\.([^\.]+)/.exec(issue.path);
+ if (m) {
+ return m[1];
+ }
+ }
+
+ const m2 = /Net '([^']+)'/.exec(issue.message);
+ if (m2) {
+ return m2[1];
+ }
+
+ return null;
+}
+
+function parseRefFromIssue(issue) {
+ if (typeof issue.path === "string") {
+ const m = /^instances\.([^\.]+)/.exec(issue.path);
+ if (m) {
+ return m[1];
+ }
+ }
+
+ const m2 = /'([A-Za-z][A-Za-z0-9_]*)\./.exec(issue.message);
+ if (m2) {
+ return m2[1];
+ }
+
+ return null;
+}
+
+function parseRefPinFromIssue(issue) {
+ const m = /'([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z0-9_]+)'/.exec(issue.message);
+ if (m) {
+ return { ref: m[1], pin: m[2] };
+ }
+ return null;
+}
+
+function pinPoint(inst, pin, width, height) {
+ const x0 = inst.placement.x;
+ const y0 = inst.placement.y;
+
+ switch (pin.side) {
+ case "left":
+ return { x: x0, y: y0 + pin.offset };
+ case "right":
+ return { x: x0 + width, y: y0 + pin.offset };
+ case "top":
+ return { x: x0 + pin.offset, y: y0 };
+ case "bottom":
+ return { x: x0 + pin.offset, y: y0 + height };
+ default:
+ return { x: x0, y: y0 };
+ }
+}
+
+function bboxFromPoints(points, padding = 18) {
+ if (!points.length) {
+ return null;
+ }
+
+ const xs = points.map((p) => p.x);
+ const ys = points.map((p) => p.y);
+ const minX = Math.min(...xs) - padding;
+ const maxX = Math.max(...xs) + padding;
+ const minY = Math.min(...ys) - padding;
+ const maxY = Math.max(...ys) + padding;
+
+ return {
+ x: minX,
+ y: minY,
+ w: maxX - minX,
+ h: maxY - minY
+ };
+}
+
+function netFocusBbox(layout, netName) {
+ const routed = layout.routed.find((r) => r.net.name === netName);
+ if (!routed) {
+ return null;
+ }
+
+ const points = [];
+ for (const route of routed.routes) {
+ for (const seg of route) {
+ points.push(seg.a, seg.b);
+ }
+ }
+ points.push(...(routed.tiePoints ?? []));
+ points.push(...(routed.junctionPoints ?? []));
+ points.push(...(routed.labelPoints ?? []));
+
+ return bboxFromPoints(points);
+}
+
+function componentFocus(layout, model, ref) {
+ const inst = layout.placed.find((x) => x.ref === ref);
+ if (!inst) {
+ return null;
+ }
+ const sym = model.symbols[inst.symbol];
+ if (!sym) {
+ return null;
+ }
+
+ return {
+ type: "component",
+ ref,
+ bbox: {
+ x: inst.placement.x - 12,
+ y: inst.placement.y - 12,
+ w: sym.body.width + 24,
+ h: sym.body.height + 24
+ }
+ };
+}
+
+function pinFocus(layout, model, ref, pinName) {
+ const inst = layout.placed.find((x) => x.ref === ref);
+ if (!inst) {
+ return null;
+ }
+
+ const sym = model.symbols[inst.symbol];
+ const pin = sym?.pins.find((p) => p.name === pinName);
+ if (!pin) {
+ return null;
+ }
+
+ const p = pinPoint(inst, pin, sym.body.width, sym.body.height);
+ return {
+ type: "pin",
+ ref,
+ pin: pinName,
+ bbox: { x: p.x - 28, y: p.y - 28, w: 56, h: 56 }
+ };
+}
+
+function buildFocusMap(model, layout, issues) {
+ const map = {};
+
+ for (const issue of issues) {
+ const byNet = parseNetFromIssue(issue);
+ if (byNet) {
+ map[issue.id] = {
+ type: "net",
+ net: byNet,
+ bbox: netFocusBbox(layout, byNet)
+ };
+ continue;
+ }
+
+ const rp = parseRefPinFromIssue(issue);
+ if (rp) {
+ const pf = pinFocus(layout, model, rp.ref, rp.pin);
+ if (pf) {
+ map[issue.id] = pf;
+ continue;
+ }
+ }
+
+ const byRef = parseRefFromIssue(issue);
+ if (byRef) {
+ const cf = componentFocus(layout, model, byRef);
+ if (cf) {
+ map[issue.id] = cf;
+ continue;
+ }
+ }
+
+ map[issue.id] = { type: "global", bbox: { x: 0, y: 0, w: layout.width, h: layout.height } };
+ }
+
+ return map;
+}
+
+function annotateIssues(issues, prefix) {
+ return issues.map((issue, idx) => ({
+ ...issue,
+ id: `${prefix}${idx}`,
+ suggestion: issueSuggestion(issue.code)
+ }));
+}
+
+export function compile(payload, options = {}) {
+ const validated = validateModel(payload, options);
if (!validated.model) {
- const errors = validated.issues.filter((x) => x.severity === "error");
- const warnings = validated.issues.filter((x) => x.severity === "warning");
+ const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
+ const warnings = annotateIssues(validated.issues.filter((x) => x.severity === "warning"), "W");
return {
ok: false,
errors,
warnings,
topology: emptyTopology(),
+ layout: emptyLayout(),
+ layout_metrics: {
+ segment_count: 0,
+ overlap_edges: 0,
+ crossings: 0,
+ label_collisions: 0,
+ tie_points_used: 0,
+ bus_groups: 0
+ },
+ bus_groups: [],
+ focus_map: {},
+ render_mode_used: options.render_mode ?? "schematic_stub",
svg: ""
};
}
const analysis = analyzeModel(validated.model, validated.issues);
- const svg = renderSvg(validated.model);
+ const layout = layoutAndRoute(validated.model, options);
+ const svg = renderSvgFromLayout(validated.model, layout, options);
+
+ const errors = annotateIssues(analysis.errors, "E");
+ const warnings = annotateIssues(analysis.warnings, "W");
+ const issues = [...errors, ...warnings];
+ const focusMap = buildFocusMap(validated.model, layout, issues);
+
+ const placed = layout.placed.map((inst) => ({
+ ref: inst.ref,
+ x: inst.placement.x,
+ y: inst.placement.y,
+ rotation: inst.placement.rotation,
+ locked: inst.placement.locked
+ }));
return {
...analysis,
+ errors,
+ warnings,
+ focus_map: focusMap,
+ layout: {
+ width: layout.width,
+ height: layout.height,
+ placed
+ },
+ layout_metrics: layout.metrics,
+ bus_groups: layout.bus_groups,
+ render_mode_used: layout.render_mode_used,
svg
};
}
-export function analyze(payload) {
- const validated = validateModel(payload);
+export function analyze(payload, options = {}) {
+ const validated = validateModel(payload, options);
if (!validated.model) {
- const errors = validated.issues.filter((x) => x.severity === "error");
- const warnings = validated.issues.filter((x) => x.severity === "warning");
+ const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
+ const warnings = annotateIssues(validated.issues.filter((x) => x.severity === "warning"), "W");
return {
ok: false,
errors,
@@ -48,5 +312,10 @@ export function analyze(payload) {
};
}
- return analyzeModel(validated.model, validated.issues);
+ const out = analyzeModel(validated.model, validated.issues);
+ return {
+ ...out,
+ errors: annotateIssues(out.errors, "E"),
+ warnings: annotateIssues(out.warnings, "W")
+ };
}
diff --git a/src/layout.js b/src/layout.js
index ead820b..66f9e4c 100644
--- a/src/layout.js
+++ b/src/layout.js
@@ -1,79 +1,862 @@
const GRID = 20;
-const COMPONENT_GAP_X = 220;
-const COMPONENT_GAP_Y = 180;
-const MARGIN_X = 120;
+const MARGIN_X = 140;
const MARGIN_Y = 140;
+const COLUMN_GAP = 320;
+const ROW_GAP = 190;
+const OBSTACLE_PADDING = 14;
+
+const NET_CLASS_PRIORITY = {
+ power: 0,
+ ground: 1,
+ clock: 2,
+ signal: 3,
+ analog: 4,
+ bus: 5,
+ differential: 6
+};
+
+const LABEL_TIE_CLASSES = new Set(["power", "ground", "bus"]);
+const DEFAULT_RENDER_MODE = "schematic_stub";
+const ROTATION_STEPS = [0, 90, 180, 270];
function toGrid(value) {
return Math.round(value / GRID) * GRID;
}
+function pointKey(p) {
+ return `${p.x},${p.y}`;
+}
+
+function edgeKey(a, b) {
+ const ka = pointKey(a);
+ const kb = pointKey(b);
+ return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
+}
+
+function clone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+function pinTypeFor(model, ref, pinName) {
+ const inst = model.instances.find((x) => x.ref === ref);
+ if (!inst) {
+ return "passive";
+ }
+ const sym = model.symbols[inst.symbol];
+ const pin = sym?.pins.find((p) => p.name === pinName);
+ return pin?.type ?? "passive";
+}
+
function pinPoint(inst, pin, width, height) {
const x0 = inst.placement.x;
const y0 = inst.placement.y;
+ const rotation = normalizeRotation(inst.placement.rotation ?? 0);
+ let base = { x: x0, y: y0 };
switch (pin.side) {
case "left":
- return { x: x0, y: y0 + pin.offset };
+ base = { x: x0, y: y0 + pin.offset };
+ break;
case "right":
- return { x: x0 + width, y: y0 + pin.offset };
+ base = { x: x0 + width, y: y0 + pin.offset };
+ break;
case "top":
- return { x: x0 + pin.offset, y: y0 };
+ base = { x: x0 + pin.offset, y: y0 };
+ break;
case "bottom":
- return { x: x0 + pin.offset, y: y0 + height };
+ base = { x: x0 + pin.offset, y: y0 + height };
+ break;
default:
- return { x: x0, y: y0 };
+ base = { x: x0, y: y0 };
}
+
+ if (!rotation) {
+ return base;
+ }
+
+ const cx = x0 + width / 2;
+ const cy = y0 + height / 2;
+ return rotatePoint(base, { x: cx, y: cy }, rotation);
}
-function componentFlowScore(model, ref) {
- let score = 0;
+function normalizeRotation(value) {
+ const n = Number(value ?? 0);
+ if (!Number.isFinite(n)) {
+ return 0;
+ }
+ const snapped = Math.round(n / 90) * 90;
+ let rot = snapped % 360;
+ if (rot < 0) {
+ rot += 360;
+ }
+ return rot;
+}
+
+function rotatePoint(point, center, rotation) {
+ const rad = (rotation * Math.PI) / 180;
+ const cos = Math.round(Math.cos(rad));
+ const sin = Math.round(Math.sin(rad));
+ const dx = point.x - center.x;
+ const dy = point.y - center.y;
+ return {
+ x: Math.round(center.x + dx * cos - dy * sin),
+ y: Math.round(center.y + dx * sin + dy * cos)
+ };
+}
+
+function rotateSide(side, rotation) {
+ const steps = normalizeRotation(rotation) / 90;
+ const order = ["top", "right", "bottom", "left"];
+ const idx = order.indexOf(side);
+ if (idx < 0) {
+ return side;
+ }
+ return order[(idx + steps) % 4];
+}
+
+function getNodePin(model, placedMap, node) {
+ const inst = placedMap.get(node.ref);
+ if (!inst) {
+ return null;
+ }
+
+ const sym = model.symbols[inst.symbol];
+ const pin = sym.pins.find((x) => x.name === node.pin);
+ if (!pin) {
+ return null;
+ }
+
+ const point = pinPoint(inst, pin, sym.body.width, sym.body.height);
+ const exit = {
+ left: { x: point.x - GRID, y: point.y },
+ right: { x: point.x + GRID, y: point.y },
+ top: { x: point.x, y: point.y - GRID },
+ bottom: { x: point.x, y: point.y + GRID }
+ }[rotateSide(pin.side, inst.placement.rotation ?? 0)];
+
+ return {
+ ref: node.ref,
+ pin: node.pin,
+ pinType: pin.type,
+ side: pin.side,
+ point,
+ exit: { x: toGrid(exit.x), y: toGrid(exit.y) }
+ };
+}
+
+function buildDirectedEdges(model) {
+ const edges = [];
+
for (const net of model.nets) {
- for (const node of net.nodes) {
- if (node.ref !== ref) {
- continue;
- }
- if (net.class === "power") {
- score -= 2;
- }
- if (net.class === "clock") {
- score += 1;
- }
- if (net.class === "signal" || net.class === "analog") {
- score += 2;
- }
- if (net.class === "ground") {
- score -= 1;
+ const sources = net.nodes.filter((n) => {
+ const t = pinTypeFor(model, n.ref, n.pin);
+ return t === "output" || t === "power_out";
+ });
+
+ const sinks = net.nodes.filter((n) => {
+ const t = pinTypeFor(model, n.ref, n.pin);
+ return t === "input" || t === "power_in" || t === "analog" || t === "bidirectional";
+ });
+
+ for (const s of sources) {
+ for (const d of sinks) {
+ if (s.ref !== d.ref) {
+ edges.push([s.ref, d.ref]);
+ }
}
}
}
+
+ const dedup = new Map();
+ for (const [a, b] of edges) {
+ dedup.set(`${a}->${b}`, [a, b]);
+ }
+
+ return [...dedup.values()];
+}
+
+function computeRanks(model) {
+ const refs = model.instances.map((x) => x.ref).sort();
+ const rank = new Map(refs.map((r) => [r, 1]));
+
+ const powerRefs = new Set(
+ model.instances
+ .filter((inst) => {
+ const sym = model.symbols[inst.symbol];
+ return (
+ sym.category.toLowerCase().includes("power") ||
+ sym.pins.some((p) => p.type === "power_out")
+ );
+ })
+ .map((x) => x.ref)
+ );
+
+ for (const r of powerRefs) {
+ rank.set(r, 0);
+ }
+
+ const edges = buildDirectedEdges(model);
+
+ for (let i = 0; i < refs.length; i += 1) {
+ let changed = false;
+ for (const [from, to] of edges) {
+ const next = (rank.get(from) ?? 1) + 1;
+ if (!powerRefs.has(to) && next > (rank.get(to) ?? 1)) {
+ rank.set(to, next);
+ changed = true;
+ }
+ }
+ if (!changed) {
+ break;
+ }
+ }
+
+ for (const r of powerRefs) {
+ rank.set(r, 0);
+ }
+
+ return { rank, edges, powerRefs };
+}
+
+function computeBaryOrder(columns, edges) {
+ const predecessors = new Map();
+ for (const [a, b] of edges) {
+ const list = predecessors.get(b) ?? [];
+ list.push(a);
+ predecessors.set(b, list);
+ }
+
+ const orderByRef = new Map();
+ for (const refs of columns.values()) {
+ refs.forEach((ref, idx) => orderByRef.set(ref, idx));
+ }
+
+ const sortedColumns = [...columns.entries()].sort((a, b) => Number(a[0]) - Number(b[0]));
+ for (const [, refs] of sortedColumns) {
+ refs.sort((a, b) => {
+ const pa = predecessors.get(a) ?? [];
+ const pb = predecessors.get(b) ?? [];
+ const ba = pa.length
+ ? pa.reduce((sum, r) => sum + (orderByRef.get(r) ?? 0), 0) / pa.length
+ : Number.MAX_SAFE_INTEGER;
+ const bb = pb.length
+ ? pb.reduce((sum, r) => sum + (orderByRef.get(r) ?? 0), 0) / pb.length
+ : Number.MAX_SAFE_INTEGER;
+
+ if (ba !== bb) {
+ return ba - bb;
+ }
+ return a.localeCompare(b);
+ });
+
+ refs.forEach((ref, idx) => orderByRef.set(ref, idx));
+ }
+}
+
+function applyAlignmentConstraints(placedMap, constraints) {
+ const alignments = constraints?.alignment ?? [];
+ for (const rule of alignments) {
+ const left = placedMap.get(rule.left_of);
+ const right = placedMap.get(rule.right_of);
+ if (!left || !right) {
+ continue;
+ }
+
+ const targetRightX = left.placement.x + COLUMN_GAP;
+ if (right.placement.x < targetRightX) {
+ right.placement.x = toGrid(targetRightX);
+ }
+ }
+}
+
+function applyNearConstraints(model, placedMap, constraints) {
+ const rules = constraints?.near ?? [];
+ for (const rule of rules) {
+ const comp = placedMap.get(rule.component);
+ const target = placedMap.get(rule.target_pin?.ref);
+ if (!comp || !target || comp.placement.locked) {
+ continue;
+ }
+
+ const targetSym = model.symbols[target.symbol];
+ const targetPin = targetSym.pins.find((p) => p.name === rule.target_pin.pin);
+ if (!targetPin) {
+ continue;
+ }
+
+ const tp = pinPoint(target, targetPin, targetSym.body.width, targetSym.body.height);
+ comp.placement.x = toGrid(tp.x + GRID * 4);
+ comp.placement.y = toGrid(tp.y - GRID * 3);
+ }
+}
+
+function buildInstanceMap(instances) {
+ return new Map(instances.map((inst) => [inst.ref, inst]));
+}
+
+function buildConstraintGroups(model, rank) {
+ const allRefs = new Set(model.instances.map((i) => i.ref));
+ const consumed = new Set();
+ const out = [];
+
+ for (const g of model.constraints?.groups ?? []) {
+ const members = (g.members ?? []).filter((ref) => allRefs.has(ref) && !consumed.has(ref));
+ if (!members.length) {
+ continue;
+ }
+ for (const ref of members) {
+ consumed.add(ref);
+ }
+ out.push({
+ name: g.name ?? `group_${out.length + 1}`,
+ members,
+ synthetic: false
+ });
+ }
+
+ const leftovers = model.instances
+ .map((i) => i.ref)
+ .filter((ref) => !consumed.has(ref))
+ .sort((a, b) => {
+ const ra = rank.get(a) ?? 1;
+ const rb = rank.get(b) ?? 1;
+ if (ra !== rb) {
+ return ra - rb;
+ }
+ return a.localeCompare(b);
+ });
+
+ for (const ref of leftovers) {
+ out.push({
+ name: `solo_${ref}`,
+ members: [ref],
+ synthetic: true
+ });
+ }
+
+ return out;
+}
+
+function rankColumnsForRefs(refs, rank) {
+ const minRank = Math.min(...refs.map((r) => rank.get(r) ?? 1));
+ const cols = new Map();
+
+ for (const ref of refs) {
+ const localRank = Math.max(0, (rank.get(ref) ?? 1) - minRank);
+ const list = cols.get(localRank) ?? [];
+ list.push(ref);
+ cols.set(localRank, list);
+ }
+
+ const uniqueCols = [...cols.keys()].sort((a, b) => a - b);
+ if (uniqueCols.length === 1 && refs.length >= 4) {
+ cols.clear();
+ const targetCols = Math.min(3, Math.max(2, Math.ceil(refs.length / 3)));
+ refs.forEach((ref, idx) => {
+ const col = idx % targetCols;
+ const list = cols.get(col) ?? [];
+ list.push(ref);
+ cols.set(col, list);
+ });
+ }
+
+ return cols;
+}
+
+function connectivityDegree(model) {
+ const deg = new Map(model.instances.map((i) => [i.ref, 0]));
+ for (const net of model.nets) {
+ const refs = [...new Set((net.nodes ?? []).map((n) => n.ref))];
+ for (const ref of refs) {
+ deg.set(ref, (deg.get(ref) ?? 0) + Math.max(1, refs.length - 1));
+ }
+ }
+ return deg;
+}
+
+function placeGroup(model, group, start, context) {
+ const { rank, degree, instanceByRef, respectLocks } = context;
+ const refs = [...group.members].sort((a, b) => a.localeCompare(b));
+ const cols = rankColumnsForRefs(refs, rank);
+ const colOrder = [...cols.keys()].sort((a, b) => a - b);
+
+ const colWidths = new Map();
+ for (const col of colOrder) {
+ const w = Math.max(
+ ...cols.get(col).map((ref) => {
+ const inst = instanceByRef.get(ref);
+ const sym = inst ? model.symbols[inst.symbol] : null;
+ return sym?.body?.width ?? 120;
+ }),
+ 120
+ );
+ colWidths.set(col, w);
+ }
+
+ const colX = new Map();
+ let xCursor = start.x;
+ for (const col of colOrder) {
+ colX.set(col, toGrid(xCursor));
+ xCursor += (colWidths.get(col) ?? 120) + 170;
+ }
+
+ const placed = [];
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ for (const col of colOrder) {
+ const refsInCol = [...(cols.get(col) ?? [])].sort((a, b) => {
+ const da = degree.get(a) ?? 0;
+ const db = degree.get(b) ?? 0;
+ if (da !== db) {
+ return db - da;
+ }
+ return a.localeCompare(b);
+ });
+
+ let yCursor = start.y;
+ for (const ref of refsInCol) {
+ const inst = instanceByRef.get(ref);
+ if (!inst) {
+ continue;
+ }
+ const sym = model.symbols[inst.symbol];
+ const locked = respectLocks ? (inst.placement.locked ?? false) : false;
+ let x = inst.placement.x;
+ let y = inst.placement.y;
+
+ if (x == null || y == null || !locked) {
+ x = toGrid(colX.get(col) ?? start.x);
+ y = toGrid(yCursor);
+ }
+
+ const next = {
+ ...inst,
+ placement: {
+ x,
+ y,
+ rotation: normalizeRotation(inst.placement.rotation ?? 0),
+ locked
+ }
+ };
+ placed.push(next);
+
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x + sym.body.width);
+ maxY = Math.max(maxY, y + sym.body.height);
+
+ yCursor = y + sym.body.height + 110;
+ }
+ }
+
+ if (!placed.length) {
+ return {
+ placed,
+ bbox: { x: start.x, y: start.y, w: 320, h: 240 }
+ };
+ }
+
+ return {
+ placed,
+ bbox: {
+ x: minX,
+ y: minY,
+ w: maxX - minX,
+ h: maxY - minY
+ }
+ };
+}
+
+function buildNodeNetMap(model) {
+ const map = new Map();
+ for (const net of model.nets) {
+ for (const node of net.nodes ?? []) {
+ const key = `${node.ref}.${node.pin}`;
+ const list = map.get(key) ?? [];
+ list.push(net);
+ map.set(key, list);
+ }
+ }
+ return map;
+}
+
+function scoreInstanceRotation(model, placedMap, inst, rotation, nodeNetMap) {
+ const sym = model.symbols[inst.symbol];
+ if (!sym || !Array.isArray(sym.pins)) {
+ return Number.POSITIVE_INFINITY;
+ }
+
+ const temp = {
+ ...inst,
+ placement: {
+ ...inst.placement,
+ rotation
+ }
+ };
+
+ let score = 0;
+ for (const pin of sym.pins) {
+ const key = `${inst.ref}.${pin.name}`;
+ const nets = nodeNetMap.get(key) ?? [];
+ if (!nets.length) {
+ continue;
+ }
+
+ const p = pinPoint(temp, pin, sym.body.width, sym.body.height);
+ for (const net of nets) {
+ const others = (net.nodes ?? []).filter((n) => !(n.ref === inst.ref && n.pin === pin.name));
+ if (!others.length) {
+ continue;
+ }
+
+ let cx = 0;
+ let cy = 0;
+ let count = 0;
+
+ for (const n of others) {
+ const oi = placedMap.get(n.ref);
+ if (!oi) {
+ continue;
+ }
+ const os = model.symbols[oi.symbol];
+ const op = os?.pins?.find((pp) => pp.name === n.pin);
+ if (!os || !op) {
+ continue;
+ }
+ const q = pinPoint(oi, op, os.body.width, os.body.height);
+ cx += q.x;
+ cy += q.y;
+ count += 1;
+ }
+
+ if (!count) {
+ continue;
+ }
+
+ const tx = cx / count;
+ const ty = cy / count;
+ score += Math.abs(p.x - tx) + Math.abs(p.y - ty);
+ }
+ }
+
return score;
}
-function intersectionPenalty(segments, boxes) {
- for (const seg of segments) {
- const minX = Math.min(seg.a.x, seg.b.x);
- const maxX = Math.max(seg.a.x, seg.b.x);
- const minY = Math.min(seg.a.y, seg.b.y);
- const maxY = Math.max(seg.a.y, seg.b.y);
+function applyAutoRotation(model, placedMap, options = {}) {
+ const allow = options.autoRotate !== false;
+ if (!allow) {
+ return;
+ }
- for (const b of boxes) {
- const overlap = !(maxX < b.x || minX > b.x + b.w || maxY < b.y || minY > b.y + b.h);
- if (overlap) {
- return true;
+ const nodeNetMap = buildNodeNetMap(model);
+ const refs = [...placedMap.keys()].sort();
+ for (const ref of refs) {
+ const inst = placedMap.get(ref);
+ if (!inst || inst.placement.locked) {
+ continue;
+ }
+
+ let bestRot = normalizeRotation(inst.placement.rotation ?? 0);
+ let bestScore = scoreInstanceRotation(model, placedMap, inst, bestRot, nodeNetMap);
+
+ for (const rot of ROTATION_STEPS) {
+ const s = scoreInstanceRotation(model, placedMap, inst, rot, nodeNetMap);
+ if (s < bestScore - 0.001) {
+ bestScore = s;
+ bestRot = rot;
}
}
+
+ inst.placement.rotation = bestRot;
+ placedMap.set(ref, inst);
+ }
+}
+
+function placeInstances(model, options = {}) {
+ const respectLocks = options.respectLocks ?? true;
+ const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
+ const { rank } = computeRanks(model);
+ const degree = connectivityDegree(model);
+ const instanceByRef = buildInstanceMap(instances);
+ const groups = buildConstraintGroups(model, rank);
+
+ const placed = [];
+ const placedMap = new Map();
+ const groupsPerRow = groups.length <= 2 ? groups.length : 2;
+ const groupCellW = 860;
+ const groupCellH = 560;
+
+ for (let i = 0; i < groups.length; i += 1) {
+ const group = groups[i];
+ const row = groupsPerRow ? Math.floor(i / groupsPerRow) : 0;
+ const col = groupsPerRow ? i % groupsPerRow : 0;
+ const origin = {
+ x: toGrid(MARGIN_X + col * groupCellW),
+ y: toGrid(MARGIN_Y + row * groupCellH)
+ };
+
+ const out = placeGroup(model, group, origin, {
+ rank,
+ degree,
+ instanceByRef,
+ respectLocks
+ });
+
+ for (const inst of out.placed) {
+ placed.push(inst);
+ placedMap.set(inst.ref, inst);
+ }
+ }
+
+ applyAutoRotation(model, placedMap, {
+ autoRotate: options.autoRotate ?? true
+ });
+
+ applyAlignmentConstraints(placedMap, model.constraints);
+ applyNearConstraints(model, placedMap, model.constraints);
+
+ return { placed, placedMap };
+}
+
+function buildObstacles(model, placed) {
+ return placed.map((inst) => {
+ const sym = model.symbols[inst.symbol];
+ return {
+ ref: inst.ref,
+ x: inst.placement.x - OBSTACLE_PADDING,
+ y: inst.placement.y - OBSTACLE_PADDING,
+ w: sym.body.width + OBSTACLE_PADDING * 2,
+ h: sym.body.height + OBSTACLE_PADDING * 2
+ };
+ });
+}
+
+function between(value, min, max) {
+ return value >= min && value <= max;
+}
+
+function segmentIntersectsBox(a, b, box) {
+ if (a.x === b.x) {
+ if (!between(a.x, box.x, box.x + box.w)) {
+ return false;
+ }
+ const minY = Math.min(a.y, b.y);
+ const maxY = Math.max(a.y, b.y);
+ return !(maxY < box.y || minY > box.y + box.h);
+ }
+
+ if (a.y === b.y) {
+ if (!between(a.y, box.y, box.y + box.h)) {
+ return false;
+ }
+ const minX = Math.min(a.x, b.x);
+ const maxX = Math.max(a.x, b.x);
+ return !(maxX < box.x || minX > box.x + box.w);
+ }
+
+ return false;
+}
+
+function buildBounds(model, placed) {
+ const maxX = Math.max(...placed.map((p) => p.placement.x + model.symbols[p.symbol].body.width), MARGIN_X * 3);
+ const maxY = Math.max(...placed.map((p) => p.placement.y + model.symbols[p.symbol].body.height), MARGIN_Y * 3);
+
+ return {
+ minX: 0,
+ minY: 0,
+ maxX: toGrid(maxX + MARGIN_X * 2),
+ maxY: toGrid(maxY + MARGIN_Y * 2)
+ };
+}
+
+function segmentBlocked(a, b, obstacles, allowedRefs) {
+ for (const box of obstacles) {
+ if (allowedRefs.has(box.ref)) {
+ continue;
+ }
+ if (segmentIntersectsBox(a, b, box)) {
+ return true;
+ }
}
return false;
}
+function heuristic(a, b) {
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
+
+function reconstructPath(cameFrom, currentKey) {
+ const out = [];
+ let key = currentKey;
+ while (key) {
+ const node = cameFrom.get(key)?.node;
+ if (!node) {
+ const [x, y] = key.split(",").map(Number);
+ out.push({ x, y });
+ break;
+ }
+ out.push(node);
+ key = cameFrom.get(key)?.prev;
+ }
+ out.reverse();
+ return out;
+}
+
+function hasForeignEdgeUsage(edgeUsage, netName, a, b) {
+ const usage = edgeUsage.get(edgeKey(a, b));
+ if (!usage) {
+ return false;
+ }
+
+ for (const existingNet of usage.byNet.keys()) {
+ if (existingNet !== netName) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function hasForeignPointUsage(pointUsage, netName, point) {
+ const usage = pointUsage.get(pointKey(point));
+ if (!usage) {
+ return false;
+ }
+
+ for (const existingNet of usage.keys()) {
+ if (existingNet !== netName) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function proximityPenalty(point, obstacles, allowedRefs) {
+ let penalty = 0;
+ for (const box of obstacles) {
+ if (allowedRefs.has(box.ref)) {
+ continue;
+ }
+
+ const dx = Math.max(box.x - point.x, 0, point.x - (box.x + box.w));
+ const dy = Math.max(box.y - point.y, 0, point.y - (box.y + box.h));
+ const dist = dx + dy;
+ if (dist < GRID * 2) {
+ penalty += (GRID * 2 - dist) * 0.35;
+ }
+ }
+ return penalty;
+}
+
+function aStar(start, goal, context) {
+ const { bounds, obstacles, allowedRefs, edgeUsage, pointUsage, netName } = context;
+
+ const startNode = { x: toGrid(start.x), y: toGrid(start.y) };
+ const goalNode = { x: toGrid(goal.x), y: toGrid(goal.y) };
+
+ const open = new Map();
+ const gScore = new Map();
+ const cameFrom = new Map();
+
+ const startKey = pointKey(startNode);
+ open.set(startKey, { ...startNode, dir: null, f: heuristic(startNode, goalNode) });
+ gScore.set(startKey, 0);
+ cameFrom.set(startKey, { prev: null, node: startNode, dir: null });
+
+ const maxIterations = 30000;
+ let iterations = 0;
+
+ while (open.size > 0 && iterations < maxIterations) {
+ iterations += 1;
+
+ let current = null;
+ for (const cand of open.values()) {
+ if (!current || cand.f < current.f) {
+ current = cand;
+ }
+ }
+
+ if (!current) {
+ break;
+ }
+
+ const currentKey = pointKey(current);
+ if (current.x === goalNode.x && current.y === goalNode.y) {
+ return reconstructPath(cameFrom, currentKey);
+ }
+
+ open.delete(currentKey);
+
+ const neighbors = [
+ { x: current.x + GRID, y: current.y, dir: "h" },
+ { x: current.x - GRID, y: current.y, dir: "h" },
+ { x: current.x, y: current.y + GRID, dir: "v" },
+ { x: current.x, y: current.y - GRID, dir: "v" }
+ ];
+
+ for (const nb of neighbors) {
+ if (nb.x < bounds.minX || nb.x > bounds.maxX || nb.y < bounds.minY || nb.y > bounds.maxY) {
+ continue;
+ }
+
+ if (segmentBlocked(current, nb, obstacles, allowedRefs)) {
+ continue;
+ }
+
+ if (hasForeignEdgeUsage(edgeUsage, netName, current, nb)) {
+ continue;
+ }
+
+ const isGoal = nb.x === goalNode.x && nb.y === goalNode.y;
+ if (!isGoal && hasForeignPointUsage(pointUsage, netName, nb)) {
+ continue;
+ }
+
+ const nbKey = pointKey(nb);
+ const prevCost = gScore.get(currentKey) ?? Number.POSITIVE_INFINITY;
+ const turnPenalty = current.dir && current.dir !== nb.dir ? 16 : 0;
+ const obstaclePenalty = proximityPenalty(nb, obstacles, allowedRefs);
+ const tentative = prevCost + GRID + turnPenalty + obstaclePenalty;
+
+ if (tentative >= (gScore.get(nbKey) ?? Number.POSITIVE_INFINITY)) {
+ continue;
+ }
+
+ gScore.set(nbKey, tentative);
+ open.set(nbKey, {
+ ...nb,
+ f: tentative + heuristic(nb, goalNode)
+ });
+ cameFrom.set(nbKey, {
+ prev: currentKey,
+ node: { x: nb.x, y: nb.y },
+ dir: nb.dir
+ });
+ }
+ }
+
+ return null;
+}
+
+function pointsToSegments(points) {
+ const segments = [];
+ for (let i = 1; i < points.length; i += 1) {
+ const a = points[i - 1];
+ const b = points[i];
+ if (a.x === b.x && a.y === b.y) {
+ continue;
+ }
+ segments.push({ a: { ...a }, b: { ...b } });
+ }
+ return simplifySegments(segments);
+}
+
function simplifySegments(segments) {
const out = [];
for (const seg of segments) {
- if (seg.a.x === seg.b.x && seg.a.y === seg.b.y) {
- continue;
- }
const prev = out[out.length - 1];
if (!prev) {
out.push(seg);
@@ -81,8 +864,8 @@ function simplifySegments(segments) {
}
const prevVertical = prev.a.x === prev.b.x;
- const curVertical = seg.a.x === seg.b.x;
- if (prevVertical === curVertical && prev.b.x === seg.a.x && prev.b.y === seg.a.y) {
+ const currVertical = seg.a.x === seg.b.x;
+ if (prevVertical === currVertical && prev.b.x === seg.a.x && prev.b.y === seg.a.y) {
prev.b = { ...seg.b };
continue;
}
@@ -93,129 +876,557 @@ function simplifySegments(segments) {
return out;
}
-function routeManhattan(a, b, obstacles) {
- const straight1 = [
- { a, b: { x: toGrid(b.x), y: toGrid(a.y) } },
- { a: { x: toGrid(b.x), y: toGrid(a.y) }, b }
- ];
- if (!intersectionPenalty(straight1, obstacles)) {
- return simplifySegments(straight1);
+function segmentStepPoints(a, b) {
+ const points = [];
+
+ if (a.x === b.x) {
+ const step = a.y < b.y ? GRID : -GRID;
+ for (let y = a.y; step > 0 ? y <= b.y : y >= b.y; y += step) {
+ points.push({ x: a.x, y });
+ }
+ return points;
}
- const detourY = toGrid((a.y + b.y) / 2 + 80);
- const detourX = toGrid((a.x + b.x) / 2);
- const detour = [
- { a, b: { x: a.x, y: detourY } },
- { a: { x: a.x, y: detourY }, b: { x: detourX, y: detourY } },
- { a: { x: detourX, y: detourY }, b: { x: detourX, y: b.y } },
- { a: { x: detourX, y: b.y }, b }
- ];
+ if (a.y === b.y) {
+ const step = a.x < b.x ? GRID : -GRID;
+ for (let x = a.x; step > 0 ? x <= b.x : x >= b.x; x += step) {
+ points.push({ x, y: a.y });
+ }
+ return points;
+ }
- return simplifySegments(detour);
+ return [a, b];
}
-function pointForNode(model, placed, ref, pin) {
- const inst = placed.find((x) => x.ref === ref);
- if (!inst) {
- return null;
- }
- const sym = model.symbols[inst.symbol];
- const p = sym.pins.find((x) => x.name === pin);
- if (!p) {
- return null;
- }
+function addUsageForSegments(edgeUsage, pointUsage, netName, segments) {
+ for (const seg of segments) {
+ const stepPoints = segmentStepPoints(seg.a, seg.b);
- return pinPoint(inst, p, sym.body.width, sym.body.height);
-}
-
-export function layoutAndRoute(model) {
- const instances = [...model.instances].sort((a, b) => a.ref.localeCompare(b.ref));
-
- const groupMap = new Map();
- const groups = model.constraints?.groups ?? [];
- groups.forEach((g, i) => g.members.forEach((m) => groupMap.set(m, i)));
-
- const flowSorted = instances
- .map((inst) => ({ inst, score: componentFlowScore(model, inst.ref), group: groupMap.get(inst.ref) ?? 9999 }))
- .sort((a, b) => a.group - b.group || a.score - b.score || a.inst.ref.localeCompare(b.inst.ref));
-
- const placed = [];
- let row = 0;
- let col = 0;
-
- for (const item of flowSorted) {
- const symbol = model.symbols[item.inst.symbol];
- const locked = item.inst.placement.locked ?? false;
-
- let x = item.inst.placement.x;
- let y = item.inst.placement.y;
-
- if (x == null || y == null || !locked) {
- x = toGrid(MARGIN_X + col * COMPONENT_GAP_X);
- y = toGrid(MARGIN_Y + row * COMPONENT_GAP_Y);
+ for (let i = 1; i < stepPoints.length; i += 1) {
+ const p0 = stepPoints[i - 1];
+ const p1 = stepPoints[i];
+ const eKey = edgeKey(p0, p1);
+ const edge = edgeUsage.get(eKey) ?? { total: 0, byNet: new Map() };
+ edge.total += 1;
+ edge.byNet.set(netName, (edge.byNet.get(netName) ?? 0) + 1);
+ edgeUsage.set(eKey, edge);
}
- placed.push({
- ...item.inst,
- placement: {
- x,
- y,
- rotation: item.inst.placement.rotation ?? 0,
- locked
- }
+ for (const p of stepPoints) {
+ const pKey = pointKey(p);
+ const usage = pointUsage.get(pKey) ?? new Map();
+ usage.set(netName, (usage.get(netName) ?? 0) + 1);
+ pointUsage.set(pKey, usage);
+ }
+ }
+}
+
+function computeJunctionPoints(routes) {
+ const degree = new Map();
+
+ for (const route of routes) {
+ for (const seg of route) {
+ const aKey = pointKey(seg.a);
+ const bKey = pointKey(seg.b);
+ degree.set(aKey, (degree.get(aKey) ?? 0) + 1);
+ degree.set(bKey, (degree.get(bKey) ?? 0) + 1);
+ }
+ }
+
+ const out = [];
+ for (const [key, deg] of degree.entries()) {
+ if (deg >= 3) {
+ const [x, y] = key.split(",").map(Number);
+ out.push({ x, y });
+ }
+ }
+
+ out.sort((a, b) => a.x - b.x || a.y - b.y);
+ return out;
+}
+
+function labelPointForPin(pin) {
+ if (pin.side === "left") {
+ return { x: pin.exit.x - GRID * 2.2, y: pin.exit.y - GRID * 0.4 };
+ }
+ if (pin.side === "right") {
+ return { x: pin.exit.x + GRID * 0.6, y: pin.exit.y - GRID * 0.4 };
+ }
+ if (pin.side === "top") {
+ return { x: pin.exit.x + GRID * 0.3, y: pin.exit.y - GRID * 0.8 };
+ }
+ return { x: pin.exit.x + GRID * 0.3, y: pin.exit.y + GRID * 0.9 };
+}
+
+function chooseEndpoints(pinNodes) {
+ if (pinNodes.length < 2) {
+ return null;
+ }
+
+ const priority = (pinType) => {
+ if (pinType === "power_out") return 0;
+ if (pinType === "output") return 1;
+ if (pinType === "bidirectional") return 2;
+ if (pinType === "analog") return 3;
+ return 4;
+ };
+
+ const sorted = [...pinNodes].sort((a, b) => {
+ const pa = priority(a.pinType);
+ const pb = priority(b.pinType);
+ if (pa !== pb) {
+ return pa - pb;
+ }
+ return a.exit.x - b.exit.x || a.exit.y - b.exit.y;
+ });
+
+ return { source: sorted[0], target: sorted[1] };
+}
+
+function pinRoutePriority(pin) {
+ if (pin.pinType === "power_out") return 0;
+ if (pin.pinType === "output") return 1;
+ if (pin.pinType === "bidirectional") return 2;
+ if (pin.pinType === "analog") return 3;
+ return 4;
+}
+
+function uniquePoints(points) {
+ const map = new Map();
+ for (const p of points) {
+ map.set(pointKey(p), p);
+ }
+ return [...map.values()];
+}
+
+function routeLabelTieNet(net, pinNodes, context) {
+ const routes = [];
+ const tiePoints = [];
+
+ for (const pin of pinNodes) {
+ const stub = pointsToSegments([pin.point, pin.exit]);
+ if (stub.length) {
+ routes.push(stub);
+ addUsageForSegments(context.edgeUsage, context.pointUsage, net.name, stub);
+ }
+ tiePoints.push({ x: pin.exit.x, y: pin.exit.y });
+ }
+
+ const labelPoints = [];
+ if (tiePoints.length) {
+ const centroid = {
+ x: tiePoints.reduce((sum, p) => sum + p.x, 0) / tiePoints.length,
+ y: tiePoints.reduce((sum, p) => sum + p.y, 0) / tiePoints.length
+ };
+ labelPoints.push({
+ x: toGrid(centroid.x + GRID * 0.6),
+ y: toGrid(centroid.y - GRID * 0.8)
});
- col += 1;
- if (col >= 4) {
- col = 0;
- row += 1;
- }
-
- if (symbol.category.toLowerCase().includes("power")) {
- placed[placed.length - 1].placement.y = toGrid(MARGIN_Y * 0.5);
+ const anchorPin = [...pinNodes].sort((a, b) => a.exit.x - b.exit.x || a.exit.y - b.exit.y)[0];
+ if (anchorPin) {
+ labelPoints.push(labelPointForPin(anchorPin));
}
}
- const boxes = placed.map((p) => {
- const sym = model.symbols[p.symbol];
+ return {
+ mode: "label_tie",
+ routes,
+ labelPoints,
+ tiePoints,
+ junctionPoints: []
+ };
+}
+
+function pathLength(points) {
+ let length = 0;
+ for (let i = 1; i < points.length; i += 1) {
+ length += Math.abs(points[i].x - points[i - 1].x) + Math.abs(points[i].y - points[i - 1].y);
+ }
+ return length;
+}
+
+function routePointToPointNet(net, pinNodes, context) {
+ if (pinNodes.length < 2) {
return {
- x: p.placement.x,
- y: p.placement.y,
- w: sym.body.width,
- h: sym.body.height
+ mode: "routed",
+ routes: [],
+ labelPoints: [],
+ tiePoints: [],
+ junctionPoints: []
};
+ }
+
+ const sorted = [...pinNodes].sort((a, b) => {
+ const pa = pinRoutePriority(a);
+ const pb = pinRoutePriority(b);
+ if (pa !== pb) {
+ return pa - pb;
+ }
+ return a.exit.x - b.exit.x || a.exit.y - b.exit.y;
});
- const routed = model.nets.map((net) => {
- const routes = [];
- if (net.nodes.length < 2) {
- return { net, routes };
- }
+ const source = sorted[0];
+ const routes = [];
+ const sourceStub = pointsToSegments([source.point, source.exit]);
+ if (sourceStub.length) {
+ routes.push(sourceStub);
+ addUsageForSegments(context.edgeUsage, context.pointUsage, net.name, sourceStub);
+ }
- const first = net.nodes[0];
- for (let i = 1; i < net.nodes.length; i += 1) {
- const target = net.nodes[i];
- const a = pointForNode(model, placed, first.ref, first.pin);
- const b = pointForNode(model, placed, target.ref, target.pin);
- if (!a || !b) {
+ const treePoints = new Map();
+ treePoints.set(pointKey(source.exit), source.exit);
+
+ const allowedRefs = new Set(sorted.map((p) => p.ref));
+ const remaining = sorted.slice(1);
+
+ for (const target of remaining) {
+ const candidates = uniquePoints([...treePoints.values()])
+ .sort((a, b) => heuristic(target.exit, a) - heuristic(target.exit, b))
+ .slice(0, 12);
+
+ let best = null;
+ for (const attach of candidates) {
+ const path = aStar(target.exit, attach, {
+ ...context,
+ allowedRefs,
+ netName: net.name
+ });
+ if (!path) {
continue;
}
- routes.push(routeManhattan(a, b, boxes));
+
+ const cost = pathLength(path);
+ if (!best || cost < best.cost) {
+ best = { path, attach, cost };
+ }
}
- return { net, routes };
+ if (!best) {
+ return routeLabelTieNet(net, sorted, context);
+ }
+
+ const direct = heuristic(target.exit, best.attach);
+ if (context.renderMode === "schematic_stub" && best.cost > direct * 1.65) {
+ return routeLabelTieNet(net, sorted, context);
+ }
+
+ const branchPoints = [target.point, target.exit, ...best.path.slice(1)];
+ const branchSegments = pointsToSegments(branchPoints);
+ if (branchSegments.length) {
+ routes.push(branchSegments);
+ addUsageForSegments(context.edgeUsage, context.pointUsage, net.name, branchSegments);
+ for (const seg of branchSegments) {
+ for (const p of segmentStepPoints(seg.a, seg.b)) {
+ treePoints.set(pointKey(p), p);
+ }
+ }
+ }
+ }
+
+ const allPathPoints = routes.flatMap((route) => route.flatMap((seg) => [seg.a, seg.b]));
+ const centroid = allPathPoints.length
+ ? {
+ x: allPathPoints.reduce((sum, p) => sum + p.x, 0) / allPathPoints.length,
+ y: allPathPoints.reduce((sum, p) => sum + p.y, 0) / allPathPoints.length
+ }
+ : labelPointForPin(source);
+
+ const labelPoints = [labelPointForPin(source)];
+ if (sorted.length >= 3) {
+ labelPoints.push({ x: toGrid(centroid.x + GRID * 0.4), y: toGrid(centroid.y - GRID * 0.6) });
+ }
+ const junctionPoints = computeJunctionPoints(routes);
+ if (sorted.length >= 3 && junctionPoints.length === 0) {
+ junctionPoints.push({ x: source.exit.x, y: source.exit.y });
+ }
+
+ return {
+ mode: "routed",
+ routes,
+ labelPoints,
+ tiePoints: [],
+ junctionPoints
+ };
+}
+
+function detectBusGroups(nets) {
+ const groups = new Map();
+ for (const net of nets) {
+ const match = /^([A-Za-z0-9]+)_/.exec(net.name);
+ if (!match) {
+ continue;
+ }
+ const key = match[1].toUpperCase();
+ const list = groups.get(key) ?? [];
+ list.push(net.name);
+ groups.set(key, list);
+ }
+
+ const out = [];
+ for (const [name, members] of groups.entries()) {
+ if (members.length >= 2) {
+ out.push({ name, nets: members.sort() });
+ }
+ }
+
+ out.sort((a, b) => a.name.localeCompare(b.name));
+ return out;
+}
+
+function shouldUseLabelTie(net, pinNodes, context) {
+ if (context.renderMode === "explicit") {
+ return LABEL_TIE_CLASSES.has(net.class) && pinNodes.length > 2;
+ }
+
+ if (LABEL_TIE_CLASSES.has(net.class)) {
+ return true;
+ }
+
+ if (context.busNetNames.has(net.name)) {
+ return true;
+ }
+
+ return false;
+}
+
+function routeAllNets(model, placed, placedMap, bounds, options) {
+ const obstacles = buildObstacles(model, placed);
+ const edgeUsage = new Map();
+ const pointUsage = new Map();
+ const busGroups = detectBusGroups(model.nets);
+ const busNetNames = new Set(busGroups.flatMap((g) => g.nets));
+
+ const nets = [...model.nets].sort((a, b) => {
+ const pa = NET_CLASS_PRIORITY[a.class] ?? 99;
+ const pb = NET_CLASS_PRIORITY[b.class] ?? 99;
+ if (pa !== pb) {
+ return pa - pb;
+ }
+ return a.name.localeCompare(b.name);
});
- const width =
- Math.max(...placed.map((p) => p.placement.x + model.symbols[p.symbol].body.width), MARGIN_X * 2) + MARGIN_X;
- const height =
- Math.max(...placed.map((p) => p.placement.y + model.symbols[p.symbol].body.height), MARGIN_Y * 2) + MARGIN_Y;
+ const routedByName = new Map();
+
+ for (const net of nets) {
+ const pinNodes = net.nodes.map((node) => getNodePin(model, placedMap, node)).filter(Boolean);
+
+ const routeContext = {
+ bounds,
+ obstacles,
+ edgeUsage,
+ pointUsage,
+ renderMode: options.renderMode,
+ busNetNames
+ };
+
+ const routed = shouldUseLabelTie(net, pinNodes, routeContext)
+ ? routeLabelTieNet(net, pinNodes, routeContext)
+ : routePointToPointNet(net, pinNodes, routeContext);
+
+ routedByName.set(net.name, {
+ net,
+ isBusMember: busNetNames.has(net.name),
+ ...routed
+ });
+ }
+
+ const routed = model.nets.map(
+ (net) =>
+ routedByName.get(net.name) ?? {
+ net,
+ mode: "routed",
+ routes: [],
+ labelPoints: [],
+ tiePoints: [],
+ junctionPoints: []
+ }
+ );
+
+ return { routed, busGroups };
+}
+
+function lineIntersection(a1, a2, b1, b2) {
+ const aVertical = a1.x === a2.x;
+ const bVertical = b1.x === b2.x;
+
+ if (aVertical === bVertical) {
+ return null;
+ }
+
+ const v = aVertical ? [a1, a2] : [b1, b2];
+ const h = aVertical ? [b1, b2] : [a1, a2];
+
+ const vx = v[0].x;
+ const hy = h[0].y;
+ const vMinY = Math.min(v[0].y, v[1].y);
+ const vMaxY = Math.max(v[0].y, v[1].y);
+ const hMinX = Math.min(h[0].x, h[1].x);
+ const hMaxX = Math.max(h[0].x, h[1].x);
+
+ if (vx >= hMinX && vx <= hMaxX && hy >= vMinY && hy <= vMaxY) {
+ return { x: vx, y: hy };
+ }
+
+ return null;
+}
+
+function collectSegmentsByNet(routed) {
+ const out = [];
+ for (const rn of routed) {
+ for (const route of rn.routes) {
+ for (const seg of route) {
+ out.push({ net: rn.net.name, seg });
+ }
+ }
+ }
+ return out;
+}
+
+function countOverlapEdges(routed) {
+ const edgeNets = new Map();
+ for (const rn of routed) {
+ for (const route of rn.routes) {
+ for (const seg of route) {
+ const steps = segmentStepPoints(seg.a, seg.b);
+ for (let i = 1; i < steps.length; i += 1) {
+ const k = edgeKey(steps[i - 1], steps[i]);
+ const set = edgeNets.get(k) ?? new Set();
+ set.add(rn.net.name);
+ edgeNets.set(k, set);
+ }
+ }
+ }
+ }
+
+ let overlaps = 0;
+ for (const nets of edgeNets.values()) {
+ if (nets.size > 1) {
+ overlaps += 1;
+ }
+ }
+ return overlaps;
+}
+
+function countCrossings(routed) {
+ const segments = collectSegmentsByNet(routed);
+ let crossings = 0;
+
+ for (let i = 0; i < segments.length; i += 1) {
+ const a = segments[i];
+ for (let j = i + 1; j < segments.length; j += 1) {
+ const b = segments[j];
+ if (a.net === b.net) {
+ continue;
+ }
+ const hit = lineIntersection(a.seg.a, a.seg.b, b.seg.a, b.seg.b);
+ if (!hit) {
+ continue;
+ }
+ const atEndpoint =
+ pointKey(hit) === pointKey(a.seg.a) ||
+ pointKey(hit) === pointKey(a.seg.b) ||
+ pointKey(hit) === pointKey(b.seg.a) ||
+ pointKey(hit) === pointKey(b.seg.b);
+ if (!atEndpoint) {
+ crossings += 1;
+ }
+ }
+ }
+
+ return crossings;
+}
+
+function countLabelCollisions(routed) {
+ const labels = routed.flatMap((rn) => {
+ if (rn.isBusMember && rn.mode === "label_tie") {
+ return [];
+ }
+
+ const points = rn.labelPoints?.length ? [rn.labelPoints[0]] : [];
+ return points.map((p) => ({ ...p, net: rn.net.name }));
+ });
+ let collisions = 0;
+
+ for (let i = 0; i < labels.length; i += 1) {
+ for (let j = i + 1; j < labels.length; j += 1) {
+ const a = labels[i];
+ const b = labels[j];
+ if (Math.abs(a.x - b.x) < GRID * 2 && Math.abs(a.y - b.y) < GRID * 1.2) {
+ collisions += 1;
+ }
+ }
+ }
+
+ return collisions;
+}
+
+function computeLayoutMetrics(routed, busGroups) {
+ const segmentCount = routed.reduce(
+ (total, rn) => total + rn.routes.reduce((sum, route) => sum + route.length, 0),
+ 0
+ );
+ const tiePoints = routed.reduce((total, rn) => total + rn.tiePoints.length, 0);
+
+ return {
+ segment_count: segmentCount,
+ overlap_edges: countOverlapEdges(routed),
+ crossings: countCrossings(routed),
+ label_collisions: countLabelCollisions(routed),
+ tie_points_used: tiePoints,
+ bus_groups: busGroups.length
+ };
+}
+
+export function applyLayoutToModel(model, options = {}) {
+ const working = clone(model);
+ const respectLocks = options.respectLocks ?? true;
+ const autoRotate = options.autoRotate ?? true;
+
+ if (!respectLocks) {
+ for (const inst of working.instances) {
+ inst.placement.x = null;
+ inst.placement.y = null;
+ inst.placement.locked = false;
+ }
+ }
+
+ const { placed } = placeInstances(working, { respectLocks, autoRotate });
+ const byRef = new Map(placed.map((inst) => [inst.ref, inst]));
+
+ for (const inst of working.instances) {
+ const p = byRef.get(inst.ref);
+ if (!p) {
+ continue;
+ }
+ inst.placement.x = p.placement.x;
+ inst.placement.y = p.placement.y;
+ inst.placement.rotation = p.placement.rotation;
+ inst.placement.locked = p.placement.locked;
+ }
+
+ return working;
+}
+
+export function layoutAndRoute(model, options = {}) {
+ const renderMode = options.render_mode === "explicit" ? "explicit" : DEFAULT_RENDER_MODE;
+ const respectLocks = options.respect_locks ?? true;
+ const autoRotate = options.auto_rotate ?? true;
+
+ const { placed, placedMap } = placeInstances(model, { respectLocks, autoRotate });
+ const bounds = buildBounds(model, placed);
+ const { routed, busGroups } = routeAllNets(model, placed, placedMap, bounds, {
+ renderMode
+ });
return {
placed,
routed,
- width: toGrid(width),
- height: toGrid(height)
+ width: bounds.maxX,
+ height: bounds.maxY,
+ bus_groups: busGroups,
+ metrics: computeLayoutMetrics(routed, busGroups),
+ render_mode_used: renderMode
};
}
@@ -224,5 +1435,17 @@ export function netAnchorPoint(net, model, placed) {
if (!first) {
return null;
}
- return pointForNode(model, placed, first.ref, first.pin);
+
+ const inst = placed.find((x) => x.ref === first.ref);
+ if (!inst) {
+ return null;
+ }
+
+ const sym = model.symbols[inst.symbol];
+ const p = sym.pins.find((x) => x.name === first.pin);
+ if (!p) {
+ return null;
+ }
+
+ return pinPoint(inst, p, sym.body.width, sym.body.height);
}
diff --git a/src/mcp-server.js b/src/mcp-server.js
new file mode 100644
index 0000000..d592de7
--- /dev/null
+++ b/src/mcp-server.js
@@ -0,0 +1,226 @@
+import { compile, analyze } from "./compile.js";
+
+const SERVER_INFO = {
+ name: "schemeta-mcp",
+ version: "0.2.0"
+};
+
+let stdinBuffer = Buffer.alloc(0);
+
+function writeMessage(message) {
+ const json = JSON.stringify(message);
+ const body = Buffer.from(json, "utf8");
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "utf8");
+ process.stdout.write(Buffer.concat([header, body]));
+}
+
+function sendResult(id, result) {
+ writeMessage({ jsonrpc: "2.0", id, result });
+}
+
+function sendError(id, code, message, data = undefined) {
+ writeMessage({
+ jsonrpc: "2.0",
+ id,
+ error: { code, message, data }
+ });
+}
+
+function decodePayload(value) {
+ if (typeof value === "string") {
+ return JSON.parse(value);
+ }
+ if (value && typeof value === "object") {
+ return value;
+ }
+ throw new Error("payload must be a JSON object or JSON string");
+}
+
+function toolListResult() {
+ return {
+ tools: [
+ {
+ name: "schemeta_compile",
+ description: "Compile Schemeta JSON into SVG with ERC, metrics, and topology results.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ payload: {
+ description: "Schemeta model object or JSON string",
+ anyOf: [{ type: "object" }, { type: "string" }]
+ },
+ options: {
+ description: "Compile options (render_mode, show_labels, generic_symbols)",
+ type: "object"
+ }
+ },
+ required: ["payload"]
+ }
+ },
+ {
+ name: "schemeta_analyze",
+ description: "Analyze Schemeta JSON for ERC and topology without rendering SVG.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ payload: {
+ description: "Schemeta model object or JSON string",
+ anyOf: [{ type: "object" }, { type: "string" }]
+ },
+ options: {
+ description: "Analyze options (generic_symbols)",
+ type: "object"
+ }
+ },
+ required: ["payload"]
+ }
+ },
+ {
+ name: "schemeta_ui_bundle",
+ description: "Return Schemeta UI bundle metadata for iframe embedding.",
+ inputSchema: {
+ type: "object",
+ properties: {}
+ }
+ }
+ ]
+ };
+}
+
+function uiBundleDescriptor() {
+ return {
+ name: "schemeta-workspace",
+ version: "0.2.0",
+ entry: "/",
+ title: "Schemeta Workspace",
+ transport: "iframe"
+ };
+}
+
+function handleToolCall(name, args) {
+ if (!args || typeof args !== "object") {
+ args = {};
+ }
+
+ if (name === "schemeta_compile") {
+ const payload = decodePayload(args.payload);
+ const result = compile(payload, args.options ?? {});
+ return {
+ content: [{ type: "text", text: JSON.stringify(result) }],
+ structuredContent: result,
+ isError: false
+ };
+ }
+
+ if (name === "schemeta_analyze") {
+ const payload = decodePayload(args.payload);
+ const result = analyze(payload, args.options ?? {});
+ return {
+ content: [{ type: "text", text: JSON.stringify(result) }],
+ structuredContent: result,
+ isError: false
+ };
+ }
+
+ if (name === "schemeta_ui_bundle") {
+ const result = uiBundleDescriptor();
+ return {
+ content: [{ type: "text", text: JSON.stringify(result) }],
+ structuredContent: result,
+ isError: false
+ };
+ }
+
+ throw new Error(`Unknown tool '${name}'`);
+}
+
+function handleRequest(message) {
+ const { id, method, params } = message;
+
+ if (method === "initialize") {
+ return sendResult(id, {
+ protocolVersion: "2024-11-05",
+ capabilities: { tools: {} },
+ serverInfo: SERVER_INFO
+ });
+ }
+
+ if (method === "notifications/initialized") {
+ return;
+ }
+
+ if (method === "ping") {
+ return sendResult(id, {});
+ }
+
+ if (method === "tools/list") {
+ return sendResult(id, toolListResult());
+ }
+
+ if (method === "tools/call") {
+ try {
+ const name = params?.name;
+ const args = params?.arguments ?? {};
+ const result = handleToolCall(name, args);
+ return sendResult(id, result);
+ } catch (err) {
+ return sendError(id, -32602, err.message);
+ }
+ }
+
+ if (id !== undefined) {
+ return sendError(id, -32601, `Method not found: ${method}`);
+ }
+}
+
+function tryParseBuffer() {
+ while (true) {
+ const headerEnd = stdinBuffer.indexOf("\r\n\r\n");
+ if (headerEnd === -1) {
+ return;
+ }
+
+ const headerText = stdinBuffer.slice(0, headerEnd).toString("utf8");
+ const headers = headerText.split("\r\n");
+ const lengthLine = headers.find((h) => h.toLowerCase().startsWith("content-length:"));
+ if (!lengthLine) {
+ stdinBuffer = Buffer.alloc(0);
+ return;
+ }
+
+ const contentLength = Number(lengthLine.split(":")[1].trim());
+ const totalLength = headerEnd + 4 + contentLength;
+ if (stdinBuffer.length < totalLength) {
+ return;
+ }
+
+ const body = stdinBuffer.slice(headerEnd + 4, totalLength).toString("utf8");
+ stdinBuffer = stdinBuffer.slice(totalLength);
+
+ let message;
+ try {
+ message = JSON.parse(body);
+ } catch {
+ continue;
+ }
+
+ try {
+ handleRequest(message);
+ } catch (err) {
+ if (message?.id !== undefined) {
+ sendError(message.id, -32603, "Internal error", err.message);
+ }
+ }
+ }
+}
+
+process.stdin.on("data", (chunk) => {
+ stdinBuffer = Buffer.concat([stdinBuffer, Buffer.from(chunk)]);
+ tryParseBuffer();
+});
+
+process.stdin.on("error", (err) => {
+ process.stderr.write(`stdin error: ${String(err)}\n`);
+});
+
+process.stdin.resume();
diff --git a/src/render.js b/src/render.js
index 64ff940..c763786 100644
--- a/src/render.js
+++ b/src/render.js
@@ -1,5 +1,17 @@
import { layoutAndRoute, netAnchorPoint } from "./layout.js";
+const GRID = 20;
+
+const NET_COLORS = {
+ power: "#b54708",
+ ground: "#344054",
+ signal: "#1d4ed8",
+ analog: "#0f766e",
+ differential: "#c11574",
+ clock: "#b93815",
+ bus: "#155eef"
+};
+
function esc(text) {
return String(text)
.replaceAll("&", "&")
@@ -8,47 +20,425 @@ function esc(text) {
.replaceAll('"', """);
}
-export function renderSvg(model) {
- const layout = layoutAndRoute(model);
+function normalizeRotation(value) {
+ const n = Number(value ?? 0);
+ if (!Number.isFinite(n)) {
+ return 0;
+ }
+ const snapped = Math.round(n / 90) * 90;
+ let rot = snapped % 360;
+ if (rot < 0) {
+ rot += 360;
+ }
+ return rot;
+}
+
+function rotatePoint(point, center, rotation) {
+ if (!rotation) {
+ return point;
+ }
+ const rad = (rotation * Math.PI) / 180;
+ const cos = Math.round(Math.cos(rad));
+ const sin = Math.round(Math.sin(rad));
+ const dx = point.x - center.x;
+ const dy = point.y - center.y;
+ return {
+ x: Math.round(center.x + dx * cos - dy * sin),
+ y: Math.round(center.y + dx * sin + dy * cos)
+ };
+}
+
+function rotateSide(side, rotation) {
+ const steps = normalizeRotation(rotation) / 90;
+ const order = ["top", "right", "bottom", "left"];
+ const idx = order.indexOf(side);
+ if (idx < 0) {
+ return side;
+ }
+ return order[(idx + steps) % 4];
+}
+
+function truncate(text, max) {
+ const s = String(text ?? "");
+ if (s.length <= max) {
+ return s;
+ }
+ return `${s.slice(0, Math.max(1, max - 1))}...`;
+}
+
+function netColor(netClass) {
+ return NET_COLORS[netClass] ?? NET_COLORS.signal;
+}
+
+function symbolTemplateKind(sym) {
+ const t = String(sym?.template_name ?? "").toLowerCase();
+ if (["resistor", "capacitor", "inductor", "diode", "led", "connector"].includes(t)) {
+ return t;
+ }
+ const c = String(sym?.category ?? "").toLowerCase();
+ if (c.includes("resistor")) return "resistor";
+ if (c.includes("capacitor")) return "capacitor";
+ if (c.includes("inductor")) return "inductor";
+ if (c.includes("diode")) return "diode";
+ if (c.includes("led")) return "led";
+ if (c.includes("connector")) return "connector";
+ return null;
+}
+
+function renderSymbolBody(sym, x, y, width, height) {
+ const kind = symbolTemplateKind(sym);
+ if (!kind) {
+ return ``;
+ }
+
+ const midX = x + width / 2;
+ const midY = y + height / 2;
+ const left = x + 16;
+ const right = x + width - 16;
+ const top = y + 14;
+ const bottom = y + height - 14;
+ const body = [];
+ body.push(``);
+
+ if (kind === "resistor") {
+ const y0 = midY;
+ const pts = [
+ [left, y0],
+ [left + 16, y0 - 10],
+ [left + 28, y0 + 10],
+ [left + 40, y0 - 10],
+ [left + 52, y0 + 10],
+ [left + 64, y0 - 10],
+ [right, y0]
+ ];
+ body.push(``);
+ } else if (kind === "capacitor") {
+ body.push(``);
+ body.push(``);
+ body.push(``);
+ body.push(``);
+ } else if (kind === "inductor") {
+ body.push(``);
+ for (let i = 0; i < 4; i += 1) {
+ const cx = left + 18 + i * 16;
+ body.push(``);
+ }
+ body.push(``);
+ } else if (kind === "diode" || kind === "led") {
+ const triLeft = left + 12;
+ const triRight = midX + 6;
+ body.push(``);
+ body.push(``);
+ body.push(``);
+ body.push(``);
+ if (kind === "led") {
+ body.push(``);
+ body.push(``);
+ }
+ } else if (kind === "connector") {
+ body.push(``);
+ body.push(``);
+ body.push(``);
+ }
+
+ return body.join("");
+}
+
+function pinNetMap(model) {
+ const map = new Map();
+ for (const net of model.nets) {
+ for (const node of net.nodes) {
+ const key = `${node.ref}.${node.pin}`;
+ const list = map.get(key) ?? [];
+ list.push(net.name);
+ map.set(key, list);
+ }
+ }
+ return map;
+}
+
+function renderWirePath(pathD, netName, netClass) {
+ const color = netColor(netClass);
+ return [
+ ``,
+ ``
+ ].join("");
+}
+
+function renderNetLabel(x, y, netName, netClass, bold = false) {
+ const color = netColor(netClass);
+ const weight = bold ? "700" : "600";
+ return `${esc(netName)}`;
+}
+
+function isGroundLikeNet(net) {
+ const cls = String(net?.class ?? "").trim().toLowerCase();
+ if (cls === "ground") {
+ return true;
+ }
+ const name = String(net?.name ?? "").trim().toLowerCase();
+ return name === "gnd" || name === "ground" || name.endsWith("_gnd");
+}
+
+function tieLabelPoint(point, netClass) {
+ if (netClass === "power") {
+ return { x: point.x + 8, y: point.y - 10 };
+ }
+ return { x: point.x + 8, y: point.y - 8 };
+}
+
+function distance(a, b) {
+ return Math.hypot(a.x - b.x, a.y - b.y);
+}
+
+function pickLabelPoints(points, maxCount, used, minSpacing, avoidPoints = []) {
+ const accepted = [];
+
+ for (const p of points) {
+ if (accepted.length >= maxCount) {
+ break;
+ }
+
+ let blocked = false;
+ for (const prev of used) {
+ if (distance(p, prev) < minSpacing) {
+ blocked = true;
+ break;
+ }
+ }
+ if (blocked) {
+ continue;
+ }
+
+ for (const pin of avoidPoints) {
+ if (distance(p, pin) < minSpacing * 0.9) {
+ blocked = true;
+ break;
+ }
+ }
+ if (blocked) {
+ continue;
+ }
+
+ accepted.push(p);
+ used.push(p);
+ }
+
+ return accepted;
+}
+
+function renderGroundSymbol(x, y, netName) {
+ const y0 = y + 3;
+ const y1 = y + 7;
+ const y2 = y + 10;
+ const y3 = y + 13;
+ return `
+
+
+
+
+
+`;
+}
+
+function renderPowerSymbol(x, y, netName) {
+ return `
+
+
+
+`;
+}
+
+function renderGenericTie(x, y, netName, netClass) {
+ const color = netColor(netClass);
+ return ``;
+}
+
+function renderTieSymbol(x, y, netName, netClass) {
+ if (netClass === "ground") {
+ return renderGroundSymbol(x, y, netName);
+ }
+ if (netClass === "power") {
+ return renderPowerSymbol(x, y, netName);
+ }
+ return renderGenericTie(x, y, netName, netClass);
+}
+
+function representativePoint(routeInfo, netAnchor) {
+ if (routeInfo.labelPoints?.length) {
+ return routeInfo.labelPoints[0];
+ }
+ if (routeInfo.tiePoints?.length) {
+ return routeInfo.tiePoints[0];
+ }
+ if (routeInfo.routes?.length && routeInfo.routes[0].length) {
+ const seg = routeInfo.routes[0][0];
+ return {
+ x: (seg.a.x + seg.b.x) / 2,
+ y: (seg.a.y + seg.b.y) / 2
+ };
+ }
+ return netAnchor;
+}
+
+function renderLegend() {
+ const entries = [
+ ["power", "Power"],
+ ["ground", "Ground"],
+ ["clock", "Clock"],
+ ["signal", "Signal"],
+ ["analog", "Analog"]
+ ];
+
+ const rows = entries
+ .map(
+ ([cls, label], idx) =>
+ `${label}`
+ )
+ .join("");
+
+ return `
+
+
+ ${rows}
+`;
+}
+
+export function renderSvgFromLayout(model, layout, options = {}) {
+ const showLabels = options.show_labels !== false;
+ const pinNets = pinNetMap(model);
+ const netClassByName = new Map((model.nets ?? []).map((n) => [n.name, n.class]));
+ const allPinPoints = [];
const components = layout.placed
.map((inst) => {
const sym = model.symbols[inst.symbol];
const x = inst.placement.x;
const y = inst.placement.y;
+ const rotation = normalizeRotation(inst.placement.rotation ?? 0);
+ const cx = x + sym.body.width / 2;
+ const cy = y + sym.body.height / 2;
+ const templateKind = symbolTemplateKind(sym);
+ const compactLabel = templateKind || sym.body.width <= 140 || sym.body.height <= 90;
+ const legacyShowInstanceNetLabels = Boolean(inst.properties?.show_net_labels);
+ const pinUi =
+ inst.properties?.pin_ui && typeof inst.properties.pin_ui === "object" && !Array.isArray(inst.properties.pin_ui)
+ ? inst.properties.pin_ui
+ : {};
- const pinSvg = sym.pins
- .map((pin) => {
- let px = x;
- let py = y;
+ const pinCircles = [];
+ const pinLabels = [];
+ const instanceNetLabels = [];
+ for (const pin of sym.pins) {
+ let px = x;
+ let py = y;
- if (pin.side === "left") {
- px = x;
- py = y + pin.offset;
- } else if (pin.side === "right") {
- px = x + sym.body.width;
- py = y + pin.offset;
- } else if (pin.side === "top") {
- px = x + pin.offset;
- py = y;
- } else {
- px = x + pin.offset;
- py = y + sym.body.height;
+ if (pin.side === "left") {
+ px = x;
+ py = y + pin.offset;
+ } else if (pin.side === "right") {
+ px = x + sym.body.width;
+ py = y + pin.offset;
+ } else if (pin.side === "top") {
+ px = x + pin.offset;
+ py = y;
+ } else {
+ px = x + pin.offset;
+ py = y + sym.body.height;
+ }
+
+ pinCircles.push(
+ ``
+ );
+
+ const rotated = rotatePoint({ x: px, y: py }, { x: cx, y: cy }, rotation);
+ const rx = rotated.x;
+ const ry = rotated.y;
+ const rotatedSide = rotateSide(pin.side, rotation);
+
+ allPinPoints.push({ x: rx, y: ry });
+ let labelX = rx + 6;
+ let labelY = ry - 4;
+ let textAnchor = "start";
+
+ if (rotatedSide === "right") {
+ labelX = rx - 6;
+ labelY = ry - 4;
+ textAnchor = "end";
+ } else if (rotatedSide === "top") {
+ labelX = rx + 4;
+ labelY = ry + 12;
+ textAnchor = "start";
+ } else if (rotatedSide === "bottom") {
+ labelX = rx + 4;
+ labelY = ry - 8;
+ textAnchor = "start";
+ }
+
+ const showPinLabel = !templateKind || !/^\d+$/.test(pin.name);
+ if (showPinLabel) {
+ pinLabels.push(
+ `${esc(pin.name)}`
+ );
+ }
+
+ const pinUiEntry = pinUi[pin.name];
+ const showPinNetLabel =
+ pinUiEntry && typeof pinUiEntry === "object" && Object.prototype.hasOwnProperty.call(pinUiEntry, "show_net_label")
+ ? Boolean(pinUiEntry.show_net_label)
+ : legacyShowInstanceNetLabels;
+ if (showPinNetLabel && showLabels) {
+ const nets = pinNets.get(`${inst.ref}.${pin.name}`) ?? [];
+ const displayNet = nets.find((n) => !isGroundLikeNet({ name: n, class: "" })) ?? nets[0];
+ if (displayNet) {
+ let netX = labelX;
+ let netY = labelY;
+ let netAnchor = textAnchor;
+
+ if (rotatedSide === "left") {
+ netX = rx - 12;
+ netY = ry - 10;
+ netAnchor = "end";
+ } else if (rotatedSide === "right") {
+ netX = rx + 12;
+ netY = ry - 10;
+ netAnchor = "start";
+ } else if (rotatedSide === "top") {
+ netX = rx + 8;
+ netY = ry - 10;
+ netAnchor = "start";
+ } else {
+ netX = rx + 8;
+ netY = ry + 14;
+ netAnchor = "start";
+ }
+
+ instanceNetLabels.push(
+ `${esc(displayNet)}`
+ );
}
+ }
+ }
- return [
- ``,
- `${esc(pin.name)}`
- ].join("");
- })
- .join("");
+ const pinLabelsSvg = [...pinLabels, ...instanceNetLabels].join("");
+ const pinCoreSvg = pinCircles.join("");
+
+ const rotationTransform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : "";
+
+ const refLabel = truncate(inst.ref, compactLabel ? 6 : 10);
+ const valueLabel = truncate(inst.properties?.value ?? inst.symbol, compactLabel ? 18 : 28);
+ const refY = compactLabel ? y - 6 : y + 18;
+ const valueY = compactLabel ? y + sym.body.height + 14 : y + 34;
return `
-
- ${esc(inst.ref)}
- ${esc(inst.properties?.value ?? inst.symbol)}
- ${pinSvg}
+
+ ${renderSymbolBody(sym, x, y, sym.body.width, sym.body.height)}
+ ${pinCoreSvg}
+
+ ${pinLabelsSvg}
+ ${esc(refLabel)}
+ ${esc(valueLabel)}
`;
})
.join("\n");
@@ -59,18 +449,107 @@ export function renderSvg(model) {
const path = route
.map((seg, idx) => `${idx === 0 ? "M" : "L"} ${seg.a.x} ${seg.a.y} L ${seg.b.x} ${seg.b.y}`)
.join(" ");
- return ``;
+ return renderWirePath(path, rn.net.name, rn.net.class);
})
)
.join("\n");
- const labels = model.nets
- .map((net) => {
- const p = netAnchorPoint(net, model, layout.placed);
- if (!p) {
+ const junctions = layout.routed
+ .flatMap((rn) =>
+ (rn.junctionPoints ?? []).map((p) => {
+ const color = netColor(rn.net.class);
+ return ``;
+ })
+ )
+ .join("\n");
+
+ const tiePoints = layout.routed
+ .flatMap((rn) =>
+ (rn.tiePoints ?? []).map((p) => renderTieSymbol(p.x, p.y, rn.net.name, rn.net.class))
+ )
+ .join("\n");
+
+ const routedByName = new Map(layout.routed.map((r) => [r.net.name, r]));
+ const usedLabelPoints = [];
+ const labels = [];
+ const tieLabels = [];
+
+ for (const net of model.nets) {
+ if (isGroundLikeNet(net)) {
+ continue;
+ }
+
+ const routeInfo = routedByName.get(net.name);
+ if (routeInfo?.isBusMember && routeInfo.mode === "label_tie") {
+ continue;
+ }
+
+ const netAnchor = netAnchorPoint(net, model, layout.placed);
+ const candidates = [];
+
+ if (routeInfo?.mode === "label_tie") {
+ candidates.push(...(routeInfo?.labelPoints ?? []));
+ const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
+ for (const p of selected) {
+ labels.push(renderNetLabel(p.x, p.y, net.name, net.class, true));
+ }
+ continue;
+ }
+
+ if (routeInfo?.labelPoints?.length) {
+ candidates.push(...routeInfo.labelPoints);
+ }
+ if (netAnchor) {
+ candidates.push({ x: netAnchor.x + 8, y: netAnchor.y - 8 });
+ }
+
+ const selected = pickLabelPoints(candidates, 1, usedLabelPoints, GRID * 2.4, allPinPoints);
+ for (const p of selected) {
+ labels.push(renderNetLabel(p.x, p.y, net.name, net.class));
+ }
+ }
+
+ if (showLabels) {
+ const usedTieLabels = [];
+ for (const rn of layout.routed) {
+ if (rn.mode !== "label_tie" || isGroundLikeNet(rn.net)) {
+ continue;
+ }
+
+ const candidates = (rn.tiePoints ?? []).map((p) => tieLabelPoint(p, rn.net.class));
+ if (!candidates.length) {
+ continue;
+ }
+
+ const maxPerNet = rn.net.class === "power" ? Math.min(6, candidates.length) : Math.min(2, candidates.length);
+ const selected = pickLabelPoints(candidates, maxPerNet, usedTieLabels, GRID * 1.5, allPinPoints);
+ for (const p of selected) {
+ tieLabels.push(renderNetLabel(p.x, p.y, rn.net.name, rn.net.class, true));
+ }
+ }
+ }
+
+ const busLabels = (layout.bus_groups ?? [])
+ .map((group) => {
+ const reps = group.nets
+ .map((netName) => {
+ const net = model.nets.find((n) => n.name === netName);
+ if (!net) {
+ return null;
+ }
+ const anchor = netAnchorPoint(net, model, layout.placed);
+ return representativePoint(routedByName.get(netName), anchor);
+ })
+ .filter(Boolean);
+
+ if (!reps.length) {
return "";
}
- return `${esc(net.name)}`;
+
+ const x = reps.reduce((sum, p) => sum + p.x, 0) / reps.length;
+ const y = reps.reduce((sum, p) => sum + p.y, 0) / reps.length;
+
+ return `${esc(group.name)} bus`;
})
.join("\n");
@@ -82,14 +561,25 @@ export function renderSvg(model) {
})
.join("\n");
+ const labelLayer = showLabels ? [...labels, ...tieLabels].join("\n") : "";
+
return `
-