Expand Schemeta frontend editor with pin/net/symbol editing and schema updates

This commit is contained in:
Rbanh 2026-02-16 21:44:58 -05:00
parent 61814349ed
commit f2d48cee85
15 changed files with 7078 additions and 244 deletions

159
README.md
View File

@ -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: This version includes:
- Schemeta JSON Model validation - Deterministic JSON validation, ERC, topology extraction
- ERC checks (power/output conflicts, floating pins, ground-net checks) - Auto-generic symbol synthesis for unknown components/pins (deterministic fallback)
- Deterministic component placement + Manhattan routing - Auto-template synthesis for common passives/connectors (`R/C/L/D/LED/J/P`)
- SVG rendering with interactive data attributes - Constraint-aware placement, orthogonal routing, net-stub schematic mode
- HTTP API: `POST /compile`, `POST /analyze` - 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 ## Run
```bash ```bash
npm install npm run start
npm run dev
``` ```
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 ## REST API
### `POST /analyze`
Input: SJM JSON
Output: validation/ERC errors + warnings + topology summary
### `POST /compile` ### `POST /compile`
Input: SJM JSON Input:
Output: all `analyze` fields + rendered `svg` ```json
{
## Example "payload": { "...": "SJM" },
"options": {
```bash "render_mode": "schematic_stub",
curl -sS -X POST http://localhost:8787/compile \ "show_labels": true,
-H 'content-type: application/json' \ "generic_symbols": true
--data-binary @examples/esp32-audio.json }
}
``` ```
## Notes `payload` can also be posted directly (backward compatible).
- Deterministic rendering is guaranteed by stable sorting (`ref`, `net.name`) and fixed layout constants. Output includes:
- Wires are derived from net truth; nets remain source-of-truth. - `errors`, `warnings` with `id` + `suggestion`
- Current layout is constraint-aware only at basic group ordering level in this MVP. - `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

2372
frontend/app.js Normal file

File diff suppressed because it is too large Load Diff

238
frontend/index.html Normal file
View File

@ -0,0 +1,238 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schemeta Workspace</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<h1>SCHEMETA</h1>
<p>AI-Native Schematic Workspace</p>
</div>
<div class="actions">
<button id="newProjectBtn">New</button>
<button id="loadSampleBtn">Load Sample</button>
<button id="importBtn">Import JSON</button>
<button id="exportBtn">Export JSON</button>
<button id="autoLayoutBtn">Auto Layout</button>
<button id="autoTidyBtn">Auto Tidy</button>
<label class="inlineSelect">
Mode
<select id="renderModeSelect">
<option value="schematic_stub">Schematic Stub</option>
<option value="explicit">Explicit Wires</option>
</select>
</label>
<input id="fileInput" type="file" accept="application/json,.json" hidden />
</div>
</header>
<main class="workspace">
<aside class="pane left">
<section>
<div class="sectionHead">
<h2>Instances</h2>
<button id="isolateComponentBtn" class="chip">Isolate</button>
</div>
<input id="instanceFilter" placeholder="Filter instances" />
<ul id="instanceList" class="list"></ul>
</section>
<section>
<div class="sectionHead">
<h2>Nets</h2>
<button id="isolateNetBtn" class="chip">Isolate</button>
</div>
<input id="netFilter" placeholder="Filter nets" />
<ul id="netList" class="list"></ul>
</section>
</aside>
<section class="pane center">
<div class="canvasTools">
<button id="zoomOutBtn">-</button>
<button id="zoomResetBtn">100%</button>
<button id="zoomInBtn">+</button>
<button id="fitViewBtn">Fit</button>
<label class="inlineCheck"><input id="showLabelsInput" type="checkbox" checked /> Labels</label>
<span id="compileStatus">Idle</span>
</div>
<div id="canvasViewport" class="canvasViewport">
<div id="canvasInner" class="canvasInner"></div>
<div id="selectionBox" class="selectionBox hidden"></div>
<div id="pinTooltip" class="pinTooltip hidden"></div>
</div>
</section>
<aside class="pane right">
<section>
<h2>Selected</h2>
<div id="selectedSummary" class="card">Click a component, net, or pin to inspect it.</div>
<div id="componentEditor" class="editorCard hidden">
<div class="editorGrid">
<label>Ref <input id="instRefInput" type="text" /></label>
<label>Value <input id="instValueInput" type="text" /></label>
<label>Notes <input id="instNotesInput" type="text" /></label>
<label>X <input id="xInput" type="number" step="20" /></label>
<label>Y <input id="yInput" type="number" step="20" /></label>
<label>Rotation
<select id="rotationInput">
<option value="0">0deg</option>
<option value="90">90deg</option>
<option value="180">180deg</option>
<option value="270">270deg</option>
</select>
</label>
</div>
<label class="inline"><input id="lockedInput" type="checkbox" /> Locked</label>
<div class="editorActions">
<button id="rotateSelectedBtn">Rotate +90</button>
<button id="updatePlacementBtn">Apply Component</button>
</div>
</div>
<div id="symbolEditor" class="editorCard hidden">
<div id="symbolMeta" class="hintText"></div>
<div class="editorGrid">
<label>Category <input id="symbolCategoryInput" type="text" /></label>
<label>Body Width <input id="symbolWidthInput" type="number" min="20" step="10" /></label>
<label>Body Height <input id="symbolHeightInput" type="number" min="20" step="10" /></label>
</div>
<div class="editorActions">
<button id="addSymbolPinBtn">Add Pin</button>
<button id="applySymbolBtn">Apply Symbol</button>
</div>
<div id="symbolPinsList" class="miniList"></div>
</div>
<div id="pinEditor" class="editorCard hidden">
<div id="pinMeta" class="hintText"></div>
<div class="editorGrid">
<label>Pin Name <input id="pinNameInput" type="text" /></label>
<label>Pin Number <input id="pinNumberInput" type="text" /></label>
<label>Pin Side
<select id="pinSideInput">
<option value="left">left</option>
<option value="right">right</option>
<option value="top">top</option>
<option value="bottom">bottom</option>
</select>
</label>
<label>Pin Type
<select id="pinTypeInput">
<option value="power_in">power_in</option>
<option value="power_out">power_out</option>
<option value="input">input</option>
<option value="output">output</option>
<option value="bidirectional">bidirectional</option>
<option value="passive">passive</option>
<option value="analog">analog</option>
<option value="ground">ground</option>
</select>
</label>
<label>Pin Offset <input id="pinOffsetInput" type="number" min="0" step="1" /></label>
</div>
<div class="editorActions">
<button id="applyPinPropsBtn">Apply Pin Properties</button>
</div>
<label class="inline"><input id="showPinNetLabelInput" type="checkbox" /> Show net label on this pin</label>
<label>Connect to existing net
<select id="pinNetSelect"></select>
</label>
<div class="editorActions">
<button id="connectPinBtn">Connect</button>
</div>
<label>Or create new net
<input id="newNetNameInput" type="text" placeholder="NET_1" />
</label>
<label>New net class
<select id="newNetClassInput">
<option value="signal">signal</option>
<option value="analog">analog</option>
<option value="power">power</option>
<option value="ground">ground</option>
<option value="clock">clock</option>
<option value="bus">bus</option>
<option value="differential">differential</option>
</select>
</label>
<div class="editorActions">
<button id="createConnectNetBtn">Create + Connect</button>
</div>
<div id="pinConnections" class="miniList"></div>
</div>
<div id="netEditor" class="editorCard hidden">
<div class="editorGrid">
<label>Name <input id="netNameInput" type="text" /></label>
<label>Class
<select id="netClassInput">
<option value="signal">signal</option>
<option value="analog">analog</option>
<option value="power">power</option>
<option value="ground">ground</option>
<option value="clock">clock</option>
<option value="bus">bus</option>
<option value="differential">differential</option>
</select>
</label>
</div>
<div class="editorActions">
<button id="updateNetBtn">Apply Net</button>
</div>
<div class="editorGrid">
<label>Node Ref <input id="netNodeRefInput" type="text" placeholder="U1" /></label>
<label>Node Pin <input id="netNodePinInput" type="text" placeholder="GPIO1" /></label>
</div>
<div class="editorActions">
<button id="addNetNodeBtn">Add Node</button>
</div>
<div id="netNodesList" class="miniList"></div>
</div>
</section>
<section>
<h2>Diagnostics</h2>
<div id="issues" class="card"></div>
</section>
<section>
<h2>Topology</h2>
<div id="topology" class="card"></div>
</section>
<section>
<div class="sectionHead">
<h2>JSON</h2>
<div class="jsonActions">
<button id="showSchemaBtn">View Schema</button>
<button id="validateJsonBtn">Validate</button>
<button id="formatJsonBtn">Format</button>
<button id="sortJsonBtn">Sort Keys</button>
<button id="copyReproBtn">Copy Repro</button>
<button id="applyJsonBtn" class="primary">Apply JSON</button>
</div>
</div>
<div id="jsonFeedback" class="jsonFeedback"></div>
<textarea id="jsonEditor" spellcheck="false"></textarea>
</section>
</aside>
</main>
<div id="schemaModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schemaTitle">
<div class="modalCard">
<div class="modalHead">
<h3 id="schemaTitle">Schemeta JSON Schema</h3>
<div class="jsonActions">
<button id="copySchemaBtn">Copy</button>
<button id="downloadSchemaBtn">Download</button>
<button id="closeSchemaBtn">Close</button>
</div>
</div>
<p class="modalHint">Use this schema in AI prompts/tools to generate valid Schemeta JSON deterministically.</p>
<textarea id="schemaViewer" spellcheck="false" readonly></textarea>
</div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

View 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" }
]
}

View File

@ -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" }
}
}
}
}

443
frontend/styles.css Normal file
View File

@ -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;
}
}

View File

@ -7,6 +7,7 @@
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"test": "node --test" "test": "node --test",
"mcp": "node src/mcp-server.js"
} }
} }

View File

@ -87,29 +87,91 @@ function buildSignalPaths(model) {
return [...dedup.values()].sort((a, b) => a.join("/").localeCompare(b.join("/"))); 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) { function extractTopology(model) {
const powerDomains = model.nets const powerNets = model.nets.filter((n) => n.class === "power" || n.class === "ground");
.filter((n) => n.class === "power" || n.class === "ground") const powerDomains = powerNets.map((n) => n.name).sort();
.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 clockSources = new Set();
const clockSinks = new Set();
for (const net of model.nets) { for (const net of model.nets) {
if (net.class !== "clock") { if (net.class !== "clock") {
continue; continue;
} }
for (const node of net.nodes) { for (const node of net.nodes) {
const type = pinTypeFor(model, node.ref, node.pin); const type = pinTypeFor(model, node.ref, node.pin);
if (type === "output" || type === "power_out") { if (type === "output" || type === "power_out") {
clockSources.add(node.ref); 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 { return {
power_domains: powerDomains, power_domains: powerDomains,
power_domain_consumers: powerDomainConsumers,
clock_sources: [...clockSources].sort(), clock_sources: [...clockSources].sort(),
signal_paths: buildSignalPaths(model) clock_sinks: [...clockSinks].sort(),
buses,
signal_paths: signalPaths,
named_signal_paths: namedSignalPaths
}; };
} }

View File

@ -1,5 +1,6 @@
import { analyzeModel } from "./analyze.js"; 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"; import { validateModel } from "./validate.js";
function emptyTopology() { function emptyTopology() {
@ -10,36 +11,299 @@ function emptyTopology() {
}; };
} }
export function compile(payload) { function emptyLayout() {
const validated = validateModel(payload); 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) { if (!validated.model) {
const errors = validated.issues.filter((x) => x.severity === "error"); const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
const warnings = validated.issues.filter((x) => x.severity === "warning"); const warnings = annotateIssues(validated.issues.filter((x) => x.severity === "warning"), "W");
return { return {
ok: false, ok: false,
errors, errors,
warnings, warnings,
topology: emptyTopology(), 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: "" svg: ""
}; };
} }
const analysis = analyzeModel(validated.model, validated.issues); 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 { return {
...analysis, ...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 svg
}; };
} }
export function analyze(payload) { export function analyze(payload, options = {}) {
const validated = validateModel(payload); const validated = validateModel(payload, options);
if (!validated.model) { if (!validated.model) {
const errors = validated.issues.filter((x) => x.severity === "error"); const errors = annotateIssues(validated.issues.filter((x) => x.severity === "error"), "E");
const warnings = validated.issues.filter((x) => x.severity === "warning"); const warnings = annotateIssues(validated.issues.filter((x) => x.severity === "warning"), "W");
return { return {
ok: false, ok: false,
errors, 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")
};
} }

File diff suppressed because it is too large Load Diff

226
src/mcp-server.js Normal file
View File

@ -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();

View File

@ -1,5 +1,17 @@
import { layoutAndRoute, netAnchorPoint } from "./layout.js"; 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) { function esc(text) {
return String(text) return String(text)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@ -8,47 +20,425 @@ function esc(text) {
.replaceAll('"', "&quot;"); .replaceAll('"', "&quot;");
} }
export function renderSvg(model) { function normalizeRotation(value) {
const layout = layoutAndRoute(model); 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 `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="8" fill="#ffffff" stroke="#1f2937" stroke-width="2" />`;
}
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(`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="8" fill="#fffdfa" stroke="#1f2937" stroke-width="2" />`);
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(`<polyline points="${pts.map((p) => `${p[0]},${p[1]}`).join(" ")}" fill="none" stroke="#344054" stroke-width="2" />`);
} else if (kind === "capacitor") {
body.push(`<line x1="${midX - 10}" y1="${top}" x2="${midX - 10}" y2="${bottom}" stroke="#344054" stroke-width="2" />`);
body.push(`<line x1="${midX + 10}" y1="${top}" x2="${midX + 10}" y2="${bottom}" stroke="#344054" stroke-width="2" />`);
body.push(`<line x1="${left}" y1="${midY}" x2="${midX - 10}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
body.push(`<line x1="${midX + 10}" y1="${midY}" x2="${right}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
} else if (kind === "inductor") {
body.push(`<line x1="${left}" y1="${midY}" x2="${left + 10}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
for (let i = 0; i < 4; i += 1) {
const cx = left + 18 + i * 16;
body.push(`<path d="M ${cx - 8} ${midY} A 8 8 0 0 1 ${cx + 8} ${midY}" fill="none" stroke="#344054" stroke-width="2" />`);
}
body.push(`<line x1="${right - 10}" y1="${midY}" x2="${right}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
} else if (kind === "diode" || kind === "led") {
const triLeft = left + 12;
const triRight = midX + 6;
body.push(`<line x1="${left}" y1="${midY}" x2="${triLeft}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
body.push(`<path d="M ${triLeft} ${midY - 14} L ${triLeft} ${midY + 14} L ${triRight} ${midY} Z" fill="none" stroke="#344054" stroke-width="2" />`);
body.push(`<line x1="${triRight + 4}" y1="${midY - 16}" x2="${triRight + 4}" y2="${midY + 16}" stroke="#344054" stroke-width="2" />`);
body.push(`<line x1="${triRight + 4}" y1="${midY}" x2="${right}" y2="${midY}" stroke="#344054" stroke-width="2" />`);
if (kind === "led") {
body.push(`<line x1="${triRight - 2}" y1="${midY - 20}" x2="${triRight + 8}" y2="${midY - 30}" stroke="#b42318" stroke-width="1.6" />`);
body.push(`<line x1="${triRight + 10}" y1="${midY - 18}" x2="${triRight + 20}" y2="${midY - 28}" stroke="#b42318" stroke-width="1.6" />`);
}
} else if (kind === "connector") {
body.push(`<line x1="${midX}" y1="${top + 6}" x2="${midX}" y2="${bottom - 6}" stroke="#98a2b3" stroke-width="1.6" />`);
body.push(`<circle cx="${midX}" cy="${midY - 16}" r="5" fill="#fff" stroke="#344054" stroke-width="1.6" />`);
body.push(`<circle cx="${midX}" cy="${midY + 16}" r="5" fill="#fff" stroke="#344054" stroke-width="1.6" />`);
}
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 [
`<path d="${pathD}" fill="none" stroke="#f8fafc" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" data-net="${esc(netName)}" data-net-class="${esc(netClass)}" />`,
`<path d="${pathD}" fill="none" stroke="${color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" data-net="${esc(netName)}" data-net-class="${esc(netClass)}" />`
].join("");
}
function renderNetLabel(x, y, netName, netClass, bold = false) {
const color = netColor(netClass);
const weight = bold ? "700" : "600";
return `<text x="${x}" y="${y}" font-size="10" font-weight="${weight}" fill="${color}" stroke="#f8fafc" stroke-width="3" paint-order="stroke fill" data-net-label="${esc(netName)}">${esc(netName)}</text>`;
}
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 `
<g data-net-tie="${esc(netName)}">
<line x1="${x}" y1="${y}" x2="${x}" y2="${y0}" stroke="#344054" stroke-width="1.4" />
<line x1="${x - 6}" y1="${y1}" x2="${x + 6}" y2="${y1}" stroke="#344054" stroke-width="1.4" />
<line x1="${x - 4}" y1="${y2}" x2="${x + 4}" y2="${y2}" stroke="#344054" stroke-width="1.4" />
<line x1="${x - 2}" y1="${y3}" x2="${x + 2}" y2="${y3}" stroke="#344054" stroke-width="1.4" />
</g>`;
}
function renderPowerSymbol(x, y, netName) {
return `
<g data-net-tie="${esc(netName)}">
<line x1="${x}" y1="${y}" x2="${x}" y2="${y - 4}" stroke="#b54708" stroke-width="1.6" />
<path d="M ${x - 5} ${y + 2} L ${x} ${y - 8} L ${x + 5} ${y + 2} Z" fill="#b54708" stroke="#f8fafc" stroke-width="1" />
</g>`;
}
function renderGenericTie(x, y, netName, netClass) {
const color = netColor(netClass);
return `<circle cx="${x}" cy="${y}" r="3" fill="#ffffff" stroke="${color}" stroke-width="1.3" data-net-tie="${esc(netName)}" />`;
}
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) =>
`<g transform="translate(0 ${idx * 14})"><line x1="0" y1="6" x2="16" y2="6" stroke="${netColor(cls)}" stroke-width="2" /><text x="22" y="9" fill="#475467" font-size="10">${label}</text></g>`
)
.join("");
return `
<g data-layer="legend" transform="translate(14 14)">
<rect x="-8" y="-8" width="120" height="82" rx="6" fill="#ffffffd8" stroke="#d0d5dd" />
${rows}
</g>`;
}
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 const components = layout.placed
.map((inst) => { .map((inst) => {
const sym = model.symbols[inst.symbol]; const sym = model.symbols[inst.symbol];
const x = inst.placement.x; const x = inst.placement.x;
const y = inst.placement.y; 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 const pinCircles = [];
.map((pin) => { const pinLabels = [];
let px = x; const instanceNetLabels = [];
let py = y; for (const pin of sym.pins) {
let px = x;
let py = y;
if (pin.side === "left") { if (pin.side === "left") {
px = x; px = x;
py = y + pin.offset; py = y + pin.offset;
} else if (pin.side === "right") { } else if (pin.side === "right") {
px = x + sym.body.width; px = x + sym.body.width;
py = y + pin.offset; py = y + pin.offset;
} else if (pin.side === "top") { } else if (pin.side === "top") {
px = x + pin.offset; px = x + pin.offset;
py = y; py = y;
} else { } else {
px = x + pin.offset; px = x + pin.offset;
py = y + sym.body.height; py = y + sym.body.height;
}
pinCircles.push(
`<circle cx="${px}" cy="${py}" r="3.2" fill="#111827" data-pin-ref="${esc(inst.ref)}" data-pin-name="${esc(pin.name)}" data-pin-nets="${esc((pinNets.get(`${inst.ref}.${pin.name}`) ?? []).join(","))}" />`
);
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(
`<text x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="10" fill="#475467" data-pin-label="${esc(inst.ref)}.${esc(pin.name)}">${esc(pin.name)}</text>`
);
}
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(
`<text x="${netX}" y="${netY}" text-anchor="${netAnchor}" font-size="10" font-weight="700" fill="${netColor(netClassByName.get(displayNet))}" stroke="#f8fafc" stroke-width="2.6" paint-order="stroke fill" data-net-label="${esc(displayNet)}" data-ref-net-label="${esc(inst.ref)}">${esc(displayNet)}</text>`
);
} }
}
}
return [ const pinLabelsSvg = [...pinLabels, ...instanceNetLabels].join("");
`<circle cx="${px}" cy="${py}" r="3" fill="#1f2937" data-pin="${esc(pin.name)}" />`, const pinCoreSvg = pinCircles.join("");
`<text x="${px + 6}" y="${py - 4}" font-size="10" fill="#374151">${esc(pin.name)}</text>`
].join(""); const rotationTransform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : "";
})
.join(""); 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 ` return `
<g data-ref="${esc(inst.ref)}" data-symbol="${esc(inst.symbol)}"> <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" /> <g${rotationTransform}>
<text x="${x + 10}" y="${y + 18}" font-size="12" font-weight="700" fill="#111827">${esc(inst.ref)}</text> ${renderSymbolBody(sym, x, y, sym.body.width, sym.body.height)}
<text x="${x + 10}" y="${y + 34}" font-size="10" fill="#4b5563">${esc(inst.properties?.value ?? inst.symbol)}</text> ${pinCoreSvg}
${pinSvg} </g>
${pinLabelsSvg}
<text x="${x + 8}" y="${refY}" font-size="${compactLabel ? 11 : 12}" font-weight="700" fill="#111827" stroke="#f8fafc" stroke-width="2.2" paint-order="stroke fill">${esc(refLabel)}</text>
<text x="${x + 8}" y="${valueY}" font-size="${compactLabel ? 9 : 10}" fill="#667085" stroke="#f8fafc" stroke-width="2" paint-order="stroke fill">${esc(valueLabel)}</text>
</g>`; </g>`;
}) })
.join("\n"); .join("\n");
@ -59,18 +449,107 @@ export function renderSvg(model) {
const path = route const path = route
.map((seg, idx) => `${idx === 0 ? "M" : "L"} ${seg.a.x} ${seg.a.y} L ${seg.b.x} ${seg.b.y}`) .map((seg, idx) => `${idx === 0 ? "M" : "L"} ${seg.a.x} ${seg.a.y} L ${seg.b.x} ${seg.b.y}`)
.join(" "); .join(" ");
return `<path d="${path}" fill="none" stroke="#2563eb" stroke-width="2" data-net="${esc(rn.net.name)}" />`; return renderWirePath(path, rn.net.name, rn.net.class);
}) })
) )
.join("\n"); .join("\n");
const labels = model.nets const junctions = layout.routed
.map((net) => { .flatMap((rn) =>
const p = netAnchorPoint(net, model, layout.placed); (rn.junctionPoints ?? []).map((p) => {
if (!p) { const color = netColor(rn.net.class);
return `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${color}" stroke="#f8fafc" stroke-width="1.3" data-net-junction="${esc(rn.net.name)}" />`;
})
)
.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 "";
} }
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>`;
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 `<text x="${x + 12}" y="${y - 12}" font-size="11" font-weight="700" fill="#155eef" stroke="#f8fafc" stroke-width="3" paint-order="stroke fill" data-bus-group="${esc(group.name)}">${esc(group.name)} bus</text>`;
}) })
.join("\n"); .join("\n");
@ -82,14 +561,25 @@ export function renderSvg(model) {
}) })
.join("\n"); .join("\n");
const labelLayer = showLabels ? [...labels, ...tieLabels].join("\n") : "";
return `<?xml version="1.0" encoding="UTF-8"?> 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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}" data-engine="schemeta-v2">
<g data-layer="background"> <g data-layer="background">
<rect x="0" y="0" width="${layout.width}" height="${layout.height}" fill="#f9fafb" /> <rect x="0" y="0" width="${layout.width}" height="${layout.height}" fill="#f8fafc" />
</g> </g>
<g data-layer="components">${components}</g> <g data-layer="components">${components}</g>
<g data-layer="wires">${wires}</g> <g data-layer="wires">${wires}</g>
<g data-layer="net-labels">${labels}</g> <g data-layer="junctions">${junctions}</g>
<g data-layer="ties">${tiePoints}</g>
<g data-layer="net-labels">${labelLayer}</g>
<g data-layer="bus-groups">${busLabels}</g>
<g data-layer="annotations">${annotations}</g> <g data-layer="annotations">${annotations}</g>
${renderLegend()}
</svg>`; </svg>`;
} }
export function renderSvg(model, options = {}) {
const layout = layoutAndRoute(model, options);
return renderSvgFromLayout(model, layout, options);
}

