commit 61814349ed14ab89b2efbd0259e35c4847d5f630 Author: Rbanh Date: Mon Feb 16 18:48:51 2026 -0500 Initial Schemeta MVP compiler and API diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a83dbc --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# SCHEMETA (MVP) + +AI-Native Schematic Compiler & Visual Teaching Platform. + +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` + +## Run + +```bash +npm install +npm run dev +``` + +Server defaults to `http://localhost:8787`. + +## API + +### `POST /analyze` +Input: SJM JSON +Output: validation/ERC errors + warnings + topology summary + +### `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 +``` + +## Notes + +- 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. diff --git a/examples/esp32-audio.json b/examples/esp32-audio.json new file mode 100644 index 0000000..f478f22 --- /dev/null +++ b/examples/esp32-audio.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/package.json b/package.json new file mode 100644 index 0000000..0d81498 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "schemeta", + "version": "0.1.0", + "private": true, + "description": "AI-native schematic compiler and visual teaching platform MVP", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "test": "node --test" + } +} diff --git a/src/analyze.js b/src/analyze.js new file mode 100644 index 0000000..eb68b20 --- /dev/null +++ b/src/analyze.js @@ -0,0 +1,200 @@ +function pinTypeFor(model, ref, pin) { + const instance = model.instances.find((x) => x.ref === ref); + if (!instance) { + return "passive"; + } + const symbol = model.symbols[instance.symbol]; + const p = symbol.pins.find((x) => x.name === pin); + return p?.type ?? "passive"; +} + +function buildSignalPaths(model) { + const directedEdges = []; + + for (const net of model.nets) { + if (net.class === "power" || net.class === "ground") { + continue; + } + + const sources = net.nodes + .filter((n) => { + const t = pinTypeFor(model, n.ref, n.pin); + return t === "output" || t === "power_out"; + }) + .map((n) => n.ref); + + const sinks = net.nodes + .filter((n) => { + const t = pinTypeFor(model, n.ref, n.pin); + return t === "input" || t === "power_in" || t === "analog"; + }) + .map((n) => n.ref); + + for (const s of sources) { + for (const d of sinks) { + if (s !== d) { + directedEdges.push([s, d]); + } + } + } + } + + const uniqEdges = new Map(); + for (const edge of directedEdges) { + uniqEdges.set(`${edge[0]}->${edge[1]}`, edge); + } + + const adj = new Map(); + for (const [a, b] of uniqEdges.values()) { + const list = adj.get(a) ?? []; + list.push(b); + adj.set(a, list); + } + + const starts = [...adj.keys()].sort(); + const result = []; + + for (const start of starts) { + const queue = [{ node: start, path: [start] }]; + const seen = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + const key = current.path.join(">>"); + if (seen.has(key)) { + continue; + } + seen.add(key); + + const next = adj.get(current.node) ?? []; + if (next.length === 0 && current.path.length > 1) { + result.push(current.path); + } + + for (const n of next) { + if (!current.path.includes(n)) { + queue.push({ node: n, path: [...current.path, n] }); + } + } + } + } + + const dedup = new Map(); + for (const p of result) { + dedup.set(p.join(" -> "), p); + } + + return [...dedup.values()].sort((a, b) => a.join("/").localeCompare(b.join("/"))); +} + +function extractTopology(model) { + const powerDomains = model.nets + .filter((n) => n.class === "power" || n.class === "ground") + .map((n) => n.name) + .sort(); + + const clockSources = 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); + } + } + } + + return { + power_domains: powerDomains, + clock_sources: [...clockSources].sort(), + signal_paths: buildSignalPaths(model) + }; +} + +function ercChecks(model) { + const issues = []; + const hasGroundNet = model.nets.some((n) => n.class === "ground"); + + if (!hasGroundNet) { + issues.push({ + code: "ground_net_missing", + message: "No ground net defined.", + severity: "error", + path: "nets" + }); + } + + const connectedPins = new Set(); + + for (const net of model.nets) { + const types = net.nodes.map((n) => ({ + node: n, + type: pinTypeFor(model, n.ref, n.pin) + })); + + for (const t of types) { + connectedPins.add(`${t.node.ref}.${t.node.pin}`); + } + + const powerOut = types.filter((t) => t.type === "power_out"); + if (powerOut.length > 1) { + issues.push({ + code: "multi_power_out", + message: `Net '${net.name}' has multiple power_out pins.`, + severity: "error", + path: `nets.${net.name}` + }); + } + + const outputs = types.filter((t) => t.type === "output"); + if (outputs.length > 1) { + issues.push({ + code: "output_conflict", + message: `Net '${net.name}' directly connects multiple output pins.`, + severity: "error", + path: `nets.${net.name}` + }); + } + } + + for (const instance of model.instances) { + const symbol = model.symbols[instance.symbol]; + for (const pin of symbol.pins) { + const key = `${instance.ref}.${pin.name}`; + if ((pin.type === "power_in" || pin.type === "ground") && !connectedPins.has(key)) { + issues.push({ + code: "required_power_unconnected", + message: `Required pin '${key}' is unconnected.`, + severity: "warning", + path: `instances.${instance.ref}` + }); + } + if (pin.type === "input" && !connectedPins.has(key)) { + issues.push({ + code: "floating_input", + message: `Input pin '${key}' is floating.`, + severity: "warning", + path: `instances.${instance.ref}` + }); + } + } + } + + return issues; +} + +export function analyzeModel(model, validationIssues = []) { + const ercIssues = ercChecks(model); + const all = [...validationIssues, ...ercIssues]; + const errors = all.filter((x) => x.severity === "error"); + const warnings = all.filter((x) => x.severity === "warning"); + + return { + ok: errors.length === 0, + errors, + warnings, + topology: extractTopology(model) + }; +} diff --git a/src/compile.js b/src/compile.js new file mode 100644 index 0000000..754ea86 --- /dev/null +++ b/src/compile.js @@ -0,0 +1,52 @@ +import { analyzeModel } from "./analyze.js"; +import { renderSvg } from "./render.js"; +import { validateModel } from "./validate.js"; + +function emptyTopology() { + return { + power_domains: [], + clock_sources: [], + signal_paths: [] + }; +} + +export function compile(payload) { + const validated = validateModel(payload); + + if (!validated.model) { + const errors = validated.issues.filter((x) => x.severity === "error"); + const warnings = validated.issues.filter((x) => x.severity === "warning"); + return { + ok: false, + errors, + warnings, + topology: emptyTopology(), + svg: "" + }; + } + + const analysis = analyzeModel(validated.model, validated.issues); + const svg = renderSvg(validated.model); + + return { + ...analysis, + svg + }; +} + +export function analyze(payload) { + const validated = validateModel(payload); + + if (!validated.model) { + const errors = validated.issues.filter((x) => x.severity === "error"); + const warnings = validated.issues.filter((x) => x.severity === "warning"); + return { + ok: false, + errors, + warnings, + topology: emptyTopology() + }; + } + + return analyzeModel(validated.model, validated.issues); +} diff --git a/src/layout.js b/src/layout.js new file mode 100644 index 0000000..ead820b --- /dev/null +++ b/src/layout.js @@ -0,0 +1,228 @@ +const GRID = 20; +const COMPONENT_GAP_X = 220; +const COMPONENT_GAP_Y = 180; +const MARGIN_X = 120; +const MARGIN_Y = 140; + +function toGrid(value) { + return Math.round(value / GRID) * GRID; +} + +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 componentFlowScore(model, ref) { + let score = 0; + 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; + } + } + } + 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); + + 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; + } + } + } + return false; +} + +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); + continue; + } + + 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) { + prev.b = { ...seg.b }; + continue; + } + + out.push(seg); + } + + 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); + } + + 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 } + ]; + + return simplifySegments(detour); +} + +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; + } + + 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); + } + + placed.push({ + ...item.inst, + placement: { + x, + y, + rotation: item.inst.placement.rotation ?? 0, + locked + } + }); + + 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 boxes = placed.map((p) => { + const sym = model.symbols[p.symbol]; + return { + x: p.placement.x, + y: p.placement.y, + w: sym.body.width, + h: sym.body.height + }; + }); + + const routed = model.nets.map((net) => { + const routes = []; + if (net.nodes.length < 2) { + return { net, routes }; + } + + 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) { + continue; + } + routes.push(routeManhattan(a, b, boxes)); + } + + return { net, routes }; + }); + + 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; + + return { + placed, + routed, + width: toGrid(width), + height: toGrid(height) + }; +} + +export function netAnchorPoint(net, model, placed) { + const first = net.nodes[0]; + if (!first) { + return null; + } + return pointForNode(model, placed, first.ref, first.pin); +} diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..64ff940 --- /dev/null +++ b/src/render.js @@ -0,0 +1,95 @@ +import { layoutAndRoute, netAnchorPoint } from "./layout.js"; + +function esc(text) { + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function renderSvg(model) { + const layout = layoutAndRoute(model); + + const components = layout.placed + .map((inst) => { + const sym = model.symbols[inst.symbol]; + const x = inst.placement.x; + const y = inst.placement.y; + + const pinSvg = sym.pins + .map((pin) => { + 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; + } + + return [ + ``, + `${esc(pin.name)}` + ].join(""); + }) + .join(""); + + return ` + + + ${esc(inst.ref)} + ${esc(inst.properties?.value ?? inst.symbol)} + ${pinSvg} +`; + }) + .join("\n"); + + const wires = layout.routed + .flatMap((rn) => + rn.routes.map((route) => { + 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 ``; + }) + ) + .join("\n"); + + const labels = model.nets + .map((net) => { + const p = netAnchorPoint(net, model, layout.placed); + if (!p) { + return ""; + } + return `${esc(net.name)}`; + }) + .join("\n"); + + const annotations = (model.annotations ?? []) + .map((a, idx) => { + const x = a.x ?? 16; + const y = a.y ?? 24 + idx * 16; + return `${esc(a.text)}`; + }) + .join("\n"); + + return ` + + + + + ${components} + ${wires} + ${labels} + ${annotations} +`; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6f2866a --- /dev/null +++ b/src/server.js @@ -0,0 +1,68 @@ +import { createServer } from "node:http"; +import { analyze, compile } from "./compile.js"; + +const PORT = Number(process.env.PORT ?? "8787"); + +function json(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload, null, 2)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + if (chunks.length === 0) { + resolve({}); + return; + } + + try { + const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); + resolve(value); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +const server = createServer(async (req, res) => { + if (!req.url || !req.method) { + return json(res, 400, { error: "Invalid request." }); + } + + if (req.method === "GET" && req.url === "/health") { + return json(res, 200, { + service: "schemeta", + status: "ok", + date: new Date().toISOString() + }); + } + + if (req.method === "POST" && req.url === "/analyze") { + try { + const body = await readBody(req); + return json(res, 200, analyze(body)); + } catch { + return json(res, 400, { error: "Invalid JSON payload." }); + } + } + + if (req.method === "POST" && req.url === "/compile") { + try { + const body = await readBody(req); + return json(res, 200, compile(body)); + } catch { + return json(res, 400, { error: "Invalid JSON payload." }); + } + } + + return json(res, 404, { error: "Not found." }); +}); + +server.listen(PORT, () => { + console.log(`Schemeta server listening on http://localhost:${PORT}`); +}); diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..351940c --- /dev/null +++ b/src/validate.js @@ -0,0 +1,195 @@ +const VALID_PIN_TYPES = new Set([ + "power_in", + "power_out", + "input", + "output", + "bidirectional", + "passive", + "analog", + "ground" +]); + +const VALID_NET_CLASSES = new Set([ + "power", + "ground", + "signal", + "analog", + "differential", + "clock", + "bus" +]); + +export function validateModel(model) { + const issues = []; + + if (!model || typeof model !== "object") { + return { + model: null, + issues: [ + { + code: "invalid_root", + message: "Root payload must be an object.", + severity: "error", + path: "$" + } + ] + }; + } + + if (!model.symbols || typeof model.symbols !== "object") { + issues.push({ + code: "symbols_missing", + message: "symbols must be an object map.", + severity: "error", + path: "symbols" + }); + } + if (!Array.isArray(model.instances)) { + issues.push({ + code: "instances_missing", + message: "instances must be an array.", + severity: "error", + path: "instances" + }); + } + if (!Array.isArray(model.nets)) { + issues.push({ + code: "nets_missing", + message: "nets must be an array.", + severity: "error", + path: "nets" + }); + } + + if (issues.some((x) => x.severity === "error")) { + return { model: null, issues }; + } + + const symbolIds = new Set(Object.keys(model.symbols)); + for (const [id, sym] of Object.entries(model.symbols)) { + if (sym.symbol_id !== id) { + issues.push({ + code: "symbol_id_mismatch", + message: `symbol_id '${sym.symbol_id}' must match map key '${id}'.`, + severity: "error", + path: `symbols.${id}.symbol_id` + }); + } + + if (!Array.isArray(sym.pins) || sym.pins.length === 0) { + issues.push({ + code: "symbol_no_pins", + message: `Symbol '${id}' has no pins.`, + severity: "error", + path: `symbols.${id}.pins` + }); + continue; + } + + const pinNames = new Set(); + for (const pin of sym.pins) { + if (pinNames.has(pin.name)) { + issues.push({ + code: "duplicate_pin_name", + message: `Symbol '${id}' has duplicate pin '${pin.name}'.`, + severity: "error", + path: `symbols.${id}.pins` + }); + } + pinNames.add(pin.name); + + if (!VALID_PIN_TYPES.has(pin.type)) { + issues.push({ + code: "invalid_pin_type", + message: `Invalid pin type '${pin.type}' on ${id}.${pin.name}.`, + severity: "error", + path: `symbols.${id}.pins.${pin.name}.type` + }); + } + } + } + + const refs = new Set(); + const instanceSymbol = new Map(); + for (const inst of model.instances) { + if (refs.has(inst.ref)) { + issues.push({ + code: "duplicate_ref", + message: `Duplicate instance ref '${inst.ref}'.`, + severity: "error", + path: "instances" + }); + } + refs.add(inst.ref); + + if (!symbolIds.has(inst.symbol)) { + issues.push({ + code: "unknown_symbol", + message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`, + severity: "error", + path: `instances.${inst.ref}.symbol` + }); + } + instanceSymbol.set(inst.ref, inst.symbol); + } + + const netNames = new Set(); + for (const net of model.nets) { + if (netNames.has(net.name)) { + issues.push({ + code: "duplicate_net_name", + message: `Duplicate net '${net.name}'.`, + severity: "error", + path: `nets.${net.name}` + }); + } + netNames.add(net.name); + + if (!VALID_NET_CLASSES.has(net.class)) { + issues.push({ + code: "invalid_net_class", + message: `Invalid net class '${net.class}' on '${net.name}'.`, + severity: "error", + path: `nets.${net.name}.class` + }); + } + + if (!Array.isArray(net.nodes) || net.nodes.length < 2) { + issues.push({ + code: "net_too_small", + message: `Net '${net.name}' must contain at least two nodes.`, + severity: "error", + path: `nets.${net.name}.nodes` + }); + continue; + } + + for (const node of net.nodes) { + const symId = instanceSymbol.get(node.ref); + if (!symId) { + issues.push({ + code: "unknown_ref_in_net", + message: `Net '${net.name}' references unknown component '${node.ref}'.`, + severity: "error", + path: `nets.${net.name}.nodes` + }); + continue; + } + const sym = model.symbols[symId]; + const foundPin = sym.pins.some((p) => p.name === node.pin); + if (!foundPin) { + issues.push({ + code: "unknown_pin_in_net", + message: `Net '${net.name}' references unknown pin '${node.ref}.${node.pin}'.`, + severity: "error", + path: `nets.${net.name}.nodes` + }); + } + } + } + + return { + model: issues.some((x) => x.severity === "error") ? null : model, + issues + }; +} diff --git a/tests/compile.test.js b/tests/compile.test.js new file mode 100644 index 0000000..039971e --- /dev/null +++ b/tests/compile.test.js @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { compile } from "../src/compile.js"; +import fixture from "../examples/esp32-audio.json" with { type: "json" }; + +test("compile returns svg and topology for valid model", () => { + const result = compile(fixture); + assert.equal(result.ok, true); + assert.ok(result.svg.includes(" { + const bad = { symbols: {}, instances: [], nets: [] }; + const result = compile(bad); + assert.equal(result.ok, false); + assert.ok(result.errors.length > 0); +});