Initial Schemeta MVP compiler and API
This commit is contained in:
commit
61814349ed
43
README.md
Normal file
43
README.md
Normal file
@ -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.
|
||||
91
examples/esp32-audio.json
Normal file
91
examples/esp32-audio.json
Normal file
@ -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" }
|
||||
]
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
200
src/analyze.js
Normal file
200
src/analyze.js
Normal file
@ -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)
|
||||
};
|
||||
}
|
||||
52
src/compile.js
Normal file
52
src/compile.js
Normal file
@ -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);
|
||||
}
|
||||
228
src/layout.js
Normal file
228
src/layout.js
Normal file
@ -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);
|
||||
}
|
||||
95
src/render.js
Normal file
95
src/render.js
Normal file
@ -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 [
|
||||
`<circle cx="${px}" cy="${py}" r="3" fill="#1f2937" data-pin="${esc(pin.name)}" />`,
|
||||
`<text x="${px + 6}" y="${py - 4}" font-size="10" fill="#374151">${esc(pin.name)}</text>`
|
||||
].join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<g data-ref="${esc(inst.ref)}" data-symbol="${esc(inst.symbol)}">
|
||||
<rect x="${x}" y="${y}" width="${sym.body.width}" height="${sym.body.height}" rx="8" fill="#ffffff" stroke="#111827" stroke-width="2" />
|
||||
<text x="${x + 10}" y="${y + 18}" font-size="12" font-weight="700" fill="#111827">${esc(inst.ref)}</text>
|
||||
<text x="${x + 10}" y="${y + 34}" font-size="10" fill="#4b5563">${esc(inst.properties?.value ?? inst.symbol)}</text>
|
||||
${pinSvg}
|
||||
</g>`;
|
||||
})
|
||||
.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 `<path d="${path}" fill="none" stroke="#2563eb" stroke-width="2" data-net="${esc(rn.net.name)}" />`;
|
||||
})
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const labels = model.nets
|
||||
.map((net) => {
|
||||
const p = netAnchorPoint(net, model, layout.placed);
|
||||
if (!p) {
|
||||
return "";
|
||||
}
|
||||
return `<text x="${p.x + 8}" y="${p.y - 8}" font-size="10" fill="#0f766e" data-net-label="${esc(net.name)}">${esc(net.name)}</text>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const annotations = (model.annotations ?? [])
|
||||
.map((a, idx) => {
|
||||
const x = a.x ?? 16;
|
||||
const y = a.y ?? 24 + idx * 16;
|
||||
return `<text x="${x}" y="${y}" font-size="11" fill="#6b7280">${esc(a.text)}</text>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}" data-engine="schemeta-mvp">
|
||||
<g data-layer="background">
|
||||
<rect x="0" y="0" width="${layout.width}" height="${layout.height}" fill="#f9fafb" />
|
||||
</g>
|
||||
<g data-layer="components">${components}</g>
|
||||
<g data-layer="wires">${wires}</g>
|
||||
<g data-layer="net-labels">${labels}</g>
|
||||
<g data-layer="annotations">${annotations}</g>
|
||||
</svg>`;
|
||||
}
|
||||
68
src/server.js
Normal file
68
src/server.js
Normal file
@ -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}`);
|
||||
});
|
||||
195
src/validate.js
Normal file
195
src/validate.js
Normal file
@ -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
|
||||
};
|
||||
}
|
||||
19
tests/compile.test.js
Normal file
19
tests/compile.test.js
Normal file
@ -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("<svg"));
|
||||
assert.ok(result.topology.power_domains.includes("3V3"));
|
||||
assert.ok(result.topology.clock_sources.includes("U1"));
|
||||
});
|
||||
|
||||
test("compile fails on invalid model", () => {
|
||||
const bad = { symbols: {}, instances: [], nets: [] };
|
||||
const result = compile(bad);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.length > 0);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user