View File

@ -1,17 +1,62 @@
import { createServer } from "node:http"; 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 { analyze, compile } from "./compile.js";
import { applyLayoutToModel } from "./layout.js";
import { validateModel } from "./validate.js";
const PORT = Number(process.env.PORT ?? "8787"); 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) { 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)); res.end(JSON.stringify(payload, null, 2));
} }
function errorEnvelope(code, message) {
return {
ok: false,
error: {
code,
message
}
};
}
function readBody(req) { function readBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; 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", () => { req.on("end", () => {
if (chunks.length === 0) { if (chunks.length === 0) {
resolve({}); resolve({});
@ -21,46 +66,183 @@ function readBody(req) {
try { try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
resolve(value); resolve(value);
} catch (err) { } catch {
reject(err); reject(Object.assign(new Error("Malformed JSON"), { code: "INVALID_JSON" }));
} }
}); });
req.on("error", reject); req.on("error", reject);
}); });
} }
const server = createServer(async (req, res) => { function sanitizePath(urlPath) {
if (!req.url || !req.method) { if (urlPath === "/") {
return json(res, 400, { error: "Invalid request." }); 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, { return json(res, 200, {
ok: true,
service: "schemeta", service: "schemeta",
status: "ok", status: "ok",
date: new Date().toISOString() 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 { try {
const body = await readBody(req); const body = await readBody(req);
return json(res, 200, analyze(body)); const parsed = parsePayloadOptions(body);
} catch { return json(res, 200, analyze(parsed.payload, parsed.options));
return json(res, 400, { error: "Invalid JSON payload." }); } 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 { try {
const body = await readBody(req); const body = await readBody(req);
return json(res, 200, compile(body)); const parsed = parsePayloadOptions(body);
} catch { return json(res, 200, compile(parsed.payload, parsed.options));
return json(res, 400, { error: "Invalid JSON payload." }); } 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, () => { server.listen(PORT, () => {

View File

@ -18,9 +18,505 @@ const VALID_NET_CLASSES = new Set([
"clock", "clock",
"bus" "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 issues = [];
const enableGenericSymbols = options.generic_symbols !== false;
if (!model || typeof model !== "object") { if (!model || typeof model !== "object") {
return { return {
@ -65,8 +561,10 @@ export function validateModel(model) {
return { model: null, issues }; return { model: null, issues };
} }
const symbolIds = new Set(Object.keys(model.symbols)); const workingModel = ensureGenericSymbols(model, issues, enableGenericSymbols);
for (const [id, sym] of Object.entries(model.symbols)) { const symbolIds = new Set(Object.keys(workingModel.symbols));
for (const [id, sym] of Object.entries(workingModel.symbols)) {
if (sym.symbol_id !== id) { if (sym.symbol_id !== id) {
issues.push({ issues.push({
code: "symbol_id_mismatch", code: "symbol_id_mismatch",
@ -111,7 +609,7 @@ export function validateModel(model) {
const refs = new Set(); const refs = new Set();
const instanceSymbol = new Map(); const instanceSymbol = new Map();
for (const inst of model.instances) { for (const inst of workingModel.instances) {
if (refs.has(inst.ref)) { if (refs.has(inst.ref)) {
issues.push({ issues.push({
code: "duplicate_ref", code: "duplicate_ref",
@ -122,7 +620,14 @@ export function validateModel(model) {
} }
refs.add(inst.ref); 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({ issues.push({
code: "unknown_symbol", code: "unknown_symbol",
message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`, message: `Instance '${inst.ref}' references unknown symbol '${inst.symbol}'.`,
@ -134,7 +639,7 @@ export function validateModel(model) {
} }
const netNames = new Set(); const netNames = new Set();
for (const net of model.nets) { for (const net of workingModel.nets) {
if (netNames.has(net.name)) { if (netNames.has(net.name)) {
issues.push({ issues.push({
code: "duplicate_net_name", code: "duplicate_net_name",
@ -175,7 +680,10 @@ export function validateModel(model) {
}); });
continue; 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); const foundPin = sym.pins.some((p) => p.name === node.pin);
if (!foundPin) { if (!foundPin) {
issues.push({ issues.push({
@ -189,7 +697,7 @@ export function validateModel(model) {
} }
return { return {
model: issues.some((x) => x.severity === "error") ? null : model, model: issues.some((x) => x.severity === "error") ? null : workingModel,
issues issues
}; };
} }

View File

@ -9,6 +9,11 @@ test("compile returns svg and topology for valid model", () => {
assert.ok(result.svg.includes("<svg")); assert.ok(result.svg.includes("<svg"));
assert.ok(result.topology.power_domains.includes("3V3")); assert.ok(result.topology.power_domains.includes("3V3"));
assert.ok(result.topology.clock_sources.includes("U1")); assert.ok(result.topology.clock_sources.includes("U1"));
assert.ok(result.layout_metrics);
assert.equal(result.layout_metrics.overlap_edges, 0);
assert.equal(result.layout_metrics.crossings, 0);
assert.ok(Array.isArray(result.bus_groups));
assert.ok(result.render_mode_used);
}); });
test("compile fails on invalid model", () => { test("compile fails on invalid model", () => {
@ -17,3 +22,338 @@ test("compile fails on invalid model", () => {
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.ok(result.errors.length > 0); 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("<svg"));
assert.ok(result.warnings.some((w) => 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("<svg"));
assert.ok(result.warnings.some((w) => 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("<svg"));
assert.equal(result.errors.length, 0);
});
test("grouped auto-layout avoids tall single-column collapse", () => {
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));
});