Expand Schemeta frontend editor with pin/net/symbol editing and schema updates
This commit is contained in:
parent
61814349ed
commit
f2d48cee85
159
README.md
159
README.md
@ -1,43 +1,144 @@
|
|||||||
# SCHEMETA (MVP)
|
# SCHEMETA
|
||||||
|
|
||||||
AI-Native Schematic Compiler & Visual Teaching Platform.
|
AI-Native schematic compiler and visual teaching workspace.
|
||||||
|
|
||||||
This MVP implements:
|
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
2372
frontend/app.js
Normal file
File diff suppressed because it is too large
Load Diff
238
frontend/index.html
Normal file
238
frontend/index.html
Normal 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>
|
||||||
91
frontend/sample.schemeta.json
Normal file
91
frontend/sample.schemeta.json
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "ESP32 Audio Path"
|
||||||
|
},
|
||||||
|
"symbols": {
|
||||||
|
"esp32_s3_supermini": {
|
||||||
|
"symbol_id": "esp32_s3_supermini",
|
||||||
|
"category": "microcontroller",
|
||||||
|
"body": { "width": 160, "height": 240 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "3V3", "number": "1", "side": "left", "offset": 30, "type": "power_in" },
|
||||||
|
{ "name": "GND", "number": "2", "side": "left", "offset": 60, "type": "ground" },
|
||||||
|
{ "name": "GPIO5", "number": "10", "side": "right", "offset": 40, "type": "output" },
|
||||||
|
{ "name": "GPIO6", "number": "11", "side": "right", "offset": 70, "type": "output" },
|
||||||
|
{ "name": "GPIO7", "number": "12", "side": "right", "offset": 100, "type": "output" }
|
||||||
|
],
|
||||||
|
"graphics": {
|
||||||
|
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 160, "h": 240 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dac_i2s": {
|
||||||
|
"symbol_id": "dac_i2s",
|
||||||
|
"category": "audio",
|
||||||
|
"body": { "width": 140, "height": 180 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "3V3", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
|
||||||
|
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
|
||||||
|
{ "name": "BCLK", "number": "3", "side": "left", "offset": 80, "type": "input" },
|
||||||
|
{ "name": "LRCLK", "number": "4", "side": "left", "offset": 110, "type": "input" },
|
||||||
|
{ "name": "DIN", "number": "5", "side": "left", "offset": 140, "type": "input" },
|
||||||
|
{ "name": "AOUT", "number": "6", "side": "right", "offset": 90, "type": "analog" }
|
||||||
|
],
|
||||||
|
"graphics": {
|
||||||
|
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 140, "h": 180 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"amp": {
|
||||||
|
"symbol_id": "amp",
|
||||||
|
"category": "output",
|
||||||
|
"body": { "width": 120, "height": 120 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "5V", "number": "1", "side": "left", "offset": 20, "type": "power_in" },
|
||||||
|
{ "name": "GND", "number": "2", "side": "left", "offset": 50, "type": "ground" },
|
||||||
|
{ "name": "IN", "number": "3", "side": "left", "offset": 80, "type": "input" },
|
||||||
|
{ "name": "SPK", "number": "4", "side": "right", "offset": 70, "type": "output" }
|
||||||
|
],
|
||||||
|
"graphics": {
|
||||||
|
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"psu": {
|
||||||
|
"symbol_id": "psu",
|
||||||
|
"category": "power",
|
||||||
|
"body": { "width": 120, "height": 120 },
|
||||||
|
"pins": [
|
||||||
|
{ "name": "5V_OUT", "number": "1", "side": "right", "offset": 30, "type": "power_out" },
|
||||||
|
{ "name": "3V3_OUT", "number": "2", "side": "right", "offset": 60, "type": "power_out" },
|
||||||
|
{ "name": "GND", "number": "3", "side": "right", "offset": 90, "type": "ground" }
|
||||||
|
],
|
||||||
|
"graphics": {
|
||||||
|
"primitives": [{ "type": "rect", "x": 0, "y": 0, "w": 120, "h": 120 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instances": [
|
||||||
|
{ "ref": "U1", "symbol": "esp32_s3_supermini", "properties": { "value": "ESP32-S3" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U2", "symbol": "dac_i2s", "properties": { "value": "DAC" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U3", "symbol": "amp", "properties": { "value": "Amp" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } },
|
||||||
|
{ "ref": "U4", "symbol": "psu", "properties": { "value": "Power" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }
|
||||||
|
],
|
||||||
|
"nets": [
|
||||||
|
{ "name": "3V3", "class": "power", "nodes": [{ "ref": "U4", "pin": "3V3_OUT" }, { "ref": "U1", "pin": "3V3" }, { "ref": "U2", "pin": "3V3" }] },
|
||||||
|
{ "name": "5V", "class": "power", "nodes": [{ "ref": "U4", "pin": "5V_OUT" }, { "ref": "U3", "pin": "5V" }] },
|
||||||
|
{ "name": "GND", "class": "ground", "nodes": [{ "ref": "U4", "pin": "GND" }, { "ref": "U1", "pin": "GND" }, { "ref": "U2", "pin": "GND" }, { "ref": "U3", "pin": "GND" }] },
|
||||||
|
{ "name": "I2S_BCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO5" }, { "ref": "U2", "pin": "BCLK" }] },
|
||||||
|
{ "name": "I2S_LRCLK", "class": "clock", "nodes": [{ "ref": "U1", "pin": "GPIO6" }, { "ref": "U2", "pin": "LRCLK" }] },
|
||||||
|
{ "name": "I2S_DOUT", "class": "signal", "nodes": [{ "ref": "U1", "pin": "GPIO7" }, { "ref": "U2", "pin": "DIN" }] },
|
||||||
|
{ "name": "AUDIO_ANALOG", "class": "analog", "nodes": [{ "ref": "U2", "pin": "AOUT" }, { "ref": "U3", "pin": "IN" }] }
|
||||||
|
],
|
||||||
|
"constraints": {
|
||||||
|
"groups": [
|
||||||
|
{ "name": "power_stage", "members": ["U4"], "layout": "cluster" },
|
||||||
|
{ "name": "compute", "members": ["U1", "U2"], "layout": "cluster" }
|
||||||
|
],
|
||||||
|
"alignment": [{ "left_of": "U1", "right_of": "U2" }],
|
||||||
|
"near": [{ "component": "U2", "target_pin": { "ref": "U1", "pin": "GPIO5" } }]
|
||||||
|
},
|
||||||
|
"annotations": [
|
||||||
|
{ "text": "I2S audio chain" }
|
||||||
|
]
|
||||||
|
}
|
||||||
288
frontend/schemeta.schema.json
Normal file
288
frontend/schemeta.schema.json
Normal 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
443
frontend/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
291
src/compile.js
291
src/compile.js
@ -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")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
1495
src/layout.js
1495
src/layout.js
File diff suppressed because it is too large
Load Diff
226
src/mcp-server.js
Normal file
226
src/mcp-server.js
Normal 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();
|
||||||
564
src/render.js
564
src/render.js
@ -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("&", "&")
|
.replaceAll("&", "&")
|
||||||
@ -8,47 +20,425 @@ function esc(text) {
|
|||||||
.replaceAll('"', """);
|
.replaceAll('"', """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|||||||
216
src/server.js
216
src/server.js
@ -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, () => {
|
||||||
|
|||||||
524
src/validate.js
524
src/validate.js
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user