From f2d48cee856198292a466a7dde3fd3cb77165287 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Mon, 16 Feb 2026 21:44:58 -0500 Subject: [PATCH] Expand Schemeta frontend editor with pin/net/symbol editing and schema updates --- README.md | 159 ++- frontend/app.js | 2372 +++++++++++++++++++++++++++++++++ frontend/index.html | 238 ++++ frontend/sample.schemeta.json | 91 ++ frontend/schemeta.schema.json | 288 ++++ frontend/styles.css | 443 ++++++ package.json | 3 +- src/analyze.js | 72 +- src/compile.js | 291 +++- src/layout.js | 1495 +++++++++++++++++++-- src/mcp-server.js | 226 ++++ src/render.js | 564 +++++++- src/server.js | 216 ++- src/validate.js | 524 +++++++- tests/compile.test.js | 340 +++++ 15 files changed, 7078 insertions(+), 244 deletions(-) create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/sample.schemeta.json create mode 100644 frontend/schemeta.schema.json create mode 100644 frontend/styles.css create mode 100644 src/mcp-server.js 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

    +

    AI-Native Schematic Workspace

    +
    +
    + + + + + + + + +
    +
    + +
    + + +
    +
    + + + + + + Idle +
    +
    +
    + + +
    +
    + + +
    + + + + + + 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 ` - + - + ${components} ${wires} - ${labels} + ${junctions} + ${tiePoints} + ${labelLayer} + ${busLabels} ${annotations} + ${renderLegend()} `; } + +export function renderSvg(model, options = {}) { + const layout = layoutAndRoute(model, options); + return renderSvgFromLayout(model, layout, options); +} diff --git a/src/server.js b/src/server.js index 6f2866a..63b9314 100644 --- a/src/server.js +++ b/src/server.js @@ -1,17 +1,62 @@ import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; import { analyze, compile } from "./compile.js"; +import { applyLayoutToModel } from "./layout.js"; +import { validateModel } from "./validate.js"; const PORT = Number(process.env.PORT ?? "8787"); +const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 2 * 1024 * 1024); +const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*"; +const FRONTEND_ROOT = join(process.cwd(), "frontend"); + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png" +}; + +function setCors(res) { + res.setHeader("Access-Control-Allow-Origin", CORS_ORIGIN); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); +} function json(res, status, payload) { - res.writeHead(status, { "Content-Type": "application/json" }); + setCors(res); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload, null, 2)); } +function errorEnvelope(code, message) { + return { + ok: false, + error: { + code, + message + } + }; +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; - req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + let size = 0; + + req.on("data", (chunk) => { + const c = Buffer.from(chunk); + size += c.length; + if (size > MAX_BODY_BYTES) { + reject(Object.assign(new Error("Payload too large"), { code: "PAYLOAD_TOO_LARGE" })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { if (chunks.length === 0) { resolve({}); @@ -21,46 +66,183 @@ function readBody(req) { try { const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); resolve(value); - } catch (err) { - reject(err); + } catch { + reject(Object.assign(new Error("Malformed JSON"), { code: "INVALID_JSON" })); } }); + req.on("error", reject); }); } -const server = createServer(async (req, res) => { - if (!req.url || !req.method) { - return json(res, 400, { error: "Invalid request." }); +function sanitizePath(urlPath) { + if (urlPath === "/") { + return "index.html"; } - if (req.method === "GET" && req.url === "/health") { + const clean = normalize(urlPath).replace(/^\/+/, ""); + if (clean.includes("..")) { + return null; + } + + return clean; +} + +async function serveStatic(urlPath, res) { + const cleanPath = sanitizePath(urlPath); + if (!cleanPath) { + json(res, 400, errorEnvelope("invalid_path", "Invalid static file path.")); + return true; + } + + const filePath = join(FRONTEND_ROOT, cleanPath); + + try { + const content = await readFile(filePath); + const type = MIME_TYPES[extname(filePath)] ?? "application/octet-stream"; + setCors(res); + res.writeHead(200, { "Content-Type": type }); + res.end(content); + return true; + } catch { + return false; + } +} + +function parsePayloadOptions(body) { + if (body && typeof body === "object" && Object.prototype.hasOwnProperty.call(body, "payload")) { + return { + payload: body.payload, + options: body.options ?? {} + }; + } + + return { + payload: body, + options: {} + }; +} + +const server = createServer(async (req, res) => { + if (!req.url || !req.method) { + return json(res, 400, errorEnvelope("invalid_request", "Invalid request.")); + } + + const pathname = new URL(req.url, "http://localhost").pathname; + + if (req.method === "OPTIONS") { + setCors(res); + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET" && pathname === "/health") { return json(res, 200, { + ok: true, service: "schemeta", status: "ok", date: new Date().toISOString() }); } - if (req.method === "POST" && req.url === "/analyze") { + if (req.method === "GET" && pathname === "/mcp/ui-bundle") { + return json(res, 200, { + ok: true, + name: "schemeta-workspace", + version: "0.2.0", + entry: "/", + title: "Schemeta Workspace", + transport: "iframe" + }); + } + + if (req.method === "POST" && pathname === "/analyze") { try { const body = await readBody(req); - return json(res, 200, analyze(body)); - } catch { - return json(res, 400, { error: "Invalid JSON payload." }); + const parsed = parsePayloadOptions(body); + return json(res, 200, analyze(parsed.payload, parsed.options)); + } catch (err) { + if (err?.code === "PAYLOAD_TOO_LARGE") { + return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); + } + if (err?.code === "INVALID_JSON") { + return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); + } + return json(res, 500, errorEnvelope("internal_error", "Request failed.")); } } - if (req.method === "POST" && req.url === "/compile") { + if (req.method === "POST" && pathname === "/compile") { try { const body = await readBody(req); - return json(res, 200, compile(body)); - } catch { - return json(res, 400, { error: "Invalid JSON payload." }); + const parsed = parsePayloadOptions(body); + return json(res, 200, compile(parsed.payload, parsed.options)); + } catch (err) { + if (err?.code === "PAYLOAD_TOO_LARGE") { + return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); + } + if (err?.code === "INVALID_JSON") { + return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); + } + return json(res, 500, errorEnvelope("internal_error", "Request failed.")); } } - return json(res, 404, { error: "Not found." }); + if (req.method === "POST" && pathname === "/layout/auto") { + try { + const body = await readBody(req); + const parsed = parsePayloadOptions(body); + const validated = validateModel(parsed.payload, parsed.options); + const model = validated.model ?? parsed.payload; + const laidOut = applyLayoutToModel(model, { respectLocks: false }); + return json(res, 200, { + ok: true, + model: laidOut, + compile: compile(laidOut, parsed.options) + }); + } catch (err) { + if (err?.code === "PAYLOAD_TOO_LARGE") { + return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); + } + if (err?.code === "INVALID_JSON") { + return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); + } + return json(res, 500, errorEnvelope("internal_error", "Layout auto failed.")); + } + } + + if (req.method === "POST" && pathname === "/layout/tidy") { + try { + const body = await readBody(req); + const parsed = parsePayloadOptions(body); + const validated = validateModel(parsed.payload, parsed.options); + const model = validated.model ?? parsed.payload; + const laidOut = applyLayoutToModel(model, { respectLocks: true }); + return json(res, 200, { + ok: true, + model: laidOut, + compile: compile(laidOut, parsed.options) + }); + } catch (err) { + if (err?.code === "PAYLOAD_TOO_LARGE") { + return json(res, 413, errorEnvelope("payload_too_large", `Request body exceeds ${MAX_BODY_BYTES} bytes.`)); + } + if (err?.code === "INVALID_JSON") { + return json(res, 400, errorEnvelope("invalid_json", "Invalid JSON payload.")); + } + return json(res, 500, errorEnvelope("internal_error", "Layout tidy failed.")); + } + } + + if (req.method === "GET") { + const served = await serveStatic(pathname, res); + if (served) { + return; + } + } + + return json(res, 404, errorEnvelope("not_found", "Not found.")); }); server.listen(PORT, () => { diff --git a/src/validate.js b/src/validate.js index 351940c..1f945e6 100644 --- a/src/validate.js +++ b/src/validate.js @@ -18,9 +18,505 @@ const VALID_NET_CLASSES = new Set([ "clock", "bus" ]); +const BUILTIN_PART_TYPES = new Set(["resistor", "capacitor", "inductor", "diode", "led", "connector", "generic"]); -export function validateModel(model) { +const GENERIC_DEFAULT_WIDTH = 160; +const GENERIC_MIN_HEIGHT = 120; +const GENERIC_PIN_STEP = 18; +const TEMPLATE_PIN_STEP = 24; + +const TEMPLATE_DEFS = { + resistor: { + category: "passive_resistor", + body: { width: 120, height: 70 }, + pins: [ + { name: "1", side: "left", offset: 35, type: "passive" }, + { name: "2", side: "right", offset: 35, type: "passive" } + ] + }, + capacitor: { + category: "passive_capacitor", + body: { width: 120, height: 70 }, + pins: [ + { name: "1", side: "left", offset: 35, type: "passive" }, + { name: "2", side: "right", offset: 35, type: "passive" } + ] + }, + inductor: { + category: "passive_inductor", + body: { width: 120, height: 70 }, + pins: [ + { name: "1", side: "left", offset: 35, type: "passive" }, + { name: "2", side: "right", offset: 35, type: "passive" } + ] + }, + diode: { + category: "passive_diode", + body: { width: 120, height: 70 }, + pins: [ + { name: "A", side: "left", offset: 35, type: "passive" }, + { name: "K", side: "right", offset: 35, type: "passive" } + ] + }, + led: { + category: "passive_led", + body: { width: 120, height: 70 }, + pins: [ + { name: "A", side: "left", offset: 35, type: "passive" }, + { name: "K", side: "right", offset: 35, type: "passive" } + ] + }, + connector: { + category: "connector_generic", + body: { width: 140, height: 90 }, + pins: [ + { name: "1", side: "left", offset: 24, type: "passive" }, + { name: "2", side: "left", offset: 48, type: "passive" }, + { name: "3", side: "right", offset: 24, type: "passive" }, + { name: "4", side: "right", offset: 48, type: "passive" } + ] + } +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function hasTemplateName(sym) { + const name = String(sym?.template_name ?? "").toLowerCase(); + return Object.prototype.hasOwnProperty.call(TEMPLATE_DEFS, name); +} + +function normalizePartName(value) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +function symbolIdForPart(partName) { + return `__part_${partName}`; +} + +function isGenericSymbol(sym) { + if (!sym || typeof sym !== "object") { + return false; + } + if (sym.auto_generated === true) { + return true; + } + const category = String(sym.category ?? "").toLowerCase(); + return category.includes("generic"); +} + +function pinTypeFromNet(netClass) { + if (netClass === "ground") { + return "ground"; + } + if (netClass === "power") { + return "power_in"; + } + if (netClass === "clock") { + return "input"; + } + if (netClass === "analog") { + return "analog"; + } + return "passive"; +} + +function inferSymbolTemplate(inst) { + const ref = String(inst.ref ?? "").toUpperCase(); + const symbol = String(inst.symbol ?? "").toLowerCase(); + const value = String(inst.properties?.value ?? "").toLowerCase(); + const haystack = `${symbol} ${value}`; + + if (ref.startsWith("R") || /\bres(istor)?\b/.test(haystack)) { + return "resistor"; + } + if (ref.startsWith("C") || /\bcap(acitor)?\b/.test(haystack)) { + return "capacitor"; + } + if (ref.startsWith("L") || /\bind(uctor)?\b/.test(haystack)) { + return "inductor"; + } + if (ref.startsWith("D") || /\bdiod(e)?\b/.test(haystack)) { + return "diode"; + } + if (/\bled\b/.test(haystack)) { + return "led"; + } + if (ref.startsWith("J") || ref.startsWith("P") || /\b(conn(ector)?|header)\b/.test(haystack)) { + return "connector"; + } + return null; +} + +function buildTemplateSymbol(symbolId, templateName) { + const template = TEMPLATE_DEFS[templateName]; + if (!template) { + return null; + } + + const pins = template.pins.map((p, idx) => ({ + name: p.name, + number: String(idx + 1), + side: p.side, + offset: p.offset, + type: p.type + })); + + return { + symbol_id: symbolId, + category: template.category, + auto_generated: true, + template_name: templateName, + body: { + width: template.body.width, + height: template.body.height + }, + pins, + graphics: { + primitives: [ + { + type: "rect", + x: 0, + y: 0, + w: template.body.width, + h: template.body.height + } + ] + } + }; +} + +function buildUsageByRef(model) { + const usage = new Map(); + for (const net of model.nets) { + for (const node of net.nodes ?? []) { + const item = usage.get(node.ref) ?? new Map(); + const entry = item.get(node.pin) ?? { pin: node.pin, classes: new Set() }; + entry.classes.add(net.class); + item.set(node.pin, entry); + usage.set(node.ref, item); + } + } + return usage; +} + +function buildUsageBySymbol(model) { + const byRef = new Map((model.instances ?? []).map((inst) => [inst.ref, inst])); + const usage = new Map(); + + for (const net of model.nets ?? []) { + for (const node of net.nodes ?? []) { + const inst = byRef.get(node.ref); + if (!inst) { + continue; + } + const symbolId = inst.symbol; + const symbolUsage = usage.get(symbolId) ?? new Map(); + const entry = symbolUsage.get(node.pin) ?? { pin: node.pin, classes: new Set() }; + entry.classes.add(net.class); + symbolUsage.set(node.pin, entry); + usage.set(symbolId, symbolUsage); + } + } + + return usage; +} + +function pinCountToHeight(pinCount) { + const rows = Math.max(4, pinCount + 1); + return Math.max(GENERIC_MIN_HEIGHT, rows * GENERIC_PIN_STEP); +} + +function buildPinsFromUsage(ref, pinUsage) { + const names = [...pinUsage.values()].map((x) => x.pin).sort((a, b) => a.localeCompare(b)); + if (!names.length) { + return [ + { + name: "P1", + number: "1", + side: "left", + offset: GENERIC_PIN_STEP, + type: "passive" + } + ]; + } + + const left = names.filter((_, idx) => idx % 2 === 0); + const right = names.filter((_, idx) => idx % 2 === 1); + let leftOffset = GENERIC_PIN_STEP; + let rightOffset = GENERIC_PIN_STEP; + + const ordered = [...left, ...right]; + const pins = []; + for (let i = 0; i < ordered.length; i += 1) { + const name = ordered[i]; + const classes = [...(pinUsage.get(name)?.classes ?? new Set())].sort(); + const preferredClass = classes[0] ?? "signal"; + const side = left.includes(name) ? "left" : "right"; + const offset = side === "left" ? leftOffset : rightOffset; + if (side === "left") { + leftOffset += GENERIC_PIN_STEP; + } else { + rightOffset += GENERIC_PIN_STEP; + } + + pins.push({ + name, + number: String(i + 1), + side, + offset, + type: pinTypeFromNet(preferredClass) + }); + } + + return pins; +} + +function ensureGenericSymbols(model, issues, enableGenericSymbols) { + if (!enableGenericSymbols) { + return model; + } + + const next = clone(model); + next.symbols = next.symbols ?? {}; + const usageByRef = buildUsageByRef(next); + + for (const inst of next.instances ?? []) { + const part = normalizePartName(inst.part); + if (!part) { + continue; + } + + if (!BUILTIN_PART_TYPES.has(part)) { + issues.push({ + code: "invalid_part_type", + message: `Instance '${inst.ref}' uses unsupported part '${inst.part}'.`, + severity: "error", + path: `instances.${inst.ref}.part` + }); + continue; + } + + inst.part = part; + if (!inst.symbol) { + inst.symbol = symbolIdForPart(part); + } + + if (next.symbols[inst.symbol]) { + continue; + } + + if (part === "generic") { + next.symbols[inst.symbol] = { + symbol_id: inst.symbol, + category: "generic", + auto_generated: true + }; + continue; + } + + const templateSymbol = buildTemplateSymbol(inst.symbol, part); + if (templateSymbol) { + next.symbols[inst.symbol] = templateSymbol; + } + } + + for (const inst of next.instances ?? []) { + if (next.symbols[inst.symbol]) { + continue; + } + + const templateName = inferSymbolTemplate(inst); + const templated = templateName ? buildTemplateSymbol(inst.symbol, templateName) : null; + + if (templated) { + next.symbols[inst.symbol] = templated; + issues.push({ + code: "auto_template_symbol_created", + message: `Created '${templateName}' symbol '${inst.symbol}' for instance '${inst.ref}'.`, + severity: "warning", + path: `instances.${inst.ref}.symbol` + }); + continue; + } + + const pinUsage = usageByRef.get(inst.ref) ?? new Map(); + const pins = buildPinsFromUsage(inst.ref, pinUsage); + + next.symbols[inst.symbol] = { + symbol_id: inst.symbol, + category: "generic", + auto_generated: true, + body: { + width: GENERIC_DEFAULT_WIDTH, + height: pinCountToHeight(pins.length) + }, + pins, + graphics: { + primitives: [ + { + type: "rect", + x: 0, + y: 0, + w: GENERIC_DEFAULT_WIDTH, + h: pinCountToHeight(pins.length) + } + ] + } + }; + + issues.push({ + code: "auto_generic_symbol_created", + message: `Created generic symbol '${inst.symbol}' for instance '${inst.ref}'.`, + severity: "warning", + path: `instances.${inst.ref}.symbol` + }); + } + + const usageBySymbol = buildUsageBySymbol(next); + for (const [id, sym] of Object.entries(next.symbols)) { + if (typeof sym !== "object" || !sym) { + continue; + } + + if (!sym.symbol_id) { + sym.symbol_id = id; + issues.push({ + code: "auto_symbol_id_filled", + message: `Filled missing symbol_id for '${id}'.`, + severity: "warning", + path: `symbols.${id}.symbol_id` + }); + } + + const templateName = String(sym.template_name ?? "").toLowerCase(); + if (hasTemplateName(sym)) { + const templated = buildTemplateSymbol(id, templateName); + let templateHydrated = false; + if (!sym.category) { + sym.category = templated.category; + issues.push({ + code: "auto_symbol_category_filled", + message: `Filled missing category for '${id}' from template '${templateName}'.`, + severity: "warning", + path: `symbols.${id}.category` + }); + } + if (!sym.body || sym.body.width == null || sym.body.height == null) { + sym.body = { ...(sym.body ?? {}), ...templated.body }; + templateHydrated = true; + } + if (!Array.isArray(sym.pins) || sym.pins.length === 0) { + sym.pins = templated.pins; + templateHydrated = true; + } + if (!sym.graphics) { + sym.graphics = templated.graphics; + } + if (templateHydrated) { + issues.push({ + code: "auto_template_symbol_hydrated", + message: `Hydrated template fields for '${id}' (${templateName}).`, + severity: "warning", + path: `symbols.${id}` + }); + } + continue; + } + + if (!sym.category) { + sym.category = "generic"; + issues.push({ + code: "auto_symbol_category_filled", + message: `Filled missing category for '${id}' as generic.`, + severity: "warning", + path: `symbols.${id}.category` + }); + } + + const genericCategory = String(sym.category ?? "").toLowerCase().includes("generic"); + if (!genericCategory) { + continue; + } + + let genericHydrated = false; + if (!Array.isArray(sym.pins) || sym.pins.length === 0) { + const pinUsage = usageBySymbol.get(id) ?? new Map(); + sym.pins = buildPinsFromUsage(id, pinUsage); + genericHydrated = true; + } + + if (!sym.body || sym.body.width == null || sym.body.height == null) { + sym.body = { + width: sym.body?.width ?? GENERIC_DEFAULT_WIDTH, + height: Math.max(sym.body?.height ?? GENERIC_MIN_HEIGHT, pinCountToHeight(sym.pins.length)) + }; + genericHydrated = true; + } + + if (genericHydrated) { + issues.push({ + code: "auto_generic_symbol_hydrated", + message: `Hydrated generic fields for '${id}' from net usage.`, + severity: "warning", + path: `symbols.${id}` + }); + } + } + + for (const net of next.nets ?? []) { + for (const node of net.nodes ?? []) { + const inst = (next.instances ?? []).find((x) => x.ref === node.ref); + if (!inst) { + continue; + } + const sym = next.symbols[inst.symbol]; + if (!sym || !isGenericSymbol(sym)) { + continue; + } + + const hasPin = Array.isArray(sym.pins) && sym.pins.some((p) => p.name === node.pin); + if (hasPin) { + continue; + } + + const side = (sym.pins?.length ?? 0) % 2 === 0 ? "left" : "right"; + const sameSideCount = (sym.pins ?? []).filter((p) => p.side === side).length; + const pinStep = sym.template_name ? TEMPLATE_PIN_STEP : GENERIC_PIN_STEP; + const offset = pinStep + sameSideCount * pinStep; + const nextNumber = String((sym.pins?.length ?? 0) + 1); + + sym.pins = sym.pins ?? []; + sym.pins.push({ + name: node.pin, + number: nextNumber, + side, + offset, + type: pinTypeFromNet(net.class) + }); + + sym.body = sym.body ?? { width: GENERIC_DEFAULT_WIDTH, height: GENERIC_MIN_HEIGHT }; + sym.body.width = sym.body.width ?? GENERIC_DEFAULT_WIDTH; + sym.body.height = Math.max(sym.body.height ?? GENERIC_MIN_HEIGHT, pinCountToHeight(sym.pins.length)); + + issues.push({ + code: "auto_generic_pin_created", + message: `Added pin '${node.pin}' to generic symbol '${inst.symbol}' from net '${net.name}'.`, + severity: "warning", + path: `symbols.${inst.symbol}.pins.${node.pin}` + }); + } + } + + return next; +} + +export function validateModel(model, options = {}) { const issues = []; + const enableGenericSymbols = options.generic_symbols !== false; if (!model || typeof model !== "object") { return { @@ -65,8 +561,10 @@ export function validateModel(model) { return { model: null, issues }; } - const symbolIds = new Set(Object.keys(model.symbols)); - for (const [id, sym] of Object.entries(model.symbols)) { + const workingModel = ensureGenericSymbols(model, issues, enableGenericSymbols); + const symbolIds = new Set(Object.keys(workingModel.symbols)); + + for (const [id, sym] of Object.entries(workingModel.symbols)) { if (sym.symbol_id !== id) { issues.push({ code: "symbol_id_mismatch", @@ -111,7 +609,7 @@ export function validateModel(model) { const refs = new Set(); const instanceSymbol = new Map(); - for (const inst of model.instances) { + for (const inst of workingModel.instances) { if (refs.has(inst.ref)) { issues.push({ code: "duplicate_ref", @@ -122,7 +620,14 @@ export function validateModel(model) { } refs.add(inst.ref); - if (!symbolIds.has(inst.symbol)) { + if (!inst.symbol) { + issues.push({ + code: "instance_symbol_or_part_missing", + message: `Instance '${inst.ref}' must define either 'symbol' or 'part'.`, + severity: "error", + path: `instances.${inst.ref}` + }); + } else if (!symbolIds.has(inst.symbol)) { issues.push({ code: "unknown_symbol", message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`, @@ -134,7 +639,7 @@ export function validateModel(model) { } const netNames = new Set(); - for (const net of model.nets) { + for (const net of workingModel.nets) { if (netNames.has(net.name)) { issues.push({ code: "duplicate_net_name", @@ -175,7 +680,10 @@ export function validateModel(model) { }); continue; } - const sym = model.symbols[symId]; + const sym = workingModel.symbols[symId]; + if (!sym || !Array.isArray(sym.pins)) { + continue; + } const foundPin = sym.pins.some((p) => p.name === node.pin); if (!foundPin) { issues.push({ @@ -189,7 +697,7 @@ export function validateModel(model) { } return { - model: issues.some((x) => x.severity === "error") ? null : model, + model: issues.some((x) => x.severity === "error") ? null : workingModel, issues }; } diff --git a/tests/compile.test.js b/tests/compile.test.js index 039971e..58514f8 100644 --- a/tests/compile.test.js +++ b/tests/compile.test.js @@ -9,6 +9,11 @@ test("compile returns svg and topology for valid model", () => { assert.ok(result.svg.includes(" { @@ -17,3 +22,338 @@ test("compile fails on invalid model", () => { assert.equal(result.ok, false); assert.ok(result.errors.length > 0); }); + +test("compile accepts render mode options", () => { + const result = compile(fixture, { render_mode: "explicit" }); + assert.equal(result.ok, true); + assert.equal(result.render_mode_used, "explicit"); +}); + +test("compile auto-creates generic symbols for unknown instances", () => { + const model = { + meta: { title: "Generic Demo" }, + symbols: {}, + instances: [ + { + ref: "X1", + symbol: "mystery_block", + properties: { value: "Mystery" }, + placement: { x: null, y: null, rotation: 0, locked: false } + }, + { + ref: "X2", + symbol: "mystery_sensor", + properties: { value: "Sensor" }, + placement: { x: null, y: null, rotation: 0, locked: false } + } + ], + nets: [ + { + name: "SIG_A", + class: "signal", + nodes: [ + { ref: "X1", pin: "IN" }, + { ref: "X2", pin: "OUT" } + ] + }, + { + name: "GND", + class: "ground", + nodes: [ + { ref: "X1", pin: "GND" }, + { ref: "X2", pin: "GND" } + ] + } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model); + assert.equal(result.ok, true); + assert.ok(result.svg.includes(" w.code === "auto_generic_symbol_created")); +}); + +test("compile auto-creates passive templates for common refs", () => { + const model = { + meta: { title: "Passive Template Demo" }, + symbols: {}, + instances: [ + { + ref: "R1", + symbol: "resistor_generic", + properties: { value: "10k resistor" }, + placement: { x: null, y: null, rotation: 0, locked: false } + }, + { + ref: "U1", + symbol: "mystery_logic", + properties: { value: "Logic" }, + placement: { x: null, y: null, rotation: 0, locked: false } + } + ], + nets: [ + { + name: "SIG", + class: "signal", + nodes: [ + { ref: "R1", pin: "1" }, + { ref: "U1", pin: "IN" } + ] + }, + { + name: "GND", + class: "ground", + nodes: [ + { ref: "R1", pin: "2" }, + { ref: "U1", pin: "GND" } + ] + } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model); + assert.equal(result.ok, true); + assert.ok(result.warnings.some((w) => w.code === "auto_template_symbol_created")); +}); + +test("compile accepts minimal shorthand symbols and hydrates fields", () => { + const model = { + meta: { title: "Shorthand Symbols" }, + symbols: { + resistor_short: { + template_name: "resistor" + }, + generic_short: { + category: "generic" + } + }, + instances: [ + { + ref: "R1", + symbol: "resistor_short", + properties: { value: "1k" }, + placement: { x: null, y: null, rotation: 0, locked: false } + }, + { + ref: "X1", + symbol: "generic_short", + properties: { value: "Mystery" }, + placement: { x: null, y: null, rotation: 0, locked: false } + } + ], + nets: [ + { + name: "SIG", + class: "signal", + nodes: [ + { ref: "R1", pin: "1" }, + { ref: "X1", pin: "IO" } + ] + }, + { + name: "GND", + class: "ground", + nodes: [ + { ref: "R1", pin: "2" }, + { ref: "X1", pin: "GND" } + ] + } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model); + assert.equal(result.ok, true); + assert.ok(result.svg.includes(" w.code === "auto_template_symbol_hydrated")); + assert.ok(result.warnings.some((w) => w.code === "auto_generic_symbol_hydrated")); +}); + +test("compile supports instance.part without explicit symbols", () => { + const model = { + meta: { title: "Part Shortcut" }, + symbols: {}, + instances: [ + { + ref: "R1", + part: "resistor", + properties: { value: "10k" }, + placement: { x: null, y: null, rotation: 0, locked: false } + }, + { + ref: "C1", + part: "capacitor", + properties: { value: "100nF" }, + placement: { x: null, y: null, rotation: 0, locked: false } + } + ], + nets: [ + { + name: "SIG", + class: "signal", + nodes: [ + { ref: "R1", pin: "1" }, + { ref: "C1", pin: "1" } + ] + }, + { + name: "GND", + class: "ground", + nodes: [ + { ref: "R1", pin: "2" }, + { ref: "C1", pin: "2" } + ] + } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model); + assert.equal(result.ok, true); + assert.ok(result.svg.includes(" { + const model = { + meta: { title: "Group packing" }, + symbols: { + q: { + symbol_id: "q", + category: "analog", + body: { width: 90, height: 70 }, + pins: [ + { name: "B", number: "1", side: "left", offset: 35, type: "analog" }, + { name: "C", number: "2", side: "top", offset: 45, type: "analog" }, + { name: "E", number: "3", side: "bottom", offset: 45, type: "analog" } + ] + }, + adc: { + symbol_id: "adc", + category: "generic", + body: { width: 120, height: 60 }, + pins: [ + { name: "IN", number: "1", side: "left", offset: 30, type: "analog" }, + { name: "3V3", number: "2", side: "top", offset: 30, type: "power_in" }, + { name: "GND", number: "3", side: "bottom", offset: 30, type: "ground" } + ] + } + }, + instances: [ + { ref: "Q1", symbol: "q", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R1", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R2", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "C1", part: "capacitor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "U1", symbol: "adc", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R3", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R4", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "C2", part: "capacitor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } } + ], + nets: [ + { name: "N1", class: "analog", nodes: [{ ref: "Q1", pin: "C" }, { ref: "U1", pin: "IN" }] }, + { name: "N2", class: "ground", nodes: [{ ref: "Q1", pin: "E" }, { ref: "U1", pin: "GND" }] }, + { name: "N3", class: "power", nodes: [{ ref: "R1", pin: "1" }, { ref: "U1", pin: "3V3" }] } + ], + constraints: { + groups: [ + { name: "front", members: ["Q1", "R1", "R2", "C1"], layout: "cluster" }, + { name: "adc", members: ["U1", "R3", "R4", "C2"], layout: "cluster" } + ] + }, + annotations: [] + }; + + const result = compile(model); + assert.equal(result.ok, true); + const xs = result.layout.placed.map((p) => p.x); + const ys = result.layout.placed.map((p) => p.y); + const widthSpread = Math.max(...xs) - Math.min(...xs); + const heightSpread = Math.max(...ys) - Math.min(...ys); + + assert.ok(widthSpread > 500); + assert.ok(heightSpread < 900); +}); + +test("multi-node signal nets render explicit junction dots", () => { + const model = { + meta: { title: "Junction coverage" }, + symbols: {}, + instances: [ + { ref: "R1", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R2", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "R3", part: "resistor", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } } + ], + nets: [ + { + name: "SIG", + class: "signal", + nodes: [ + { ref: "R1", pin: "1" }, + { ref: "R2", pin: "1" }, + { ref: "R3", pin: "1" } + ] + }, + { + name: "GND", + class: "ground", + nodes: [ + { ref: "R1", pin: "2" }, + { ref: "R2", pin: "2" }, + { ref: "R3", pin: "2" } + ] + } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model, { render_mode: "explicit" }); + assert.equal(result.ok, true); + assert.ok(result.svg.includes('data-net-junction="SIG"')); +}); + +test("auto-rotation chooses non-zero orientation when it improves pin alignment", () => { + const model = { + meta: { title: "Rotation heuristic" }, + symbols: { + n1: { + symbol_id: "n1", + category: "generic", + body: { width: 120, height: 80 }, + pins: [ + { name: "L", number: "1", side: "left", offset: 40, type: "passive" }, + { name: "R", number: "2", side: "right", offset: 40, type: "passive" } + ] + }, + n2: { + symbol_id: "n2", + category: "generic", + body: { width: 120, height: 80 }, + pins: [ + { name: "T", number: "1", side: "top", offset: 60, type: "passive" }, + { name: "B", number: "2", side: "bottom", offset: 60, type: "passive" } + ] + } + }, + instances: [ + { ref: "A1", symbol: "n1", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } }, + { ref: "A2", symbol: "n2", properties: {}, placement: { x: null, y: null, rotation: 0, locked: false } } + ], + nets: [ + { name: "N1", class: "signal", nodes: [{ ref: "A1", pin: "R" }, { ref: "A2", pin: "T" }] }, + { name: "N2", class: "ground", nodes: [{ ref: "A1", pin: "L" }, { ref: "A2", pin: "B" }] } + ], + constraints: {}, + annotations: [] + }; + + const result = compile(model, { render_mode: "explicit" }); + assert.equal(result.ok, true); + assert.ok(result.layout.placed.some((p) => (p.rotation ?? 0) % 360 !== 0)); +});