diff --git a/demo/Joystick Example/circuit.json b/demo/Joystick Example/circuit.json new file mode 100644 index 0000000..856ced8 --- /dev/null +++ b/demo/Joystick Example/circuit.json @@ -0,0 +1,102 @@ +{ + "format": "tinystudio-circuit", + "version": 2, + "meta": { + "created": "2026-07-03T17:07:26.415Z" + }, + "parts": [ + { + "id": "potentiometer-rotary-16mm-5_g9d", + "type": "potentiometer-rotary-16mm-5", + "attrs": { + "label": "Potentiometer" + }, + "bb": { + "x": 710.4, + "y": -124.8, + "labelOffset": [ + 65, + -68 + ] + } + }, + { + "id": "battery-aa_2dp", + "type": "battery-aa", + "bb": { + "x": 329, + "y": -27 + } + }, + { + "id": "led-generic-5mm_fsn", + "type": "led-generic-5mm", + "bb": { + "x": 637, + "y": -57.6, + "labelOffset": [ + -42, + -67 + ] + } + }, + { + "id": "resistor_hwt", + "type": "resistor", + "bb": { + "x": 643.2, + "y": 63 + } + } + ], + "wires": [ + { + "id": "w_5m58u96x", + "from": "battery-aa_2dp:+", + "to": "led-generic-5mm_fsn:cathode", + "view": "bb", + "color": "#e5544b", + "route": [ + "h98.21", + "v-41.56", + "h-0.16" + ] + }, + { + "id": "w_hw4el0vi", + "from": "led-generic-5mm_fsn:anode", + "to": "potentiometer-rotary-16mm-5_g9d:leg1", + "view": "bb", + "color": "#42a5f5", + "route": [ + "v6.07", + "h66.91" + ] + }, + { + "id": "w_1laeiu0i", + "from": "potentiometer-rotary-16mm-5_g9d:wiper", + "to": "resistor_hwt:Pin 1", + "view": "bb", + "color": "#42a5f5", + "route": [ + "v84.36", + "h-28.32", + "v-0.27", + "h-26.96" + ] + }, + { + "id": "w_oggo6jgz", + "from": "resistor_hwt:Pin 0", + "to": "battery-aa_2dp:-", + "view": "bb", + "color": "#79818c", + "route": [ + "h-39.36", + "v-30.35", + "h-59.81" + ] + } + ] +} diff --git a/demo/Joystick Example/diagram.json b/demo/Joystick Example/diagram.json deleted file mode 100644 index c99d803..0000000 --- a/demo/Joystick Example/diagram.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "version": 1, - "editor": "tinystudio", - "parts": [ - { - "type": "potentiometer-rotary-16mm-5", - "id": "potentiometer-rotary-16mm-5_g9d", - "left": 710.4, - "top": -124.8, - "attrs": { - "labelOffset": [ - 65, - -68 - ], - "label": "Potentiometer" - } - }, - { - "type": "battery-aa", - "id": "battery-aa_2dp", - "left": 329, - "top": -27 - }, - { - "type": "led-generic-5mm", - "id": "led-generic-5mm_fsn", - "left": 637, - "top": -57.599999999999994, - "attrs": { - "labelOffset": [ - -42, - -67 - ] - } - }, - { - "type": "resistor", - "id": "resistor_hwt", - "left": 643.1999999999999, - "top": 63 - } - ], - "connections": [ - [ - "battery-aa_2dp:+", - "led-generic-5mm_fsn:cathode", - "#e5544b", - [ - "h98.21", - "v-41.56", - "h-0.16" - ] - ], - [ - "led-generic-5mm_fsn:anode", - "potentiometer-rotary-16mm-5_g9d:leg1", - "#42a5f5", - [ - "v6.07", - "h66.91" - ] - ], - [ - "potentiometer-rotary-16mm-5_g9d:wiper", - "resistor_hwt:Pin 1", - "#42a5f5", - [ - "v84.36", - "h-28.32", - "v-0.27", - "h-26.96" - ] - ], - [ - "resistor_hwt:Pin 0", - "battery-aa_2dp:-", - "#79818c", - [ - "h-39.36", - "v-30.35", - "h-59.81" - ] - ] - ] -} \ No newline at end of file diff --git a/docs/circuit-architecture-and-roadmap.md b/docs/circuit-architecture-and-roadmap.md new file mode 100644 index 0000000..10af7be --- /dev/null +++ b/docs/circuit-architecture-and-roadmap.md @@ -0,0 +1,282 @@ +# Circuit Design Document — Fritzing vs. tinyStudio, Fritzing Part Import, and the Road to KiCad + +**Status:** Draft for review · **Date:** 2026-07-02 · **Scope:** `fritzing-app` (upstream reference) and `tinyStudio` Circuit view (`DiagramEditor.tsx`, `wireRouting.ts`, `circuitNets.ts`, `partsLibrary.ts`, `PartsEditor.tsx`, `scripts/fritzing-import.mjs`) + +This document explains how Fritzing's breadboard/wiring/parts architecture works, how tinyStudio's circuit view works today, the exact differences between them, how a Fritzing part can be imported directly, the bugs and gaps in the current circuit designer, and a phased roadmap toward "the best breadboard view ever" with schematic generation and KiCad export — while keeping `diagram.json` Wokwi-compatible. + +--- + +## Part I — How Fritzing works + +Fritzing is a Qt/C++ desktop app built on `QGraphicsScene`. One electrical **model** drives four synchronized **views** (icon, breadboard, schematic, PCB). Everything below is verified against the `fritzing-app` source in this workspace. + +### 1.1 The part model: `.fzp` + per-view SVGs + +A Fritzing part is an XML file (`.fzp`) plus up to four SVG files (one per view). The `.fzp` contains: + +- **Metadata** — `moduleId` (unique id), title, label, author, date, tags, `taxonomy`, description. +- **Properties** — a `family` (parts in the same family are hot-swappable in the Inspector) plus free-form key/value pairs (`resistance`, `package`, `voltage` …). +- **Views** — for each of `iconView` / `breadboardView` / `schematicView` / `pcbView`, an `image` path to an SVG and one or more `layer` ids (`breadboard`, `schematic`, `copper0`, `copper1`, `silkscreen` …). PCB parts reference multiple layers inside a single SVG. +- **Connectors** — each `` has an `id` (`connector0` …), a human `name` (`V+`, `GND`), a `type` (**male** or **female** — this drives breadboard behavior, see §1.3), optional ERC metadata (``, current/voltage hints), and per-view bindings: + - `svgId` — the id of the graphic element in that view's SVG that *is* the pin (e.g. `connector0pin`). + - `terminalId` (optional) — a smaller element marking the exact wire-attach point (used mostly in schematic view; without it the terminal defaults to the center of the `svgId` element). + - `legId` (optional) — for parts with bendable rubber-band legs (LEDs, resistors in breadboard view), the SVG element that is the flexible leg. +- **Buses** — `` declares internal connectivity: a `` lists `` pins that are permanently connected inside the part. **This is how a breadboard works in Fritzing**: the breadboard part has hundreds of female connectors, and each 5-hole row (and each power rail) is a bus. +- **Subparts** (schematic only) — multi-unit parts (e.g. quad op-amps) that can be placed as separate schematic symbols. + +Key insight: **the `.fzp` stores no pin coordinates.** Geometry lives entirely in the SVGs; the `.fzp` binds connector ids to SVG element ids. Any importer must resolve those elements inside the SVG (including ancestor `transform`s) to get pin positions — which is exactly what `scripts/fritzing-import.mjs` does. + +Parts ship as `.fzpz` (a ZIP of the `.fzp` + its SVGs); sketches ship as `.fzz` (a ZIP containing a `.fz` XML plus any non-core parts used). + +### 1.2 The scene/item architecture + +- `ModelPart` / `ModelPartShared` — the in-memory part (shared data parsed once from the `.fzp`). +- `ItemBase` → `PaletteItem` → concrete classes (`Breadboard`, `Resistor`, `Wire`, `SymbolPaletteItem` for net labels/power symbols, `Dip`, `MysteryPart` …) — a `QGraphicsItem` per part *per view*. `PartFactory` picks the class from the moduleId/family. +- `ConnectorItem` — a `QGraphicsRectItem` child of each part for every connector in every view; handles hover highlight, hit testing, and holds the list of `connectedTo` ConnectorItems. +- `Bus` / `BusShared` — internal-connection groups from the `.fzp`. +- `SketchWidget` (≈10,800 lines) — the shared view controller; `BreadboardSketchWidget`, `SchematicSketchWidget`, `PcbSketchWidget` subclass it with per-view rules (`ignoreFemale()`, `canDropModelPart()`, trace widths, colors, grid defaults: 0.1 in breadboard/schematic, 0.05 in PCB). +- Every mutation is a `QUndoCommand` on a shared undo stack, and commands carry a **CrossViewType** so an action in one view replays into the others. + +### 1.3 Breadboard view mechanics (the part to learn from) + +The breadboard view has no special "breadboard mode" — it emerges from a few composable rules: + +1. **Male vs. female connectors.** Breadboard holes are *female*; component pins and wire ends are *male*. When a part is dropped or moved, `findConnectorsUnder()` hit-tests each of its male ConnectorItems against female ConnectorItems beneath it (`Breadboard::canFindConnectorsUnder()` returns false — the board itself never searches; things land *on it*). Overlap ⇒ an implicit connection is formed, with no wire drawn. Move the part away ⇒ the connection dissolves (`disconnectFromFemale`). +2. **Buses make rows live.** Inserting a pin into one hole connects it to the whole 5-hole row because the row is a bus in the breadboard's `.fzp`. Power rails are long buses. +3. **Stickiness.** The breadboard is *sticky* (`ItemBase::m_sticky`): parts dropped on it are recorded in `m_stickyList` and move with it when the board is dragged. +4. **Wires with bendpoints.** A user wire is a chain of `Wire` items — each `Wire` is one straight `QGraphicsLineItem` segment with a ConnectorItem at each end; a bendpoint is where two segments share a position. `Wire::collectChained()` walks the chain to find true endpoints. Breadboard wires may also carry **Bézier curvature** per segment (`` control points in the sketch file) for the pretty curved-jumper look. Dragging from any male/female connector spawns a temp wire (`createTempWireForDragging`); dropping on a legal target commits it. +5. **Rubber-band legs.** Parts with `legId` connectors (LED, resistor) have draggable legs that stretch to reach holes — the leg is part geometry, not a wire. +6. **Ratsnest sync.** Every connect/disconnect calls `ratsnestConnect()`; other views receive **VirtualWire** ratsnest lines grouping equal-potential connectors, which the user then routes properly in that view (schematic wires, PCB traces). This is the bridge that turns a breadboard layout into a schematic to route. + +### 1.4 Nets, ERC and routing + +- Equal-potential collection is a flood fill (`collectEqualPotential`) across: wire connections, female/male stacking, part-internal buses, and same-net symbols (net labels, power symbols via `symbolpaletteitem`). +- `RoutingStatus` tracks nets total / routed / connectors-to-route per view. +- ERC data (`erc.h`, connector `etype`, current/voltage) supports a basic electrical rule check and drives the newer simulation module (`src/simulation`, ngspice-based). +- PCB view has a full maze autorouter + DRC (`src/autoroute`), Gerber export (`svg2gerber`), and ground-plane generation. Schematic/breadboard have no autorouter — humans route; only the ratsnest guides them. +- `src/svg/kicad2svg` imports **KiCad → Fritzing** (modules and schematic symbols → part SVGs). There is no KiCad *export* in Fritzing — a gap tinyStudio can leapfrog. + +### 1.5 Sketch file format (`.fz` inside `.fzz`) + +XML `` with `` (per-view background/grid settings) and ``. Each `` has `moduleIdRef`, a per-view `` (x/y/z, and for wires `x1/y1/x2/y2` + `wireFlags`), optional `` (width, color, Bézier control points), and a `` block whose `` children record the id + modelIndex of everything each connector touches. Connections are therefore stored **explicitly and per-view**, symmetric on both sides. + +--- + +## Part II — How tinyStudio's Circuit view works + +The Circuit view is a React component tree inside an Electron (or web) app. The single source of truth is a **`diagram.json` file in the user's project**, edited live: `EditorPanel.tsx → CircuitView` loads `diagram.json` (creating a default if absent) and hands its text to `DiagramEditor`, which parses on every change and writes back serialized JSON through Redux (`updateFileContent`). Editing the JSON in the Code view and dragging parts in the Circuit view are the same operation on the same buffer. + +### 2.1 Data model (Wokwi-first) + +```jsonc +{ + "version": 1, + "editor": "tinystudio", + "author": "…", + "parts": [ { "type": "resistor", "id": "resistor_a1x", "left": 520, "top": 230, "rotate": 90, "attrs": {…} } ], + "connections": [ ["tinycore:SIG", "resistor:Pin 0", "#36c46b", ["v-22.73", "h-179.8"]] ], + "schematic": { "pos": { "partId": [x, y] }, "routes": { "": ["h…","v…"] } } +} +``` + +- **Parts** use Wokwi's `id`/`type`/`left`/`top`/`rotate`/`attrs` fields (px @ 96 DPI; 0.1 in = 9.6 px grid). +- **Connections** use Wokwi's 4-tuple `[from, to, color, journey]` with `h`/`v` instructions. Two tinyStudio extensions: a `d,` instruction for diagonal ("straight mode") segments, and endpoints may be a free point `{x, y}` (a junction tapping another wire's body) instead of `"part:pin"`. +- **`schematic`** is a per-view overlay (positions + routes) so a future schematic view can diverge from the breadboard layout without duplicating the electrical data. Pin *names* are stable across views, so connections stay valid when switching views. + +### 2.2 Parts library (`partsLibrary.ts`) + +One schema for all parts (`PartDef`): `type`, `label`, `family`, and `views.{breadboard,schematic}` each `{ svg, w, h, pins: { name: [x, y] } }` — inline SVG plus pin coordinates in px from the part's top-left. Sources: + +1. **Built-ins** — the tiny* boards, authored inline with generated SVG. +2. **Fritzing-imported catalogue** — 29 parts currently under `assets/parts/`, one JSON per part, lazy-loaded via Vite dynamic import; `index.json` is the eagerly-loaded manifest (label, family, icon, pin count) that fills the palette. +3. **User parts** — `PartsEditor` (modal: upload SVG, click to drop pins, drag to place, name them) registers a `PartDef` into the live registry via `registerPart()`. **In-memory only — not persisted** (see bug B7). + +### 2.3 The import pipeline that already exists (`scripts/fritzing-import.mjs`) + +This is the crucial piece for "take a Fritzing part and drop it straight into the circuit view." It already works, offline, against a checkout of `fritzing-parts`: + +1. Parse the `.fzp` (metadata, per-view `image`, connector list with `svgId`/`terminalId`/`legId`). +2. Load the view SVG; read `viewBox` + real-world `width`/`height` (in/mm/pt…), convert to px @ 96 DPI (`in×96`, `mm×3.7795` …). +3. For each connector, find the `svgId`/`terminalId` element (schematic prefers `terminalId` — the precise attach point; breadboard prefers `svgId`), compute its **local anchor** (rect center, circle center, path/polygon bbox center, line far-end heuristic, first-child for `` wrappers), then apply the **cumulative ancestor transform chain** and scale into canvas px. +4. Emit `PartDef` JSON + manifest entry + `_report.json` (ok / partial / failed / skipped). + +So the conversion math (the genuinely hard part: FZP→pixels with transforms and units) is done and validated. What's missing is productization — see Part IV. + +### 2.4 Wiring engine (`wireRouting.ts` — ported from the tinySchematic prototype) + +- **Absolute bendpoints.** A wire's stored instruction list is decoded into interior bend positions *fixed in world space*. When a part moves, only the leg(s) touching it re-anchor (`collectFrozen` → `buildWirePoints(source, bends, target)`); the wire body holds still. This mirrors Fritzing's behavior, where bendpoints are their own objects. +- **Orthogonal elbow routing.** `calculateOrthogonalPath` builds L-paths; the final segment approaches the pin along the major travel axis so wires dock perpendicular. Hold Shift for straight/diagonal mode (`d` instructions). +- **Junctions.** While drawing (or dragging an endpoint), landing on another wire's body clamps the endpoint onto that segment (`clampOntoSegment`) and stores a free `{x,y}` endpoint; a solder dot is rendered, and the net model merges the nets. +- **Editing.** Selected wires get Fritzing-style handles: squares mid-segment (perpendicular segment drag via `dragSegment`, inserting corner bends at anchored ends), circles at bends (`vertexDrag` keeps neighbors orthogonal), rings at endpoints (retarget to a pin, a wire body, or free space). Double-click adds/removes a bend. `simplifyWirePoints` drops collinear/duplicate points before saving. + +### 2.5 Net model (`circuitNets.ts`) + +Union-find (DSU) over endpoint keys with three edge types: physical connections, part-internal **buses** (via an optional `busesFor` callback — **currently never supplied by the editor**, see bug B8), and junction endpoints lying within 2 px of another wire's polyline. Output maps pins/connections → net index; drives the yellow equipotential hover glow and the "Nets: N" counter. This is a faithful miniature of Fritzing's `collectEqualPotential`. + +### 2.6 Rendering & UX + +Parts are absolutely-positioned `
`s with `dangerouslySetInnerHTML` SVG bodies (CSS `rotate` around center); wires are a single SVG overlay (glow → dark outline → color core → animated selection dashes, rounded fillets via quadratic arcs); pins are 16 px hit targets rendered only in edit mode. Custom camera (`{scale, tx, ty}`), wheel zoom (0.25–3×), middle/Alt-drag pan, fit-to-view. Two-column palette rail (left) and Inspector (right: name, x/y, rotation, free-form `attrs` properties, wire color, net pin count). PNG export composes a standalone SVG and rasterizes at 2×. View/edit toggle; footer status pills. + +--- + +## Part III — Architecture comparison + +| Aspect | Fritzing | tinyStudio Circuit view | +|---|---|---| +| Platform | Qt/C++, QGraphicsScene | Electron/web, React + SVG/DOM | +| Document | `.fzz` (zipped XML), instances with per-view geometry, symmetric explicit connects | `diagram.json` (Wokwi format + extensions), connections as endpoint tuples | +| Views | 4 synced views (icon/bb/schematic/pcb), cross-view undo commands | Breadboard live; schematic exists as a per-view overlay in data + code but is **not reachable in the UI** (view state is hard-locked) | +| Part format | `.fzp` XML + per-view SVGs, pins = SVG element refs | `PartDef` JSON, pins = resolved px coordinates, SVG inlined | +| Pin identity | `connector0…N` ids + names, male/female typed | pin *names* only; no male/female concept | +| Breadboard | Real part: female connectors + row buses + sticky + drop-to-connect | No breadboard part; wires connect pin-to-pin only | +| Bendpoints | Wire = chain of segment items; Bézier curves optional | Instruction list (`h/v/d`) decoded to absolute bendpoints | +| Junctions | Bendpoint-on-wire creates a shared connector | Free `{x,y}` endpoint + proximity net merge | +| Nets | Flood fill incl. buses, net labels, power symbols | DSU incl. junctions; bus support present but unused; no net labels/power symbols | +| Undo | Full QUndoStack, cross-view | **None** (every drag frame rewrites the file buffer) | +| ERC/simulation | ERC types per connector; ngspice simulation | None (Wokwi compat is the intended simulation path) | +| PCB | Full view, maze autorouter, DRC, Gerber | Out of scope (KiCad export is the plan) | +| KiCad | Import only (KiCad→Fritzing SVG) | None yet — export is roadmap Phase 4 | +| Rubber-band legs | Yes (`legId`) | No | +| Part swapping | Family-based Inspector swap | No | + +--- + +## Part IV — Importing a Fritzing part directly into the Circuit view + +### 4.1 Why it already (mostly) just works + +The importer proves the mapping is clean: + +| Fritzing | tinyStudio | Notes | +|---|---|---| +| `.fzp` title/label/family/description | `label` / `family` / `description` | `family` also groups the palette | +| filename / `moduleId` | `type` (slugged filename; moduleId kept in `source`) | moduleIds in core are often random hashes; filenames are the stable human identity | +| `breadboardView`/`schematicView` image SVG | `views.{breadboard,schematic}.svg` (minified, width/height stripped, viewBox kept) | | +| SVG real-world size | `w`/`h` in px @ 96 DPI | `in×96`, `mm×3.7795`, `pt×1.333` | +| connector `name` (fallback `id`) | pin name (deduped `name.2`, `name.3`…) | names must stay stable across views | +| `svgId`/`terminalId` element position | `pins[name] = [x, y]` | ancestor transforms accumulated; schematic prefers `terminalId` | + +Because pin coordinates are resolved to Wokwi pixel space at import time, an imported part **drops straight into `diagram.json`** and wires exactly like a hand-made or built-in part. There is no runtime dependency on Fritzing. + +### 4.2 What's still missing for "no difficulty" in-app import + +1. **`.fzpz` drag-and-drop in the app.** Today import is a build-time Node script against a repo checkout. Ship the same logic in the renderer (it's pure XML/string work; bundle `@xmldom/xmldom` or use the browser `DOMParser`, and `fflate` for the ZIP): drop a `.fzpz` onto the palette → unzip → parse `.fzp` → resolve pins from the bundled SVGs → `registerPart()` → **persist** (see B7). The only script feature needing care is path resolution (`.fzpz` bundles its SVGs with prefixed names like `svg.breadboard.foo.svg`, so section-searching isn't needed). +2. **Buses.** The importer drops `` entirely. Emit `buses: string[][]` (groups of pin names) into `PartDef` and pass a `busesFor` callback to `buildNets` (the parameter already exists). Without this, any imported part with internal common pins — most importantly *a breadboard* — nets wrongly. +3. **Female connectors.** Add `pinType: 'male' | 'female'` per pin. Needed for drop-to-connect breadboard behavior (Phase 2) and for better wire-end semantics; harmless otherwise. +4. **Bendable legs (`legId`).** Currently ignored; leg pins resolve to the drawn leg end, which is acceptable. Real rubber-band legs are a Phase 2 renderer feature; importer just needs to tag `leg: true` on those pins. +5. **SVG hygiene at import time** (fixes live rendering *and* PNG export): + - **Namespace all `id`s** (`-`) — Fritzing SVGs freely reuse ids (`connector0pin`, gradient ids), and multiple inlined parts on one page currently collide (bug B6). + - Inline `class`-based styles (some parts carry `' + const out = namespaceSvgIds(svg, 'pR1') + assert.ok(out.includes('#pR1-body{fill:blue}')) + assert.ok(out.includes('#other{}')) // not defined as an id here — untouched + assert.ok(out.includes('id="pR1-body"')) +}) diff --git a/src/renderer/src/circuit/__tests__/symbols.test.ts b/src/renderer/src/circuit/__tests__/symbols.test.ts new file mode 100644 index 0000000..4bb41ff --- /dev/null +++ b/src/renderer/src/circuit/__tests__/symbols.test.ts @@ -0,0 +1,54 @@ +/** Tests for parts/symbols — generated IC-box schematic symbols. */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { GRID_BB } from '../core/model' +import { generateBoxSymbol, schematicVisual } from '../parts/symbols' +import type { PartDef } from '../../lib/partsLibrary' + +const def: PartDef = { + type: 'testpart', + label: 'Test Part', + views: { + breadboard: { + svg: '', + w: 50, + h: 20, + pins: { VCC: [0, 0], GND: [0, 10], SDA: [50, 0], SCL: [50, 10], INT: [25, 20] } + } + } +} + +test('generated symbol: every pin on the 9.6 major grid, left/right split by order', () => { + const v = generateBoxSymbol(def) + assert.equal(Object.keys(v.pins).length, 5) + for (const [name, [x, y]] of Object.entries(v.pins)) { + assert.ok(Math.abs(x / GRID_BB - Math.round(x / GRID_BB)) < 1e-9, `${name} x=${x}`) + assert.ok(Math.abs(y / GRID_BB - Math.round(y / GRID_BB)) < 1e-9, `${name} y=${y}`) + } + // first ceil(5/2)=3 pins on the left edge (x=0), rest on the right edge + assert.equal(v.pins['VCC'][0], 0) + assert.equal(v.pins['GND'][0], 0) + assert.equal(v.pins['SDA'][0], 0) + assert.equal(v.pins['SCL'][0], v.w) + assert.equal(v.pins['INT'][0], v.w) +}) + +test('generated symbol SVG carries the label and ink strokes', () => { + const v = generateBoxSymbol(def) + assert.ok(v.svg.includes('Test Part')) + assert.ok(v.svg.includes('var(--text-strong)')) + assert.ok(v.svg.includes('viewBox')) +}) + +test('schematicVisual prefers authored art and caches generated symbols', () => { + const authored: PartDef = { + ...def, + type: 'authored', + views: { ...def.views, schematic: { svg: 'real', w: 10, h: 10, pins: { VCC: [0, 0] } } } + } + assert.equal(schematicVisual(authored).svg, 'real') + const a = schematicVisual(def) + const b = schematicVisual(def) + assert.equal(a, b) // cached instance +}) diff --git a/src/renderer/src/circuit/core/clipboard.ts b/src/renderer/src/circuit/core/clipboard.ts new file mode 100644 index 0000000..440b16d --- /dev/null +++ b/src/renderer/src/circuit/core/clipboard.ts @@ -0,0 +1,157 @@ +/** + * circuit/core/clipboard — copy/paste/duplicate as pure document logic. + * + * The clipboard payload is JSON (spec §6.2): a self-contained fragment of + * parts + wires. It travels on the system clipboard as text (a custom MIME + * type is unreliable across the desktop and web builds), detected by shape. + * + * Rules: + * - a wire is included only if BOTH its endpoints stay resolvable inside the + * payload: pin ends must reference included parts; junction ends must + * reference included wires (filtered to a fixpoint). + * - on paste, parts get fresh refdes ids (prefix derived from the old id), + * wires get fresh ids, endpoints are rewritten, placements are offset. + */ + +import { newId, splitPinRef, isJunction, type CircuitDoc, type CircuitPart, type CircuitWire, type WireEnd } from './model' + +export const CLIPBOARD_FORMAT = 'tinystudio-circuit-clipboard' + +export interface ClipboardPayload { + format: typeof CLIPBOARD_FORMAT + version: 2 + parts: CircuitPart[] + wires: CircuitWire[] +} + +/** Build a payload from a selection. Returns null when nothing copyable. */ +export function buildClipboard( + doc: CircuitDoc, + partIds: Iterable, + wireIds: Iterable = [] +): ClipboardPayload | null { + const pset = new Set(partIds) + const wsel = new Set(wireIds) + const parts = doc.parts.filter((p) => pset.has(p.id)) + + // candidates: explicitly selected wires + wires whose pin ends all sit on + // selected parts (junction ends are provisionally in — the fixpoint below + // keeps them only if their host wire survives) + const pinIn = (e: WireEnd): boolean => typeof e === 'string' && pset.has(splitPinRef(e).part) + const endIn = (e: WireEnd): boolean => isJunction(e) || pinIn(e) + const candidates = doc.wires.filter( + (w) => wsel.has(w.id) || (endIn(w.from) && endIn(w.to) && (pinIn(w.from) || pinIn(w.to))) + ) + + // filter to a fixpoint: every endpoint must resolve inside the payload + let keep = new Map(candidates.map((w) => [w.id, w])) + for (;;) { + const next = new Map() + for (const w of keep.values()) { + const ok = (e: WireEnd): boolean => + isJunction(e) ? keep.has(e.wire) : pset.has(splitPinRef(e).part) + if (ok(w.from) && ok(w.to)) next.set(w.id, w) + } + if (next.size === keep.size) break + keep = next + } + + if (parts.length === 0 && keep.size === 0) return null + return { + format: CLIPBOARD_FORMAT, + version: 2, + parts: parts.map((p) => structuredClone(p)), + wires: [...keep.values()].map((w) => structuredClone(w)) + } +} + +/** Parse clipboard text into a payload, or null when it isn't ours. */ +export function parseClipboard(text: string): ClipboardPayload | null { + try { + const raw = JSON.parse(text) + if (raw?.format !== CLIPBOARD_FORMAT || raw.version !== 2) return null + return { + format: CLIPBOARD_FORMAT, + version: 2, + parts: Array.isArray(raw.parts) ? raw.parts : [], + wires: Array.isArray(raw.wires) ? raw.wires : [] + } + } catch { + return null + } +} + +/** Refdes prefix of an existing id: "R12" → "R", "led_3a" → "led". */ +function idPrefix(id: string): string { + const m = /^([A-Za-z][A-Za-z_-]*?)[_-]?\d*$/.exec(id) + return m ? m[1] : 'P' +} + +export interface MaterializedPaste { + parts: CircuitPart[] + wires: CircuitWire[] + /** old part id → new part id (for selecting the pasted set). */ + idMap: Map +} + +/** + * Re-id a payload against a target document and offset its placements. + * Pure — the caller wraps the result in addPart/addWire commands. + */ +export function materializePaste( + doc: CircuitDoc, + payload: ClipboardPayload, + offset: { x: number; y: number } +): MaterializedPaste { + // seed per-prefix counters from the target doc + const counters = new Map() + const bump = (prefix: string): number => { + if (!counters.has(prefix)) { + let max = 0 + const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`) + for (const p of doc.parts) { + const m = re.exec(p.id) + if (m) max = Math.max(max, parseInt(m[1], 10)) + } + counters.set(prefix, max) + } + const n = counters.get(prefix)! + 1 + counters.set(prefix, n) + return n + } + + const idMap = new Map() + const parts = payload.parts.map((src) => { + const p = structuredClone(src) + const prefix = idPrefix(p.id) + p.id = `${prefix}${bump(prefix)}` + idMap.set(src.id, p.id) + if (p.bb) { + p.bb.x += offset.x + p.bb.y += offset.y + } + if (p.sch) { + p.sch.x += offset.x + p.sch.y += offset.y + } + return p + }) + + const wireIdMap = new Map() + for (const w of payload.wires) wireIdMap.set(w.id, newId('w')) + const fixEnd = (e: WireEnd): WireEnd => { + if (isJunction(e)) return { ...e, wire: wireIdMap.get(e.wire) ?? e.wire } + const { part, pin } = splitPinRef(e) + const mapped = idMap.get(part) + return mapped ? `${mapped}:${pin}` : e + } + const wires = payload.wires.map((src) => { + const w = structuredClone(src) + w.id = wireIdMap.get(src.id)! + w.from = fixEnd(w.from) + w.to = fixEnd(w.to) + return w + }) + + return { parts, wires, idMap } +} diff --git a/src/renderer/src/circuit/core/commands.ts b/src/renderer/src/circuit/core/commands.ts new file mode 100644 index 0000000..ce01cf8 --- /dev/null +++ b/src/renderer/src/circuit/core/commands.ts @@ -0,0 +1,341 @@ +/** + * circuit/core/commands — every document mutation as a Command. + * + * Commands are PURE: `apply(doc)` returns a new doc (structural sharing, no + * mutation), which makes undo trivial (the store keeps the previous doc) and + * React integration free. `mergeKey` lets high-frequency gestures (drags) + * collapse into a single undo step: consecutive commands with the same + * mergeKey replace the top stack entry instead of pushing a new one. + * + * Geometry-dependent updates (wire re-anchoring while a part moves) are + * computed by the VIEW (it owns pin positions via the registry) and passed in + * as data — core stays free of parts/geometry knowledge. + */ + +import { + isJunction, + newId, + splitPinRef, + type Analysis, + type CircuitDoc, + type CircuitPart, + type CircuitWire, + type NetLabel, + type Placement, + type Probe, + type ViewId, + type WireEnd +} from './model' + +export interface Command { + label: string + /** Same key on consecutive commands ⇒ they merge into one undo step. */ + mergeKey?: string + apply(doc: CircuitDoc): CircuitDoc +} + +export interface WireReroute { + wireId: string + route: string[] +} + +// ── parts ──────────────────────────────────────────────────────────────────── + +export function addPart(part: CircuitPart): Command { + return { + label: `Add ${part.id}`, + apply: (doc) => ({ ...doc, parts: [...doc.parts, part] }) + } +} + +/** Move/replace a part's placement in one view; `reroutes` keeps its wires' bends fixed. */ +export function placePart( + id: string, + view: ViewId, + placement: Placement | undefined, + reroutes: WireReroute[] = [], + merge = false +): Command { + return { + label: `Move ${id}`, + mergeKey: merge ? `place:${view}:${id}` : undefined, + apply: (doc) => ({ + ...doc, + parts: doc.parts.map((p) => (p.id === id ? { ...p, [view]: placement } : p)), + wires: applyReroutes(doc.wires, reroutes) + }) + } +} + +export function setPartAttr( + id: string, + key: string, + value: string | number | boolean | undefined +): Command { + return { + label: `Set ${id}.${key}`, + mergeKey: `attr:${id}:${key}`, + apply: (doc) => ({ + ...doc, + parts: doc.parts.map((p) => { + if (p.id !== id) return p + const attrs = { ...(p.attrs || {}) } + if (value === undefined) delete attrs[key] + else attrs[key] = value + return { ...p, attrs } + }) + }) + } +} + +/** Rename a part (refdes). Rewrites every wire endpoint that references it. */ +export function renamePart(oldId: string, next: string): Command { + return { + label: `Rename ${oldId} → ${next}`, + apply: (doc) => { + if (doc.parts.some((p) => p.id === next)) return doc // uniqueness guard — no-op + const fixEnd = (e: WireEnd): WireEnd => { + if (typeof e !== 'string') return e + const { part, pin } = splitPinRef(e) + return part === oldId ? `${next}:${pin}` : e + } + return { + ...doc, + parts: doc.parts.map((p) => (p.id === oldId ? { ...p, id: next } : p)), + wires: doc.wires.map((w) => ({ ...w, from: fixEnd(w.from), to: fixEnd(w.to) })) + } + } + } +} + +/** + * Delete parts (both views) and cascade: wires touching them are removed, + * junction riders on removed wires are repaired (§ junction cascade). + */ +export function deleteParts(ids: string[]): Command { + const idSet = new Set(ids) + return { + label: ids.length === 1 ? `Delete ${ids[0]}` : `Delete ${ids.length} parts`, + apply: (doc) => { + const touchesDeleted = (e: WireEnd): boolean => + typeof e === 'string' && idSet.has(splitPinRef(e).part) + const goneWires = new Set( + doc.wires.filter((w) => touchesDeleted(w.from) || touchesDeleted(w.to)).map((w) => w.id) + ) + return { + ...doc, + parts: doc.parts.filter((p) => !idSet.has(p.id)), + wires: cascadeWireRemoval(doc.wires, goneWires) + } + } + } +} + +// ── wires ──────────────────────────────────────────────────────────────────── + +export function addWire(wire: Omit & { id?: string }): Command { + const w: CircuitWire = { id: wire.id ?? newId('w'), ...wire } as CircuitWire + return { + label: 'Add wire', + apply: (doc) => { + // duplicate guard (B5): identical endpoints in either order, same view + const dup = doc.wires.some( + (x) => + x.view === w.view && + ((sameEnd(x.from, w.from) && sameEnd(x.to, w.to)) || + (sameEnd(x.from, w.to) && sameEnd(x.to, w.from))) + ) + if (dup) return doc + return { ...doc, wires: [...doc.wires, w] } + } + } +} + +export function rerouteWire(id: string, route: string[], merge = false): Command { + return { + label: 'Reshape wire', + mergeKey: merge ? `route:${id}` : undefined, + apply: (doc) => ({ + ...doc, + wires: doc.wires.map((w) => (w.id === id ? { ...w, route } : w)) + }) + } +} + +export function setWireEnds( + id: string, + from: WireEnd | undefined, + to: WireEnd | undefined, + route?: string[] +): Command { + return { + label: 'Reconnect wire', + apply: (doc) => ({ + ...doc, + wires: doc.wires.map((w) => + w.id === id + ? { ...w, from: from ?? w.from, to: to ?? w.to, ...(route ? { route } : {}) } + : w + ) + }) + } +} + +export function recolorWire(id: string, color: string): Command { + return { + label: 'Recolor wire', + apply: (doc) => ({ + ...doc, + wires: doc.wires.map((w) => (w.id === id ? { ...w, color } : w)) + }) + } +} + +export function deleteWires(ids: string[]): Command { + return { + label: ids.length === 1 ? 'Delete wire' : `Delete ${ids.length} wires`, + apply: (doc) => ({ ...doc, wires: cascadeWireRemoval(doc.wires, new Set(ids)) }) + } +} + +// ── net labels ─────────────────────────────────────────────────────────────── + +export function addNetLabel(label: NetLabel): Command { + return { + label: `Add label ${label.name}`, + apply: (doc) => ({ ...doc, netLabels: [...(doc.netLabels ?? []), label] }) + } +} +export function deleteNetLabel(id: string): Command { + return { + label: 'Delete label', + apply: (doc) => ({ + ...doc, + netLabels: (doc.netLabels ?? []).filter((l) => l.id !== id), + wires: cascadeWireRemoval( + doc.wires, + new Set( + doc.wires + .filter( + (w) => + (typeof w.from === 'string' && splitPinRef(w.from).part === id) || + (typeof w.to === 'string' && splitPinRef(w.to).part === id) + ) + .map((w) => w.id) + ) + ) + }) + } +} + +export function moveNetLabel( + id: string, + sch: Placement, + reroutes: WireReroute[] = [], + merge = false +): Command { + return { + label: `Move ${id}`, + mergeKey: merge ? `label:${id}` : undefined, + apply: (doc) => ({ + ...doc, + netLabels: (doc.netLabels ?? []).map((l) => (l.id === id ? { ...l, sch } : l)), + wires: applyReroutes(doc.wires, reroutes) + }) + } +} + +export function updateNetLabel( + id: string, + patch: Partial> +): Command { + return { + label: `Edit ${id}`, + apply: (doc) => ({ + ...doc, + netLabels: (doc.netLabels ?? []).map((l) => (l.id === id ? { ...l, ...patch } : l)) + }) + } +} + +// ── sim ────────────────────────────────────────────────────────────────────── + +export function setAnalyses(analyses: Analysis[]): Command { + return { + label: 'Configure simulation', + apply: (doc) => ({ ...doc, sim: { ...(doc.sim ?? {}), analyses } }) + } +} +export function setProbes(probes: Probe[]): Command { + return { + label: 'Update probes', + apply: (doc) => ({ ...doc, sim: { ...(doc.sim ?? {}), probes } }) + } +} + +// ── batch ──────────────────────────────────────────────────────────────────── + +/** Compose several commands into one undo step (cross-view cascades, paste…). + * `mergeKey` lets composite gestures (multi-part drags) collapse like single + * commands do. */ +export function composite(label: string, commands: Command[], mergeKey?: string): Command { + return { + label, + mergeKey, + apply: (doc) => commands.reduce((d, c) => c.apply(d), doc) + } +} + +// ── internals ──────────────────────────────────────────────────────────────── + +function sameEnd(a: WireEnd, b: WireEnd): boolean { + if (typeof a === 'string' || typeof b === 'string') return a === b + return a.wire === b.wire && Math.abs(a.t - b.t) < 0.01 +} + +function applyReroutes(wires: CircuitWire[], reroutes: WireReroute[]): CircuitWire[] { + if (!reroutes.length) return wires + const byId = new Map(reroutes.map((r) => [r.wireId, r.route])) + return wires.map((w) => (byId.has(w.id) ? { ...w, route: byId.get(w.id)! } : w)) +} + +/** + * Remove a set of wires and repair junction riders that referenced them: + * a rider's junction end is re-anchored to the removed host's `from` endpoint + * (which keeps it on the same net); if that is itself removed, the repair + * recurses; unrepairable riders are removed too. (B9 cascade.) + */ +export function cascadeWireRemoval(wires: CircuitWire[], gone: Set): CircuitWire[] { + const byId = new Map(wires.map((w) => [w.id, w])) + const resolveAnchor = (hostId: string, seen: Set): WireEnd | null => { + if (seen.has(hostId)) return null + seen.add(hostId) + const host = byId.get(hostId) + if (!host) return null + if (!gone.has(hostId)) return { wire: hostId, t: 0.5 } // host survives — shouldn't happen, keep rider + if (typeof host.from === 'string') return host.from + return resolveAnchor(host.from.wire, seen) + } + const out: CircuitWire[] = [] + for (const w of wires) { + if (gone.has(w.id)) continue + let from = w.from + let to = w.to + let dead = false + if (isJunction(from) && gone.has(from.wire)) { + const a = resolveAnchor(from.wire, new Set([w.id])) + if (a) from = a + else dead = true + } + if (!dead && isJunction(to) && gone.has(to.wire)) { + const a = resolveAnchor(to.wire, new Set([w.id])) + if (a) to = a + else dead = true + } + if (dead) continue + // collapse degenerate wires (both ends now identical) + if (sameEnd(from, to)) continue + out.push(from === w.from && to === w.to ? w : { ...w, from, to }) + } + return out +} diff --git a/src/renderer/src/circuit/core/erc.ts b/src/renderer/src/circuit/core/erc.ts new file mode 100644 index 0000000..d7547c5 --- /dev/null +++ b/src/renderer/src/circuit/core/erc.ts @@ -0,0 +1,83 @@ +/** + * circuit/core/erc — lightweight electrical-rule checks over the net model + * (spec §9). Non-blocking: every finding is a severity-tagged ErcIssue the UI + * renders in a panel. Pure + registry-free (component floating-pin checks that + * need pin geometry live in the view layer and are merged in there). + */ + +import { splitPinRef, type CircuitDoc } from './model' +import { danglingJunctions, type NetModel } from './nets' + +export type ErcSeverity = 'error' | 'warning' | 'info' + +export interface ErcIssue { + id: string + severity: ErcSeverity + message: string + ref?: { part?: string; wire?: string; label?: string; pin?: string } +} + +/** Net-model rule checks (rail shorts, floating labels, dangling junctions, ground). */ +export function runErc(doc: CircuitDoc, net: NetModel): ErcIssue[] { + const out: ErcIssue[] = [] + const labels = doc.netLabels ?? [] + + // dangling junction hosts — a rider references a wire that no longer exists. + for (const wid of danglingJunctions(doc)) { + out.push({ + id: `dangling:${wid}`, + severity: 'error', + message: `Wire ${wid} rides a junction on a missing wire.`, + ref: { wire: wid } + }) + } + + // rail short — one net carries two different named rails/grounds (GND + 5V…). + const railsByNet = new Map>() + for (const l of labels) { + if (l.kind === 'net') continue + const idx = net.pinToNet.get(`${l.id}:1`) + if (idx === undefined) continue + let set = railsByNet.get(idx) + if (!set) railsByNet.set(idx, (set = new Set())) + set.add(l.name) + } + for (const [, names] of railsByNet) { + if (names.size > 1) { + const list = [...names].sort().join(' / ') + out.push({ + id: `short:${list}`, + severity: 'error', + message: `Rail short — ${list} are tied to the same net.` + }) + } + } + + // floating net label — placed but nothing is wired to it. + const wiredParts = new Set() + for (const w of doc.wires) { + for (const e of [w.from, w.to]) if (typeof e === 'string') wiredParts.add(splitPinRef(e).part) + } + for (const l of labels) { + if (!wiredParts.has(l.id)) { + out.push({ + id: `floatlabel:${l.id}`, + severity: 'info', + message: `Net label “${l.name}” isn't wired to anything.`, + ref: { label: l.id } + }) + } + } + + // missing ground — power rails present but no ground reference for node 0. + const kinds = new Set(labels.map((l) => l.kind)) + if (kinds.has('power') && !kinds.has('ground')) { + out.push({ + id: 'noground', + severity: 'info', + message: 'No ground (GND) reference — add a Ground label for a complete schematic.' + }) + } + + return out +} diff --git a/src/renderer/src/circuit/core/geometry.ts b/src/renderer/src/circuit/core/geometry.ts new file mode 100644 index 0000000..da4763e --- /dev/null +++ b/src/renderer/src/circuit/core/geometry.ts @@ -0,0 +1,121 @@ +/** + * circuit/core/geometry — snapping, rotation, and a spatial hash for pin + * hit-testing / drop-to-connect. Pure math; no document knowledge. + */ + +import { GRID_BB, GRID_SCH, type Pt, type ViewId } from './model' + +export function gridFor(view: ViewId): number { + return view === 'bb' ? GRID_BB : GRID_SCH +} + +export function snap(v: number, grid: number): number { + return Math.round(v / grid) * grid +} +export function snapPt(p: Pt, grid: number): Pt { + return { x: snap(p.x, grid), y: snap(p.y, grid) } +} + +/** Rotate/flip a local point inside a w×h part box (flip is horizontal, applied before rotation). */ +export function transformLocalPoint( + px: number, + py: number, + w: number, + h: number, + rotate = 0, + flip = false +): Pt { + let x = flip ? w - px : px + const y = py + const rad = (rotate * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = x - w / 2 + const dy = y - h / 2 + return { x: w / 2 + dx * cos - dy * sin, y: h / 2 + dx * sin + dy * cos } +} + +/** + * Pin position in world space for a part placed at (x,y) with rotation/flip. + * `local` is the pin's PartDef coordinate; `w`/`h` the view's part box. + */ +export function pinWorld( + local: [number, number], + placement: { x: number; y: number; rotate?: number; flip?: boolean }, + w: number, + h: number, + legOffset?: [number, number] +): Pt { + const p = transformLocalPoint( + local[0] + (legOffset?.[0] ?? 0), + local[1] + (legOffset?.[1] ?? 0), + w, + h, + placement.rotate ?? 0, + placement.flip ?? false + ) + return { x: placement.x + p.x, y: placement.y + p.y } +} + +/** + * Snap a part's placement so its PINS land on-grid (not its origin) — the + * Fritzing behavior that keeps everything lined up. Uses the first pin as the + * alignment reference. + */ +export function snapPlacementToPinGrid( + placement: { x: number; y: number; rotate?: number; flip?: boolean }, + firstPinLocal: [number, number] | undefined, + w: number, + h: number, + grid: number +): { x: number; y: number } { + if (!firstPinLocal) return { x: snap(placement.x, grid), y: snap(placement.y, grid) } + const pin = pinWorld(firstPinLocal, placement, w, h) + const snapped = snapPt(pin, grid) + return { + x: placement.x + (snapped.x - pin.x), + y: placement.y + (snapped.y - pin.y) + } +} + +// ── spatial hash ───────────────────────────────────────────────────────────── + +/** Uniform-grid spatial hash for point sets (pins, holes). Cell = 2 grid units. */ +export class SpatialHash { + private cells = new Map() + constructor(private cellSize = GRID_BB * 2) {} + + private key(x: number, y: number): string { + return `${Math.floor(x / this.cellSize)},${Math.floor(y / this.cellSize)}` + } + + insert(p: Pt, v: T): void { + const k = this.key(p.x, p.y) + let cell = this.cells.get(k) + if (!cell) this.cells.set(k, (cell = [])) + cell.push({ p, v }) + } + + /** Nearest entry within `radius` of `p`, or null. */ + nearest(p: Pt, radius: number): { p: Pt; v: T; dist: number } | null { + const r = Math.ceil(radius / this.cellSize) + const cx = Math.floor(p.x / this.cellSize) + const cy = Math.floor(p.y / this.cellSize) + let best: { p: Pt; v: T; dist: number } | null = null + for (let gx = cx - r; gx <= cx + r; gx++) { + for (let gy = cy - r; gy <= cy + r; gy++) { + const cell = this.cells.get(`${gx},${gy}`) + if (!cell) continue + for (const e of cell) { + const d = Math.hypot(e.p.x - p.x, e.p.y - p.y) + if (d <= radius && (!best || d < best.dist)) best = { ...e, dist: d } + } + } + } + return best + } + + clear(): void { + this.cells.clear() + } +} diff --git a/src/renderer/src/circuit/core/model.ts b/src/renderer/src/circuit/core/model.ts new file mode 100644 index 0000000..f8a0d8a --- /dev/null +++ b/src/renderer/src/circuit/core/model.ts @@ -0,0 +1,470 @@ +/** + * circuit/core/model — the Circuit View v2 document model. + * + * `circuit.json` v2 schema (docs/circuit-view-tech-spec.md §4), plus parsing, + * validation, serialization and migration from v1 `diagram.json` / Wokwi files. + * + * ZERO React, ZERO Node — pure TypeScript usable in renderer, web build, workers + * and unit tests alike. + */ + +// ── geometry ───────────────────────────────────────────────────────────────── + +export interface Pt { + x: number + y: number +} + +export type ViewId = 'bb' | 'sch' + +/** 0.1 in at 96 DPI — breadboard hole pitch and the base grid. */ +export const GRID_BB = 9.6 +/** Schematic fine grid (half pitch). */ +export const GRID_SCH = 4.8 + +// ── document types ─────────────────────────────────────────────────────────── + +export interface Placement { + x: number + y: number + rotate?: 0 | 90 | 180 | 270 + flip?: boolean + /** Per-pin bendable-leg tip offsets (bb only), relative to the pin's rest position. */ + legs?: Record + /** Label anchor offset override. */ + labelOffset?: [number, number] +} + +export interface CircuitPart { + /** Reference designator — unique, user-visible (R1, LED2, U1…). */ + id: string + /** PartDef type resolved through the parts registry. */ + type: string + /** Typed properties declared by the part schema (value, color…). */ + attrs?: Record + /** Breadboard placement; absent ⇒ unplaced in bb view (tray). */ + bb?: Placement + /** Schematic placement; absent ⇒ unplaced in sch view (tray). */ + sch?: Placement +} + +/** A wire endpoint: a part pin, or a junction on another wire's body. */ +export type WireEnd = string | JunctionEnd // "partId:pinName" +export interface JunctionEnd { + /** Host wire id. */ + wire: string + /** Parametric position along the host wire's rendered polyline, 0..1 by length. */ + t: number +} + +export interface CircuitWire { + /** Stable id (nanoid-8) — selection/route identity survives reorders (fixes B4). */ + id: string + from: WireEnd + to: WireEnd + /** The single view this wire lives in. Nets are shared; wires are per-view. */ + view: ViewId + /** Wokwi-style journey, source-anchored: h, v, d, (bb only). */ + route?: string[] + /** bb only — schematic wires are always ink. */ + color?: string + /** bb only — render as a curved jumper (route stays authoritative for geometry). */ + curve?: boolean +} + +export type NetLabelKind = 'ground' | 'power' | 'net' +export interface NetLabel { + id: string + name: string + kind: NetLabelKind + sch: Placement +} + +export type AnalysisKind = 'op' | 'dc' | 'tran' | 'ac' +export interface Analysis { + id: string + kind: AnalysisKind + enabled?: boolean + [k: string]: unknown +} +export interface Probe { + id: string + kind: 'voltage' | 'current' | 'diff' + at: string + label?: string + color?: string +} + +export interface PackRef { + id: string + version?: string + url?: string +} + +export interface CircuitDoc { + format: 'tinystudio-circuit' + version: 2 + meta?: { author?: string; created?: string; modified?: string } + packs?: PackRef[] + parts: CircuitPart[] + wires: CircuitWire[] + netLabels?: NetLabel[] + sim?: { analyses?: Analysis[]; probes?: Probe[] } + camera?: Partial> + /** + * Round-trip bag: every top-level key we don't model is preserved verbatim + * (fixes B3 — never destroy foreign data like Wokwi's serialMonitor). + */ + extra?: Record +} + +// ── ids ────────────────────────────────────────────────────────────────────── + +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +/** 8-char random id (no crypto dependency; collision space 36^8 ≈ 2.8e12). */ +export function newId(prefix = ''): string { + let s = '' + for (let i = 0; i < 8; i++) s += ID_ALPHABET[(Math.random() * ID_ALPHABET.length) | 0] + return prefix ? `${prefix}_${s}` : s +} + +export function isJunction(end: WireEnd): end is JunctionEnd { + return typeof end === 'object' && end !== null && 'wire' in end +} + +/** + * A v1-migrated junction endpoint the editor hasn't geometrically resolved + * yet: carries the original raw coordinate; `wire` is '' and `t` is -1 until + * the first render (where pin geometry exists) rewrites it to a real + * `{wire, t}` — see migrateV1 pass 2. + */ +export interface PendingJunctionEnd extends JunctionEnd { + x: number + y: number +} +export function isPendingJunction(end: WireEnd): end is PendingJunctionEnd { + return isJunction(end) && end.wire === '' && (end as PendingJunctionEnd).x !== undefined +} + +/** Split a "partId:pinName" pin ref. Pin names may contain ':'? No — first colon splits. */ +export function splitPinRef(ref: string): { part: string; pin: string } { + const i = ref.indexOf(':') + return { part: ref.slice(0, i), pin: ref.slice(i + 1) } +} + +// ── construction / validation ──────────────────────────────────────────────── + +export function emptyDoc(author?: string): CircuitDoc { + return { + format: 'tinystudio-circuit', + version: 2, + meta: { author, created: new Date().toISOString() }, + parts: [], + wires: [] + } +} + +const KNOWN_KEYS = new Set([ + 'format', + 'version', + 'meta', + 'packs', + 'parts', + 'wires', + 'netLabels', + 'sim', + 'camera' +]) + +export interface ParseResult { + doc: CircuitDoc + /** True when the input was a v1/Wokwi file and got migrated. */ + migrated: boolean + warnings: string[] +} + +/** + * Parse any supported circuit file text: v2 native, tinyStudio v1 diagram.json, + * or a Wokwi diagram.json. Never throws on bad input — returns an empty doc + * with warnings instead (the editor must always mount). + */ +export function parseCircuitFile(text: string): ParseResult { + const warnings: string[] = [] + let raw: Record + try { + raw = JSON.parse(text || '{}') + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('not an object') + } catch (e) { + return { + doc: emptyDoc(), + migrated: false, + warnings: [`Unparseable circuit file (${(e as Error).message}); starting empty.`] + } + } + + if (raw.format === 'tinystudio-circuit' && raw.version === 2) { + return { doc: normalizeV2(raw, warnings), migrated: false, warnings } + } + // v1 tinyStudio & plain Wokwi both look like { version: 1, parts, connections } + if (Array.isArray(raw.parts) && Array.isArray(raw.connections)) { + return { doc: migrateV1(raw, warnings), migrated: true, warnings } + } + if (Array.isArray(raw.parts) || Array.isArray(raw.wires)) { + // half-formed v2-ish content — salvage what we can + return { doc: normalizeV2({ format: 'tinystudio-circuit', version: 2, ...raw }, warnings), migrated: false, warnings } + } + return { doc: emptyDoc(), migrated: false, warnings: ['Unknown circuit file shape; starting empty.'] } +} + +function normalizeV2(raw: Record, warnings: string[]): CircuitDoc { + const doc = emptyDoc() + doc.meta = (raw.meta as CircuitDoc['meta']) ?? doc.meta + doc.packs = raw.packs as PackRef[] | undefined + doc.netLabels = raw.netLabels as NetLabel[] | undefined + doc.sim = raw.sim as CircuitDoc['sim'] + doc.camera = raw.camera as CircuitDoc['camera'] + + const seenPart = new Set() + for (const p of (raw.parts as unknown[]) ?? []) { + const part = p as CircuitPart + if (!part || typeof part.id !== 'string' || typeof part.type !== 'string') { + warnings.push('Dropped malformed part entry.') + continue + } + if (seenPart.has(part.id)) { + warnings.push(`Duplicate part id "${part.id}" — dropped duplicate.`) + continue + } + seenPart.add(part.id) + doc.parts.push(part) + } + + const seenWire = new Set() + for (const w of (raw.wires as unknown[]) ?? []) { + const wire = w as CircuitWire + if (!wire || !wire.from || !wire.to || (wire.view !== 'bb' && wire.view !== 'sch')) { + warnings.push('Dropped malformed wire entry.') + continue + } + if (!wire.id || seenWire.has(wire.id)) wire.id = newId('w') + seenWire.add(wire.id) + doc.wires.push(wire) + } + + // Preserve unknown top-level keys verbatim (B3). + for (const k of Object.keys(raw)) { + if (!KNOWN_KEYS.has(k)) { + doc.extra = doc.extra || {} + doc.extra[k] = raw[k] + } + } + return doc +} + +// ── v1 / Wokwi migration ───────────────────────────────────────────────────── + +type V1Conn = [unknown, unknown, string?, string[]?] + +/** + * Migrate a tinyStudio v1 `diagram.json` or a plain Wokwi diagram into v2. + * - parts left/top → bb placement; v1 `schematic.pos` → sch placement + * - connections → bb wires; v1 `schematic.routes` → sch wires (same endpoints) + * - free {x,y} junction endpoints → resolved to {wire,t} against the *already + * migrated* wires where possible (B9); else dropped with a warning + * - part ids kept (they become refdes-ish; a later renumber can clean up) + * - Wokwi extras (serialMonitor, dependencies…) preserved in `extra` + */ +function migrateV1(raw: Record, warnings: string[]): CircuitDoc { + const doc = emptyDoc(typeof raw.author === 'string' ? raw.author : undefined) + const sch = (raw.schematic as { pos?: Record; routes?: Record }) || {} + + for (const p of (raw.parts as Record[]) ?? []) { + if (!p || p.id == null || p.type == null) continue + const part: CircuitPart = { + id: String(p.id), + type: String(p.type), + bb: { + x: Number(p.left ?? p.x ?? 0), + y: Number(p.top ?? p.y ?? 0), + ...(p.rotate ? { rotate: (((Number(p.rotate) % 360) + 360) % 360) as 0 | 90 | 180 | 270 } : {}) + } + } + const attrs = p.attrs as Record | undefined + if (attrs && Object.keys(attrs).length) { + const { labelOffset, ...rest } = attrs as Record + if (Array.isArray(labelOffset) && part.bb) part.bb.labelOffset = labelOffset as [number, number] + if (Object.keys(rest).length) part.attrs = rest as Record + } + const sp = sch.pos?.[part.id] + if (sp) part.sch = { x: sp[0], y: sp[1] } + doc.parts.push(part) + } + + // Pass 1: pin-to-pin wires (junction resolution needs these to exist first). + interface Pending { + conn: V1Conn + key: string + } + const pending: Pending[] = [] + const refStr = (r: unknown): string => + typeof r === 'string' ? r : `${(r as Pt).x},${(r as Pt).y}` + + for (const c of (raw.connections as V1Conn[]) ?? []) { + if (!Array.isArray(c) || c.length < 2) continue + const key = `${refStr(c[0])}>${refStr(c[1])}` + if (typeof c[0] === 'string' && typeof c[1] === 'string') { + doc.wires.push({ + id: newId('w'), + from: c[0], + to: c[1], + view: 'bb', + color: typeof c[2] === 'string' ? c[2] : undefined, + route: normalizeJourney(c[3]) + }) + const schRoute = sch.routes?.[key] + if (schRoute) { + doc.wires.push({ id: newId('w'), from: c[0], to: c[1], view: 'sch', route: schRoute.slice() }) + } + } else { + pending.push({ conn: c, key }) + } + } + + // Pass 2: junction endpoints. v1 stored a bare {x,y}; we cannot geometrically + // resolve it here without part pin positions (registry lives above core), so + // we keep the coordinate as a *pending junction* the editor resolves on first + // render (it has geometry there). Encoded as { wire: '', t: -1, x, y } via extra. + for (const { conn } of pending) { + const [a, b, color, route] = conn + const fix = (r: unknown): WireEnd | null => { + if (typeof r === 'string') return r + if (r && typeof r === 'object' && 'x' in (r as Pt)) + return { wire: '', t: -1, ...(r as Pt) } as unknown as JunctionEnd + return null + } + const from = fix(a) + const to = fix(b) + if (!from || !to) { + warnings.push('Dropped a v1 connection with unusable endpoints.') + continue + } + doc.wires.push({ + id: newId('w'), + from, + to, + view: 'bb', + color: typeof color === 'string' ? color : undefined, + route: normalizeJourney(route) + }) + warnings.push('A junction endpoint was migrated as a pending junction (resolved on first render).') + } + + // Preserve Wokwi/foreign keys. + for (const k of Object.keys(raw)) { + if (!['version', 'editor', 'author', 'parts', 'connections', 'schematic'].includes(k)) { + doc.extra = doc.extra || {} + doc.extra[k] = raw[k] + } + } + return doc +} + +/** + * Normalize a Wokwi journey to source-anchored form (fixes B2). + * Wokwi: instructions before "*" run from the source; instructions after "*" + * run from the TARGET in reverse order. We fold both halves into a single + * source-anchored list by keeping the source half; the target half becomes a + * `@` marker consumed by routing.decodeJourney (which needs live endpoints to + * finish the fold). To stay dependency-free here we keep the raw list and let + * routing handle "*" — this function only trims garbage. + */ +export function normalizeJourney(route: unknown): string[] | undefined { + if (!Array.isArray(route)) return undefined + const out = route.filter( + (s) => typeof s === 'string' && (/^[hv]-?[\d.]+$/.test(s) || /^d-?[\d.]+,-?[\d.]+$/.test(s) || s === '*') + ) as string[] + return out.length ? out : undefined +} + +// ── serialization ──────────────────────────────────────────────────────────── + +/** Stable-ordered serializer: same input ⇒ same text ⇒ clean git diffs. */ +export function serializeDoc(doc: CircuitDoc): string { + const out: Record = { + format: doc.format, + version: doc.version, + ...(doc.meta ? { meta: doc.meta } : {}), + ...(doc.packs?.length ? { packs: doc.packs } : {}), + parts: doc.parts.map(serializePart), + wires: doc.wires.map(serializeWire), + ...(doc.netLabels?.length ? { netLabels: doc.netLabels } : {}), + ...(doc.sim && (doc.sim.analyses?.length || doc.sim.probes?.length) ? { sim: doc.sim } : {}), + ...(doc.camera ? { camera: doc.camera } : {}), + ...(doc.extra || {}) + } + return JSON.stringify(out, null, 2) + '\n' +} + +function serializePart(p: CircuitPart): Record { + return { + id: p.id, + type: p.type, + ...(p.attrs && Object.keys(p.attrs).length ? { attrs: p.attrs } : {}), + ...(p.bb ? { bb: serializePlacement(p.bb) } : {}), + ...(p.sch ? { sch: serializePlacement(p.sch) } : {}) + } +} + +function serializePlacement(pl: Placement): Record { + return { + x: round2(pl.x), + y: round2(pl.y), + ...(pl.rotate ? { rotate: pl.rotate } : {}), + ...(pl.flip ? { flip: true } : {}), + ...(pl.legs && Object.keys(pl.legs).length ? { legs: pl.legs } : {}), + ...(pl.labelOffset ? { labelOffset: pl.labelOffset } : {}) + } +} + +function serializeWire(w: CircuitWire): Record { + return { + id: w.id, + from: w.from, + to: w.to, + view: w.view, + ...(w.color ? { color: w.color } : {}), + ...(w.route?.length ? { route: w.route } : {}), + ...(w.curve ? { curve: true } : {}) + } +} + +export const round2 = (n: number): number => Math.round(n * 100) / 100 + +// ── queries (pure helpers used by store/commands/views) ────────────────────── + +export function partById(doc: CircuitDoc, id: string): CircuitPart | undefined { + return doc.parts.find((p) => p.id === id) +} +export function wireById(doc: CircuitDoc, id: string): CircuitWire | undefined { + return doc.wires.find((w) => w.id === id) +} +export function wiresInView(doc: CircuitDoc, view: ViewId): CircuitWire[] { + return doc.wires.filter((w) => w.view === view) +} +/** All wires touching a part (either endpoint is one of its pins), per view. */ +export function wiresTouchingPart(doc: CircuitDoc, partId: string, view?: ViewId): CircuitWire[] { + return doc.wires.filter((w) => { + if (view && w.view !== view) return false + const hit = (e: WireEnd): boolean => typeof e === 'string' && splitPinRef(e).part === partId + return hit(w.from) || hit(w.to) + }) +} +/** Wires whose junction endpoint rides on the given wire. */ +export function junctionRiders(doc: CircuitDoc, hostWireId: string): CircuitWire[] { + return doc.wires.filter( + (w) => + (isJunction(w.from) && w.from.wire === hostWireId) || + (isJunction(w.to) && w.to.wire === hostWireId) + ) +} diff --git a/src/renderer/src/circuit/core/netlist.ts b/src/renderer/src/circuit/core/netlist.ts new file mode 100644 index 0000000..1fa5ca9 --- /dev/null +++ b/src/renderer/src/circuit/core/netlist.ts @@ -0,0 +1,484 @@ +/** + * circuit/core/netlist — SPICE netlist generation (M4, spec §10.2). + * + * Pure: takes the document + a prebuilt NetModel (so breadboard seating and + * registry buses are included exactly as the editor sees them) and returns + * netlist text + node naming + warnings. No React, no DOM, no engine — the + * SimBackend consumes the text; tests golden-file it. + * + * Node naming: a net named GND (ground labels) is node `0`; other named nets + * keep their (sanitized) label name; everything else is `n` in stable net + * order — diffable golden files. + * + * Part mapping: built-in table keyed on family/type keywords (resistor, led, + * battery, the sim-* sources…). Pins are matched by name (anode/cathode/+/-) + * with positional fallback, since the Fritzing catalogue is inconsistent + * ("Pin 0" vs "leg1" vs "0"). Parts without a mapping: breadboards are + * transparent (their buses already merged nets), boards/MCUs are excluded + * with an info (model their pins with sources — §10.2.5), other parts are + * excluded with a warning. The curated tinyparts overlay replaces this table + * eventually; keep the shape compatible. + */ + +import type { Analysis, CircuitDoc, CircuitPart } from './model' +import type { NetModel } from './nets' + +// ── SPICE value normalization ──────────────────────────────────────────────── + +/** Normalize a human attr ("4.7kΩ", "10 µF", "1M") into a SPICE number. + * Note SPICE reads `m` as milli — a trailing capital M (common for megohm) + * becomes `Meg`; lowercase m stays milli. */ +export function spiceNum(v: string | number | boolean | undefined, fallback: string): string { + if (v === undefined || v === true || v === false) return fallback + let s = String(v).trim() + if (!s) return fallback + s = s + .replace(/[ΩΩ]|ohms?/gi, '') + .replace(/([0-9.]\s*[a-zµμ]*)[FH]$/i, '$1') // unit letters after value/prefix + .replace(/[µμ]/g, 'u') + .replace(/\s+/g, '') + s = s.replace(/^([0-9.eE+-]+)M$/, '$1Meg') // capital M = mega by human convention + return s || fallback +} + +/** SPICE-safe node token from a net-label name. */ +function nodeToken(name: string): string { + return name.replace(/[^A-Za-z0-9_.]+/g, '_') +} + +// ── part mapping ───────────────────────────────────────────────────────────── + +export interface SpiceCard { + /** element line(s), fully substituted */ + lines: string[] + /** .model card emitted once per model name */ + model?: { name: string; card: string } +} + +export type MappingKind = 'element' | 'transparent' | 'board' | 'unknown' + +export interface NetlistOptions { + /** family for a part type (from the parts registry); improves matching */ + familyOf?: (type: string) => string | undefined + title?: string +} + +export interface NetlistResult { + netlist: string + /** node name per net index (annotation UI: net → voltage vector name) */ + nodeOfNet: string[] + warnings: string[] + /** part ids not simulated (unknown/board) */ + excluded: string[] + /** SPICE element/device names emitted per part id (e.g. R1 → ['rr1']), + * lowercased to match ngspice's own casing — feeds mapSimIssue. */ + elementOfPart: Record +} + +interface Ctx { + part: CircuitPart + /** ordered pin names actually referenced by nets or the registry — here we + * only know what the doc wired; order = wire discovery order. */ + nodeOf: (pin: string) => string + pins: string[] + attr: (names: string[], fallback: string) => string + warn: (msg: string) => void +} + +/** Find a pin matching any regex, else positional fallback (and warn). */ +function pinLike(ctx: Ctx, res: RegExp[], positional: number, what: string): string { + for (const re of res) { + const hit = ctx.pins.find((p) => re.test(p)) + if (hit) return ctx.nodeOf(hit) + } + const p = ctx.pins[positional] + if (p === undefined) { + ctx.warn(`${ctx.part.id}: no pin for ${what} — grounded`) + return '0' + } + return ctx.nodeOf(p) +} + +const two = (ctx: Ctx): [string, string] => { + const a = ctx.pins[0] + const b = ctx.pins[1] + return [a !== undefined ? ctx.nodeOf(a) : '0', b !== undefined ? ctx.nodeOf(b) : '0'] +} + +const PLUS = [/^\+$/, /anode/i, /^vcc$/i, /pos/i] +const MINUS = [/^-$/, /cathode|kath/i, /^gnd$/i, /neg/i] + +type Emitter = (ctx: Ctx) => SpiceCard | null + +/** An editable sim attr a part type understands (Inspector surfaces these). */ +export interface SimAttrSpec { + key: string + label: string + /** value used when the attr is unset (mirrors the emitter fallbacks) */ + default: string + hint?: string +} + +const LED_MODEL = { name: 'DLED', card: '.model DLED D(IS=1e-22 N=2.2 RS=2)' } +const D_MODEL = { name: 'DGEN', card: '.model DGEN D(IS=1e-14 N=1.9 RS=0.1)' } +const NPN_MODEL = { name: 'QNPN', card: '.model QNPN NPN(BF=150 VAF=100)' } +const PNP_MODEL = { name: 'QPNP', card: '.model QPNP PNP(BF=150 VAF=100)' } + +const EMITTERS: { + match: RegExp + kind?: MappingKind + emit?: Emitter + attrs?: SimAttrSpec[] +}[] = [ + { match: /breadboard/i, kind: 'transparent' }, + { match: /tinystudio|microcontroller|\bboard\b|arduino|esp32/i, kind: 'board' }, + { + match: /sim-vdc|voltage source|battery/i, + attrs: [{ key: 'voltage', label: 'Voltage', default: '5', hint: 'V' }], + emit: (c) => ({ + lines: [ + `V${c.part.id} ${pinLike(c, PLUS, 0, '+')} ${pinLike(c, MINUS, 1, '-')} DC ${c.attr( + ['voltage', 'value'], + '5' + )}` + ] + }) + }, + { + match: /sim-vsin|sine|waveform/i, + attrs: [ + { key: 'amplitude', label: 'Amplitude', default: '1', hint: 'V' }, + { key: 'frequency', label: 'Frequency', default: '1k', hint: 'Hz' }, + { key: 'offset', label: 'DC offset', default: '0', hint: 'V' } + ], + emit: (c) => ({ + lines: [ + `V${c.part.id} ${pinLike(c, PLUS, 0, '+')} ${pinLike(c, MINUS, 1, '-')} SIN(${c.attr( + ['offset'], + '0' + )} ${c.attr(['amplitude', 'value'], '1')} ${c.attr(['frequency', 'freq'], '1k')}) AC ${c.attr( + ['amplitude', 'value'], + '1' + )}` + ] + }) + }, + { + match: /sim-idc|current source/i, + attrs: [{ key: 'current', label: 'Current', default: '1m', hint: 'A' }], + emit: (c) => ({ + lines: [ + `I${c.part.id} ${pinLike(c, PLUS, 0, '+')} ${pinLike(c, MINUS, 1, '-')} DC ${c.attr( + ['current', 'value'], + '1m' + )}` + ] + }) + }, + { + match: /resistor|photocell|ldr|thermistor/i, + attrs: [{ key: 'resistance', label: 'Resistance', default: '220', hint: 'Ω' }], + emit: (c) => { + const [a, b] = two(c) + return { lines: [`R${c.part.id} ${a} ${b} ${c.attr(['resistance', 'value'], '220')}`] } + } + }, + { + match: /potentiometer|trimmer/i, + attrs: [ + { key: 'resistance', label: 'Resistance', default: '10k', hint: 'Ω end-to-end' }, + { key: 'position', label: 'Wiper position', default: '0.5', hint: '0–1' } + ], + emit: (c) => { + const total = c.attr(['resistance', 'value'], '10k') + const posRaw = c.attr(['position'], '0.5') + const pos = Math.min(Math.max(parseFloat(posRaw) || 0.5, 0.001), 0.999) + const l1 = pinLike(c, [/leg1|^1$/i], 0, 'leg1') + const w = pinLike(c, [/wiper|^2$/i], 1, 'wiper') + const l2 = pinLike(c, [/leg2|^3$/i], 2, 'leg2') + return { + lines: [ + `R${c.part.id}a ${l1} ${w} ${spiceScale(total, pos)}`, + `R${c.part.id}b ${w} ${l2} ${spiceScale(total, 1 - pos)}` + ] + } + } + }, + { + match: /capacitor/i, + attrs: [{ key: 'capacitance', label: 'Capacitance', default: '100n', hint: 'F' }], + emit: (c) => { + const plus = c.pins.find((p) => PLUS.some((re) => re.test(p))) + const minus = c.pins.find((p) => MINUS.some((re) => re.test(p))) + const [a, b] = + plus && minus ? [c.nodeOf(plus), c.nodeOf(minus)] : two(c) + return { lines: [`C${c.part.id} ${a} ${b} ${c.attr(['capacitance', 'value'], '100n')}`] } + } + }, + { + match: /inductor/i, + attrs: [{ key: 'inductance', label: 'Inductance', default: '10u', hint: 'H' }], + emit: (c) => { + const [a, b] = two(c) + return { lines: [`L${c.part.id} ${a} ${b} ${c.attr(['inductance', 'value'], '10u')}`] } + } + }, + { + match: /led/i, + emit: (c) => ({ + lines: [ + `D${c.part.id} ${pinLike(c, [/anode/i, /^\+$/], 1, 'anode')} ${pinLike( + c, + [/cathode|kath/i, /^-$/], + 0, + 'cathode' + )} ${LED_MODEL.name}` + ], + model: LED_MODEL + }) + }, + { + match: /diode|zener/i, + emit: (c) => ({ + lines: [ + `D${c.part.id} ${pinLike(c, [/anode/i], 1, 'anode')} ${pinLike( + c, + [/cathode|kath/i], + 0, + 'cathode' + )} ${D_MODEL.name}` + ], + model: D_MODEL + }) + }, + { + match: /npn/i, + emit: (c) => ({ + lines: [ + `Q${c.part.id} ${pinLike(c, [/^c/i], 2, 'C')} ${pinLike(c, [/^b/i], 1, 'B')} ${pinLike( + c, + [/^e/i], + 0, + 'E' + )} ${NPN_MODEL.name}` + ], + model: NPN_MODEL + }) + }, + { + match: /pnp/i, + emit: (c) => ({ + lines: [ + `Q${c.part.id} ${pinLike(c, [/^c/i], 2, 'C')} ${pinLike(c, [/^b/i], 1, 'B')} ${pinLike( + c, + [/^e/i], + 0, + 'E' + )} ${PNP_MODEL.name}` + ], + model: PNP_MODEL + }) + }, + { + // current probe (spec §10.3): a 0V series source, so ngspice reports + // i(v) through it without disturbing the circuit — an ideal ammeter. + match: /^sim-probe-i\b/i, + emit: (c) => { + const [a, b] = two(c) + return { lines: [`V${c.part.id} ${a} ${b} DC 0`] } + } + }, + // voltage / diff. voltage probes need no element — every node's voltage is + // already reported by ngspice; core/probes.ts reads it back by node name + // (and computes the diff-probe subtraction). Just don't warn about them. + { match: /^sim-probe-vdiff\b/i, kind: 'transparent' }, + { match: /^sim-probe-v\b/i, kind: 'transparent' }, + { + match: /switch|button/i, + attrs: [{ key: 'closed', label: 'Closed', default: 'false', hint: 'true/false' }], + emit: (c) => { + const closed = c.part.attrs?.closed === true || c.part.attrs?.closed === 'true' + if (!closed) return null // open switch = not in the circuit + const [a, b] = two(c) + return { lines: [`R${c.part.id} ${a} ${b} 1m`] } + } + } +] + +/** total like "10k" scaled by a fraction → SPICE number (e.g. 10k*0.5 → 5000). */ +function spiceScale(total: string, frac: number): string { + const m = total.match(/^([0-9.eE+-]+)\s*(Meg|[kKmunpfgt])?$/) + if (!m) return total + const mult: Record = { + Meg: 1e6, k: 1e3, K: 1e3, m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12, f: 1e-15, g: 1e9, t: 1e12 + } + const base = parseFloat(m[1]) * (m[2] ? mult[m[2]] : 1) + const v = base * frac + return v >= 1 ? String(Math.round(v * 100) / 100) : v.toExponential(3) +} + +/** Sim attrs a part type supports (same matching rule as generation). */ +export function simAttrsFor(type: string, family = ''): SimAttrSpec[] { + const entry = EMITTERS.find((e) => e.match.test(`${type} ${family}`)) + return entry?.attrs ?? [] +} + +// ── generator ──────────────────────────────────────────────────────────────── + +export function generateNetlist( + doc: CircuitDoc, + net: NetModel, + opts: NetlistOptions = {} +): NetlistResult { + const warnings: string[] = [] + const excluded: string[] = [] + + // node names per net (spec §10.2.2) + let seq = 1 + const nodeOfNet = net.nets.map((_members, i) => { + const name = net.netNames[i] + if (name && name.toUpperCase() === 'GND') return '0' + if (name) return nodeToken(name) + return `n${seq++}` + }) + let ncSeq = 1 + + const lines: string[] = [] + const models = new Map() + const elementOfPart: Record = {} + + for (const part of doc.parts) { + const family = opts.familyOf?.(part.type) ?? '' + const key = `${part.type} ${family}` + const entry = EMITTERS.find((e) => e.match.test(key)) + if (!entry) { + excluded.push(part.id) + warnings.push(`${part.id} (${part.type}) has no simulation model — excluded`) + continue + } + if (entry.kind === 'transparent') continue + if (entry.kind === 'board') { + excluded.push(part.id) + warnings.push(`${part.id} (${part.type}) is a board — not simulated; drive its pins with sources`) + continue + } + + // pins this part exposes to the net model (wired or seated) + const pins: string[] = [] + for (const [ref] of net.pinToNet) { + const idx = ref.lastIndexOf(':') + if (ref.slice(0, idx) === part.id) pins.push(ref.slice(idx + 1)) + } + const ctx: Ctx = { + part, + pins, + nodeOf: (pin) => { + const idx = net.pinToNet.get(`${part.id}:${pin}`) + if (idx === undefined) { + warnings.push(`${part.id}:${pin} is unconnected`) + return `nc${ncSeq++}` + } + return nodeOfNet[idx] + }, + attr: (names, fallback) => { + for (const n of names) { + const v = part.attrs?.[n] + if (v !== undefined) return spiceNum(v, fallback) + } + return fallback + }, + warn: (m) => warnings.push(m) + } + const card = entry.emit!(ctx) + if (!card) continue + lines.push(...card.lines) + if (card.model) models.set(card.model.name, card.model.card) + for (const line of card.lines) { + const dev = line.split(/\s+/, 1)[0]?.toLowerCase() + if (dev) (elementOfPart[part.id] ??= []).push(dev) + } + } + + // analyses (spec §10.2.4) — default to .op + const analyses = (doc.sim?.analyses ?? []).filter((a) => a.enabled !== false) + const resolved = analyses.map(analysisCard).filter(Boolean) + const cards = resolved.length ? resolved : ['.op'] // never emit a card-less netlist + + const text = [ + `* ${opts.title ?? 'tinyStudio circuit'}`, + ...lines, + ...[...models.values()].sort(), + ...(cards as string[]), + '.end', + '' + ].join('\n') + + return { netlist: text, nodeOfNet, warnings, excluded, elementOfPart } +} + +export function analysisCard(a: Analysis): string | null { + const f = (k: string, d = ''): string => spiceNum(a[k] as string | number | undefined, d) + switch (a.kind) { + case 'op': + return '.op' + case 'tran': + // uic: skip the DC bias solve and start from zero initial conditions + return `.tran ${f('step', '10u')} ${f('stop', '10m')}${a.uic ? ' uic' : ''}` + case 'dc': + return a.src ? `.dc ${a.src} ${f('from', '0')} ${f('to', '5')} ${f('step', '0.1')}` : null + case 'ac': + return `.ac ${(a.variation as string) || 'dec'} ${f('points', '20')} ${f( + 'fstart', + '1' + )} ${f('fstop', '1Meg')}` + default: + return null + } +} + +// ── error → part/net mapping (M4 leftover) ────────────────────────────────── + +export interface SimIssueRef { + /** part ids whose emitted device name(s) appear in the message */ + parts: string[] + /** net indices whose node name appears in the message */ + nets: number[] +} + +function escapeReg(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Map one raw ngspice error/warning line back to the parts and nets it names, + * using the element/node names `generateNetlist` recorded for this run. + * Case-insensitive, word-boundary matching (ngspice lowercases everything it + * prints). Ground (`0`) is skipped — it appears in far too much numeric text + * to be a useful match. + */ +export function mapSimIssue(text: string, gen: NetlistResult): SimIssueRef { + const low = text.toLowerCase() + const parts: string[] = [] + for (const [id, devices] of Object.entries(gen.elementOfPart)) { + if (devices.some((d) => new RegExp(`\\b${escapeReg(d)}\\b`).test(low))) parts.push(id) + } + const nets: number[] = [] + gen.nodeOfNet.forEach((node, i) => { + if (!node || node === '0') return + if (new RegExp(`\\b${escapeReg(node.toLowerCase())}\\b`).test(low)) nets.push(i) + }) + return { parts, nets } +} + +/** Same as `mapSimIssue`, folded over every line of a failure's details + * (or just the top-level message), de-duplicated. */ +export function mapSimIssues(lines: string[], gen: NetlistResult): SimIssueRef { + const parts = new Set() + const nets = new Set() + for (const line of lines) { + const r = mapSimIssue(line, gen) + r.parts.forEach((p) => parts.add(p)) + r.nets.forEach((n) => nets.add(n)) + } + return { parts: [...parts], nets: [...nets] } +} diff --git a/src/renderer/src/circuit/core/nets.ts b/src/renderer/src/circuit/core/nets.ts new file mode 100644 index 0000000..0baa5e5 --- /dev/null +++ b/src/renderer/src/circuit/core/nets.ts @@ -0,0 +1,197 @@ +/** + * circuit/core/nets — the equipotential net model (the spine of Circuit v2). + * + * A net is a connected component of the electrical graph over five edge types: + * 1. wires — every wire ties its two endpoints together (BOTH views: + * the net model is shared; wires are per-view) + * 2. junctions — a {wire,t} endpoint joins the HOST WIRE's net directly + * (fixes B9: identity-based, no coordinate proximity) + * 3. buses — pins internally common inside a part (breadboard rows, + * multi-GND boards), supplied by the parts registry + * 4. net labels — schematic labels with the same name share a net (GND…) + * 5. implicit — derived connections supplied by callers (breadboard + * pin-in-hole seating, M2), as pin-ref pairs + * + * Successor to lib/circuitNets.ts. Keys are canonical strings (B10 fix: one + * canonicalization for everyone — no more refKey/refStr divergence). + */ + +import { + isJunction, + splitPinRef, + type CircuitDoc, + type WireEnd +} from './model' + +/** Canonical endpoint key: pins are "part:pin", junctions collapse to the host wire. */ +export function endKey(end: WireEnd): string { + return isJunction(end) ? `~w:${end.wire}` : end +} +/** Key for a wire's own body (junction riders attach to this). */ +export function wireKey(wireId: string): string { + return `~w:${wireId}` +} +export function labelKey(labelId: string): string { + return `~l:${labelId}` +} + +class DSU { + private parent = new Map() + find(k: string): string { + if (!this.parent.has(k)) this.parent.set(k, k) + let root = k + while (this.parent.get(root) !== root) root = this.parent.get(root)! + let cur = k + while (this.parent.get(cur) !== root) { + const next = this.parent.get(cur)! + this.parent.set(cur, root) + cur = next + } + return root + } + union(a: string, b: string): void { + this.parent.set(this.find(a), this.find(b)) + } + keys(): IterableIterator { + return this.parent.keys() + } +} + +export interface NetModel { + /** Pins per net (only "part:pin" members — the electrically meaningful view). */ + nets: string[][] + /** Net name (from labels) per net index, if any. */ + netNames: (string | undefined)[] + pinToNet: Map + wireToNet: Map + labelToNet: Map + /** Count of nets containing 2+ pins. */ + meaningful: number +} + +export interface BuildNetsOptions { + /** Internally-common pin-name groups for a part type (from the registry). */ + busesFor?: (type: string) => string[][] | undefined + /** Derived pin-ref pairs (e.g. breadboard seating): [["R1:1","bb1:e12"], …] */ + implicit?: [string, string][] +} + +export function buildNets(doc: CircuitDoc, opts: BuildNetsOptions = {}): NetModel { + const dsu = new DSU() + const allPins = new Set() + + // 1+2. wires and junction endpoints — a wire's body key unions with both of + // its endpoints, so a rider on the wire is in the same component. + for (const w of doc.wires) { + const wk = wireKey(w.id) + for (const end of [w.from, w.to]) { + const k = endKey(end) + dsu.union(wk, k) + if (!isJunction(end)) allPins.add(end) + } + } + + // 3. part-internal buses + for (const part of doc.parts) { + const buses = opts.busesFor?.(part.type) + if (!Array.isArray(buses)) continue + for (const group of buses) { + for (let i = 1; i < group.length; i++) { + const a = `${part.id}:${group[0]}` + const b = `${part.id}:${group[i]}` + dsu.union(a, b) + // NOTE: bus pins are only *listed* in nets when something connects to + // them (allPins gate) — a bare breadboard is 0 meaningful nets. + } + } + } + + // 4. net labels: wires attach to a label's virtual pin ":1"; that + // pin unions with the label key, and same-named labels union together. + const byName = new Map() + for (const l of doc.netLabels ?? []) { + const lk = labelKey(l.id) + dsu.union(lk, `${l.id}:1`) + const prev = byName.get(l.name) + if (prev) dsu.union(lk, prev) + else byName.set(l.name, lk) + } + + // 5. implicit connections (breadboard seating etc.) + for (const [a, b] of opts.implicit ?? []) { + dsu.union(a, b) + allPins.add(a) + allPins.add(b) + } + + // Assemble components. + const rootToNet = new Map() + const nets: string[][] = [] + const netNames: (string | undefined)[] = [] + const pinToNet = new Map() + const wireToNet = new Map() + const labelToNet = new Map() + + const netFor = (root: string): number => { + let idx = rootToNet.get(root) + if (idx === undefined) { + idx = nets.length + rootToNet.set(root, idx) + nets.push([]) + netNames.push(undefined) + } + return idx + } + + for (const pin of allPins) { + const idx = netFor(dsu.find(pin)) + pinToNet.set(pin, idx) + nets[idx].push(pin) + } + for (const w of doc.wires) { + wireToNet.set(w.id, netFor(dsu.find(wireKey(w.id)))) + } + for (const l of doc.netLabels ?? []) { + const idx = netFor(dsu.find(labelKey(l.id))) + labelToNet.set(l.id, idx) + if (netNames[idx] === undefined) netNames[idx] = l.name + } + + for (const net of nets) net.sort() + const meaningful = nets.filter((n) => n.length >= 2).length + return { nets, netNames, pinToNet, wireToNet, labelToNet, meaningful } +} + +/** Human description of a net (for tooltips / status): named or by members. */ +export function describeNet(model: NetModel, index: number): string { + const name = model.netNames[index] + const members = model.nets[index] ?? [] + if (name) return `${name} (${members.length} pins)` + return members.slice(0, 4).join(', ') + (members.length > 4 ? ` +${members.length - 4}` : '') +} + +/** Validate junction endpoints: every rider must reference an existing wire in + * the same view, and never (transitively) itself. Returns wire ids to repair. */ +export function danglingJunctions(doc: CircuitDoc): string[] { + const byId = new Map(doc.wires.map((w) => [w.id, w])) + const bad: string[] = [] + for (const w of doc.wires) { + for (const end of [w.from, w.to]) { + if (!isJunction(end)) continue + const host = byId.get(end.wire) + if (!host || host.view !== w.view || end.wire === w.id) bad.push(w.id) + } + } + return bad +} + +/** Pins of a part actually referenced by any wire (used by ERC + delete cascades). */ +export function connectedPins(doc: CircuitDoc, partId: string): Set { + const out = new Set() + for (const w of doc.wires) { + for (const end of [w.from, w.to]) { + if (typeof end === 'string' && splitPinRef(end).part === partId) out.add(splitPinRef(end).pin) + } + } + return out +} diff --git a/src/renderer/src/circuit/core/probes.ts b/src/renderer/src/circuit/core/probes.ts new file mode 100644 index 0000000..bb9c9f8 --- /dev/null +++ b/src/renderer/src/circuit/core/probes.ts @@ -0,0 +1,122 @@ +/** + * circuit/core/probes — placeable sim probes, the pure/testable half (M4 + * leftover, spec §10.3). Part art + registration live in parts/simProbes.ts + * (renderer-facing); the emitted SPICE for the current probe lives in + * core/netlist.ts. This module only needs to: + * - recognize probe part types (probesIn), and + * - compute a differential-voltage probe's subtraction vector from a + * completed run (diffProbeVector/diffProbeVectors) — voltage/diff probes + * emit no SPICE element, so there's nothing else to derive. + * + * Zero React, zero Node — same rule as the rest of core/. + */ + +import type { CircuitDoc, CircuitPart } from './model' +import type { NetModel } from './nets' +import type { NetlistResult } from './netlist' +import type { SimRun, SimVector } from '../sim/backend' + +export type ProbeKind = 'voltage' | 'diff' | 'current' + +export const PROBE_TYPES: Record = { + 'sim-probe-v': 'voltage', + 'sim-probe-vdiff': 'diff', + 'sim-probe-i': 'current' +} + +export function isProbeType(type: string): boolean { + return type in PROBE_TYPES +} + +export interface ProbeInfo { + part: CircuitPart + kind: ProbeKind + /** attrs.label if the user set one, else the part id (refdes) */ + label: string +} + +/** Every placed probe part in the doc, in part order. */ +export function probesIn(doc: CircuitDoc): ProbeInfo[] { + const out: ProbeInfo[] = [] + for (const p of doc.parts) { + const kind = PROBE_TYPES[p.type] + if (!kind) continue + out.push({ part: p, kind, label: String(p.attrs?.label ?? p.id) }) + } + return out +} + +function vectorOfNode(run: SimRun, node: string): SimVector | undefined { + if (node === '0') return { name: 'v(0)', type: 'voltage', values: new Array(run.numPoints).fill(0) } + const key = `v(${node.toLowerCase()})` + return run.vectors.find((v) => v.name.toLowerCase() === key) +} + +/** + * A diff probe's `+`/`-` pins land on two nets; ngspice already reports each + * node's voltage, so the probe's own value is just their subtraction. Returns + * undefined if either pin isn't wired into a net, or the run has no matching + * vector for one side (e.g. that node was excluded/unreachable). + */ +export function diffProbeVector( + probe: CircuitPart, + net: NetModel, + gen: NetlistResult, + run: SimRun +): SimVector | undefined { + const plusIdx = net.pinToNet.get(`${probe.id}:+`) + const minusIdx = net.pinToNet.get(`${probe.id}:-`) + if (plusIdx == null || minusIdx == null) return undefined + const a = vectorOfNode(run, gen.nodeOfNet[plusIdx]) + const b = vectorOfNode(run, gen.nodeOfNet[minusIdx]) + if (!a || !b || a.values.length !== b.values.length) return undefined + const values = a.values.map((v, i) => v - b.values[i]) + const vec: SimVector = { name: `vdiff(${probe.id})`, type: 'voltage', values } + if (a.imag && b.imag) vec.imag = a.imag.map((v, i) => v - b.imag![i]) + return vec +} + +/** All synthetic diff-probe vectors for the current doc + completed run. */ +export function diffProbeVectors( + doc: CircuitDoc, + net: NetModel, + gen: NetlistResult, + run: SimRun +): SimVector[] { + const out: SimVector[] = [] + for (const p of probesIn(doc)) { + if (p.kind !== 'diff') continue + const v = diffProbeVector(p.part, net, gen, run) + if (v) out.push(v) + } + return out +} + +/** + * Friendly label for a vector name, preferring a probe's attrs.label over + * the raw SPICE name: `v(n3)` → "Probe1" when a voltage probe sits on that + * node; `i(v)` → the current probe's label; `vdiff()` → its label. + */ +export function probeLabelFor(vecName: string, doc: CircuitDoc, net: NetModel, gen: NetlistResult): string | undefined { + const diffM = /^vdiff\((.+)\)$/i.exec(vecName) + if (diffM) { + const p = doc.parts.find((p) => p.id.toLowerCase() === diffM[1].toLowerCase()) + return p ? String(p.attrs?.label ?? p.id) : undefined + } + const iM = /^i\(v(.+)\)$/i.exec(vecName) + if (iM) { + const p = doc.parts.find((p) => p.type === 'sim-probe-i' && p.id.toLowerCase() === iM[1].toLowerCase()) + return p ? String(p.attrs?.label ?? p.id) : undefined + } + const vM = /^v\((.+)\)$/i.exec(vecName) + if (vM) { + const node = vM[1].toLowerCase() + const netIdx = gen.nodeOfNet.findIndex((n) => n.toLowerCase() === node) + if (netIdx < 0) return undefined + for (const p of probesIn(doc)) { + if (p.kind !== 'voltage') continue + if (net.pinToNet.get(`${p.part.id}:+`) === netIdx) return p.label + } + } + return undefined +} diff --git a/src/renderer/src/circuit/core/refdes.ts b/src/renderer/src/circuit/core/refdes.ts new file mode 100644 index 0000000..a92da19 --- /dev/null +++ b/src/renderer/src/circuit/core/refdes.ts @@ -0,0 +1,60 @@ +/** + * circuit/core/refdes — reference-designator assignment (R1, C2, LED3, U4…). + * Part ids ARE refdes in circuit.json v2 (§6.4 of the tech spec). + */ + +import type { CircuitDoc } from './model' + +/** Family → prefix map (extended by PartDef.prefix when the registry knows better). */ +const FAMILY_PREFIX: Record = { + passive: 'R', + resistor: 'R', + capacitor: 'C', + inductor: 'L', + diode: 'D', + led: 'LED', + transistor: 'Q', + mosfet: 'Q', + ic: 'U', + microcontroller: 'U', + board: 'U', + switch: 'SW', + button: 'SW', + connector: 'J', + battery: 'BT', + source: 'V', + breadboard: 'BB', + label: 'NL', + probe: 'P' +} + +export function prefixForFamily(family?: string, explicit?: string): string { + if (explicit) return explicit + if (!family) return 'P' + const f = family.toLowerCase() + // longest key first: "breadboard" must win over "board" (BB1, not U1) + const keys = Object.keys(FAMILY_PREFIX).sort((a, b) => b.length - a.length) + for (const key of keys) { + if (f.includes(key)) return FAMILY_PREFIX[key] + } + return 'P' +} + +/** Next free refdes for a prefix: R1, R2, … (fills gaps only via renumberAll). */ +export function nextRefdes(doc: CircuitDoc, prefix: string): string { + let max = 0 + const re = new RegExp(`^${escapeRe(prefix)}(\\d+)$`) + for (const p of doc.parts) { + const m = re.exec(p.id) + if (m) max = Math.max(max, parseInt(m[1], 10)) + } + return `${prefix}${max + 1}` +} + +export function isValidRefdes(id: string): boolean { + return /^[A-Za-z][A-Za-z0-9_-]*$/.test(id) +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/renderer/src/circuit/core/routing.ts b/src/renderer/src/circuit/core/routing.ts new file mode 100644 index 0000000..2f190f0 --- /dev/null +++ b/src/renderer/src/circuit/core/routing.ts @@ -0,0 +1,353 @@ +/** + * circuit/core/routing — orthogonal wire routing + bendpoint model. + * + * Port of `lib/wireRouting.ts` (the tinySchematic/Fritzing-style engine) into + * the v2 core, with one behavioral fix and one extension: + * + * FIX (B2): Wokwi's "*" journey instruction is honored. Instructions before + * "*" are anchored at the SOURCE pin; instructions after "*" are applied in + * REVERSE from the TARGET pin; the remaining gap is auto-completed with an + * orthogonal elbow — exactly the semantics documented at + * https://docs.wokwi.com/diagram-format#wire-placement-mini-language. + * On serialization we always emit source-anchored lists (valid Wokwi). + * + * EXT: `d,` diagonal moves (tinyStudio straight-mode extension) are + * decoded/encoded as before; they never co-exist with "*". + * + * Pure TS. No document knowledge beyond Pt — endpoints are resolved by callers. + */ + +import type { Pt } from './model' + +const COINCIDENT = 0.5 +const samePoint = (a: Pt, b: Pt): boolean => + Math.abs(a.x - b.x) < COINCIDENT && Math.abs(a.y - b.y) < COINCIDENT + +/** True when an instruction list describes a straight (diagonal-capable) wire. */ +export function isStraightRoute(instr?: string[]): boolean { + return !!instr && instr.some((s) => s.startsWith('d')) +} + +/** L-shaped orthogonal path; `endOnMajorAxis` docks the wire into the pin. */ +export function calculateOrthogonalPath( + fromX: number, + fromY: number, + toX: number, + toY: number, + endOnMajorAxis = false +): Pt[] { + const dx = toX - fromX + const dy = toY - fromY + const isWide = Math.abs(dx) > Math.abs(dy) + const goHorizontalFirst = endOnMajorAxis ? !isWide : isWide + if (goHorizontalFirst) { + return [ + { x: fromX, y: fromY }, + { x: toX, y: fromY }, + { x: toX, y: toY } + ] + } + return [ + { x: fromX, y: fromY }, + { x: fromX, y: toY }, + { x: toX, y: toY } + ] +} + +/** Apply one h/v/d instruction to a cursor; returns null for invalid. */ +function step(cur: Pt, instr: string): Pt | null { + if (instr.startsWith('d')) { + const [dx, dy] = instr.slice(1).split(',').map(Number) + if (Number.isNaN(dx) || Number.isNaN(dy)) return null + return { x: cur.x + dx, y: cur.y + dy } + } + const val = parseFloat(instr.substring(1)) + if (Number.isNaN(val)) return null + if (instr.startsWith('v')) return { x: cur.x, y: cur.y + val } + if (instr.startsWith('h')) return { x: cur.x + val, y: cur.y } + return null +} + +/** + * Decode a journey (possibly containing "*") into the full waypoint list + * between live `source` and `target` points — Wokwi semantics (B2 fix): + * pre-"*" → walked forward from source + * post-"*" → walked in reverse from target + * gap → auto-completed orthogonally + * Journeys without "*" behave exactly like the old decoder. + */ +export function decodeJourney(source: Pt, target: Pt, instr?: string[]): Pt[] { + const list = instr ?? [] + const star = list.indexOf('*') + const srcInstr = star === -1 ? list : list.slice(0, star) + const tgtInstr = star === -1 ? [] : list.slice(star + 1) + + const fromSrc: Pt[] = [{ x: source.x, y: source.y }] + for (const s of srcInstr) { + const n = step(fromSrc[fromSrc.length - 1], s) + if (n) fromSrc.push(n) + } + + // Target-side instructions are listed source→target in the file, but are + // applied starting at the target walking backward, then the walked chain is + // reversed so the polyline reads source→target. + const fromTgt: Pt[] = [{ x: target.x, y: target.y }] + for (let i = tgtInstr.length - 1; i >= 0; i--) { + // walking backward = inverse move + const s = tgtInstr[i] + const inv = s.startsWith('h') + ? `h${-parseFloat(s.slice(1))}` + : s.startsWith('v') + ? `v${-parseFloat(s.slice(1))}` + : null + if (!inv) continue + const n = step(fromTgt[fromTgt.length - 1], inv) + if (n) fromTgt.push(n) + } + fromTgt.reverse() + + const a = fromSrc[fromSrc.length - 1] + const b = fromTgt[0] + const mid: Pt[] = samePoint(a, b) ? [] : calculateOrthogonalPath(a.x, a.y, b.x, b.y, false).slice(1, -1) + return simplifyWirePoints([...fromSrc, ...mid, ...fromTgt]) +} + +/** + * Interior bendpoints (absolute) of a wire from its stored journey; points + * coincident with the live source/target are endpoints, not bends. + */ +export function bendsFromJourney(instr: string[] | undefined, source: Pt, target: Pt): Pt[] { + const pts = decodeJourney(source, target, instr) + const interior = pts.slice(1) + while (interior.length && samePoint(interior[interior.length - 1], target)) interior.pop() + while (interior.length && samePoint(interior[0], source)) interior.shift() + return interior.map((p) => ({ x: p.x, y: p.y })) +} + +/** + * Full rendered point list from live endpoints + fixed interior bends. + * Orthogonal wires insert an elbow per segment (final segment on the major + * travel axis); straight wires join waypoints directly. + */ +export function buildWirePoints(source: Pt, bends: Pt[], target: Pt, straight = false): Pt[] { + const waypoints = [source, ...bends, target] + if (straight) return simplifyWirePoints(waypoints.map((p) => ({ x: p.x, y: p.y }))) + const pts: Pt[] = [{ x: waypoints[0].x, y: waypoints[0].y }] + for (let i = 1; i < waypoints.length; i++) { + const a = waypoints[i - 1] + const b = waypoints[i] + const ortho = calculateOrthogonalPath(a.x, a.y, b.x, b.y, i === waypoints.length - 1) + for (let k = 1; k < ortho.length; k++) pts.push(ortho[k]) + } + return simplifyWirePoints(pts) +} + +/** Rendered polyline for a wire given resolved endpoints and its journey. */ +export function wirePoints(source: Pt, target: Pt, instr?: string[]): Pt[] { + const straight = isStraightRoute(instr) + if (straight) { + const bends = bendsFromJourney(instr, source, target) + return buildWirePoints(source, bends, target, true) + } + return decodeJourney(source, target, instr) +} + +/** + * Serialize an absolute point list into a source-anchored journey. + * Orthogonal → h/v; straight mode → d moves for diagonal segments. + */ +export function journeyFromPoints(points: Pt[], straight = false): string[] { + const out: string[] = [] + const round = (v: number): number => Math.round(v * 1000) / 1000 + for (let i = 1; i < points.length; i++) { + const dx = points[i].x - points[i - 1].x + const dy = points[i].y - points[i - 1].y + if (straight && Math.abs(dx) > 0.001 && Math.abs(dy) > 0.001) { + out.push(`d${round(dx)},${round(dy)}`) + continue + } + if (Math.abs(dx) > 0.001) out.push(`h${round(dx)}`) + if (Math.abs(dy) > 0.001) out.push(`v${round(dy)}`) + } + return out +} + +/** Drop duplicate and collinear points so saved routes stay minimal. */ +export function simplifyWirePoints(points: Pt[]): Pt[] { + if (points.length < 3) return points + const unique: Pt[] = [points[0]] + for (let i = 1; i < points.length; i++) { + const prev = unique[unique.length - 1] + if (Math.abs(points[i].x - prev.x) > 0.001 || Math.abs(points[i].y - prev.y) > 0.001) + unique.push(points[i]) + } + if (unique.length < 3) return unique + const simple: Pt[] = [unique[0]] + for (let i = 1; i < unique.length - 1; i++) { + const prev = simple[simple.length - 1] + const curr = unique[i] + const next = unique[i + 1] + const horizontal = Math.abs(prev.y - curr.y) < 0.001 && Math.abs(curr.y - next.y) < 0.001 + const vertical = Math.abs(prev.x - curr.x) < 0.001 && Math.abs(curr.x - next.x) < 0.001 + if (!horizontal && !vertical) simple.push(curr) + } + simple.push(unique[unique.length - 1]) + return simple +} + +/** + * Drag a whole segment perpendicular to itself; anchored endpoints grow a new + * corner instead of moving (pulls a straight wire into a "U"). + */ +export function dragSegment( + points: Pt[], + index: number, + axis: 'horizontal' | 'vertical', + newX: number, + newY: number +): Pt[] { + const cl = (p: Pt): Pt => ({ x: p.x, y: p.y }) + const a = points[index] + const b = points[index + 1] + const isFirst = index === 0 + const isLast = index + 1 === points.length - 1 + let na: Pt + let nb: Pt + if (axis === 'horizontal') { + na = { x: a.x, y: newY } + nb = { x: b.x, y: newY } + } else { + na = { x: newX, y: a.y } + nb = { x: newX, y: b.y } + } + const head = points.slice(0, index).map(cl) + const tail = points.slice(index + 2).map(cl) + const mid: Pt[] = [] + if (isFirst) mid.push(cl(a)) + mid.push(na, nb) + if (isLast) mid.push(cl(b)) + return [...head, ...mid, ...tail] +} + +/** Keep neighbours orthogonal while dragging an interior vertex. */ +export function vertexDrag(orig: Pt[], index: number, x: number, y: number): Pt[] { + const points = orig.map((p) => ({ x: p.x, y: p.y })) + points[index].x = x + points[index].y = y + if (index > 0) { + if (Math.abs(points[index - 1].y - points[index].y) < Math.abs(points[index - 1].x - points[index].x)) { + if (index - 1 > 0) points[index - 1].y = points[index].y + else points[index].y = points[index - 1].y + } else { + if (index - 1 > 0) points[index - 1].x = points[index].x + else points[index].x = points[index - 1].x + } + } + if (index < points.length - 1) { + if (Math.abs(points[index + 1].y - points[index].y) < Math.abs(points[index + 1].x - points[index].x)) { + if (index + 1 < points.length - 1) points[index + 1].y = points[index].y + else points[index].y = points[index + 1].y + } else { + if (index + 1 < points.length - 1) points[index + 1].x = points[index].x + else points[index].x = points[index + 1].x + } + } + return points +} + +// ── hit testing / junctions ────────────────────────────────────────────────── + +export function distanceToSegment(p: Pt, v: Pt, w: Pt): number { + const l2 = (v.x - w.x) ** 2 + (v.y - w.y) ** 2 + if (l2 === 0) return Math.hypot(p.x - v.x, p.y - v.y) + let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (v.x + t * (w.x - v.x)), p.y - (v.y + t * (w.y - v.y))) +} + +/** Clamp a point onto an axis-aligned segment (junction drops). */ +export function clampOntoSegment(p1: Pt, p2: Pt, x: number, y: number): Pt { + if (Math.abs(p1.x - p2.x) > 0.1) { + return { x: Math.max(Math.min(x, Math.max(p1.x, p2.x)), Math.min(p1.x, p2.x)), y: p1.y } + } + return { x: p1.x, y: Math.max(Math.min(y, Math.max(p1.y, p2.y)), Math.min(p1.y, p2.y)) } +} + +/** Total polyline length. */ +export function polylineLength(pts: Pt[]): number { + let l = 0 + for (let i = 1; i < pts.length; i++) l += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y) + return l +} + +/** Point at parametric position t (0..1 by length) along a polyline. */ +export function pointAtT(pts: Pt[], t: number): Pt { + if (pts.length === 0) return { x: 0, y: 0 } + if (pts.length === 1 || t <= 0) return { ...pts[0] } + const total = polylineLength(pts) + if (total === 0 || t >= 1) return { ...pts[pts.length - 1] } + let remain = t * total + for (let i = 1; i < pts.length; i++) { + const seg = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y) + if (remain <= seg) { + const f = seg === 0 ? 0 : remain / seg + return { + x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * f, + y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * f + } + } + remain -= seg + } + return { ...pts[pts.length - 1] } +} + +/** Parametric position (0..1 by length) of the point on the polyline nearest to p. */ +export function tAtPoint(pts: Pt[], p: Pt): number { + const total = polylineLength(pts) + if (total === 0) return 0 + let best = 0 + let bestDist = Infinity + let walked = 0 + for (let i = 1; i < pts.length; i++) { + const v = pts[i - 1] + const w = pts[i] + const seg = Math.hypot(w.x - v.x, w.y - v.y) + const l2 = seg * seg + let t = l2 === 0 ? 0 : ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2 + t = Math.max(0, Math.min(1, t)) + const q = { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) } + const d = Math.hypot(p.x - q.x, p.y - q.y) + if (d < bestDist) { + bestDist = d + best = (walked + t * seg) / total + } + walked += seg + } + return best +} + +export interface WireHit { + id: string + segmentIndex: number + p1: Pt + p2: Pt +} + +/** Topmost wire whose body lies within hitDist (zoom-scaled) of (mx,my). */ +export function hitWire( + mx: number, + my: number, + wires: { id: string; points: Pt[] }[], + zoom: number +): WireHit | null { + const hitDist = 6 / zoom + for (let i = wires.length - 1; i >= 0; i--) { + const { id, points } = wires[i] + for (let j = 0; j < points.length - 1; j++) { + if (distanceToSegment({ x: mx, y: my }, points[j], points[j + 1]) < hitDist) { + return { id, segmentIndex: j, p1: points[j], p2: points[j + 1] } + } + } + } + return null +} diff --git a/src/renderer/src/circuit/core/store.ts b/src/renderer/src/circuit/core/store.ts new file mode 100644 index 0000000..3b510fa --- /dev/null +++ b/src/renderer/src/circuit/core/store.ts @@ -0,0 +1,145 @@ +/** + * circuit/core/store — CircuitStore: document state + undo/redo + subscriptions. + * + * The store holds the parsed document (immutable snapshots — commands return + * new docs with structural sharing) and an undo/redo stack of previous + * snapshots. Because snapshots share structure, memory cost per step is the + * delta, not a copy. Merge keys collapse drag gestures into one step. + * + * React binds via `useSyncExternalStore(store.subscribe, store.getRevision)`. + * File I/O is the caller's job: `serialize()` for saves (debounce upstream), + * `replaceFromFile()` when the file changes externally (Code-tab edits). + */ + +import type { Command } from './commands' +import { + parseCircuitFile, + serializeDoc, + type CircuitDoc +} from './model' + +const MAX_UNDO = 200 +/** Consecutive same-mergeKey commands within this window merge (ms). */ +const MERGE_WINDOW = 1200 + +interface UndoEntry { + doc: CircuitDoc + label: string + mergeKey?: string + at: number +} + +export class CircuitStore { + private doc: CircuitDoc + private undoStack: UndoEntry[] = [] + private redoStack: UndoEntry[] = [] + private revision = 0 + private listeners = new Set<() => void>() + /** Last text produced by serialize()/accepted by replaceFromFile — echo guard. */ + private lastText: string | null = null + + constructor(doc: CircuitDoc) { + this.doc = doc + } + + static fromFile(text: string): { store: CircuitStore; migrated: boolean; warnings: string[] } { + const { doc, migrated, warnings } = parseCircuitFile(text) + const store = new CircuitStore(doc) + if (!migrated) store.lastText = text + return { store, migrated, warnings } + } + + // ── access ──────────────────────────────────────────────────────────────── + + getDoc(): CircuitDoc { + return this.doc + } + /** Monotonic change counter — cheap useSyncExternalStore snapshot. */ + getRevision = (): number => this.revision + + subscribe = (fn: () => void): (() => void) => { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + + private notify(): void { + this.revision++ + for (const fn of this.listeners) fn() + } + + // ── mutation ────────────────────────────────────────────────────────────── + + dispatch(cmd: Command): void { + const next = cmd.apply(this.doc) + if (next === this.doc) return // no-op command (guards, merges into nothing) + + const now = Date.now() + const top = this.undoStack[this.undoStack.length - 1] + const merged = + cmd.mergeKey !== undefined && + top?.mergeKey === cmd.mergeKey && + now - top.at < MERGE_WINDOW + + if (!merged) { + this.undoStack.push({ doc: this.doc, label: cmd.label, mergeKey: cmd.mergeKey, at: now }) + if (this.undoStack.length > MAX_UNDO) this.undoStack.shift() + } else if (top) { + top.at = now // keep the ORIGINAL pre-gesture doc; just extend the window + } + this.redoStack = [] + this.doc = next + this.notify() + } + + canUndo(): boolean { + return this.undoStack.length > 0 + } + canRedo(): boolean { + return this.redoStack.length > 0 + } + undoLabel(): string | undefined { + return this.undoStack[this.undoStack.length - 1]?.label + } + + undo(): void { + const entry = this.undoStack.pop() + if (!entry) return + this.redoStack.push({ doc: this.doc, label: entry.label, at: Date.now() }) + this.doc = entry.doc + this.notify() + } + + redo(): void { + const entry = this.redoStack.pop() + if (!entry) return + this.undoStack.push({ doc: this.doc, label: entry.label, at: Date.now() }) + this.doc = entry.doc + this.notify() + } + + // ── file sync ───────────────────────────────────────────────────────────── + + /** Serialize the current doc; remembers the text to recognize echoes. */ + serialize(): string { + const text = serializeDoc(this.doc) + this.lastText = text + return text + } + + /** + * The file changed outside the editor (Code tab / disk). If it's our own + * echo, ignore. Otherwise adopt it as a new undoable state (the user can + * undo a bad hand-edit). + */ + replaceFromFile(text: string): { applied: boolean; warnings: string[] } { + if (text === this.lastText) return { applied: false, warnings: [] } + const { doc, warnings } = parseCircuitFile(text) + this.undoStack.push({ doc: this.doc, label: 'Edit as code', at: Date.now() }) + if (this.undoStack.length > MAX_UNDO) this.undoStack.shift() + this.redoStack = [] + this.doc = doc + this.lastText = text + this.notify() + return { applied: true, warnings } + } +} diff --git a/src/renderer/src/circuit/index.ts b/src/renderer/src/circuit/index.ts new file mode 100644 index 0000000..981f86d --- /dev/null +++ b/src/renderer/src/circuit/index.ts @@ -0,0 +1,21 @@ +/** + * circuit/ — public API of the Circuit View module. + * + * Everything outside this folder should import from here (or from + * `circuit/views/CircuitView` for the React mount) — internals may reshuffle. + * + * As of M4 this is the only circuit editor (the legacy DiagramEditor and its + * `tinystudio.circuitV2` feature flag were removed once v2 reached parity + + * simulation). + */ + +export * from './core/model' +export * as routing from './core/routing' +export { buildNets, describeNet, danglingJunctions, endKey, wireKey } from './core/nets' +export type { NetModel, BuildNetsOptions } from './core/nets' +export * as commands from './core/commands' +export { CircuitStore } from './core/store' +export * from './core/geometry' +export * from './core/refdes' +export * from './core/clipboard' +export { namespaceSvgIds, svgNs, stripSvgSize, escapeXml } from './parts/svg' diff --git a/src/renderer/src/circuit/parts/breadboard.ts b/src/renderer/src/circuit/parts/breadboard.ts new file mode 100644 index 0000000..a22c93e --- /dev/null +++ b/src/renderer/src/circuit/parts/breadboard.ts @@ -0,0 +1,194 @@ +/** + * circuit/parts/breadboard — procedural breadboard PartDefs (spec §5.1). + * + * Generates crisp SVG + pin maps for three sizes; sizes are parametric so a + * new size is one line. Hole pitch is GRID_BB (2.54 mm @ 96 DPI) and the + * FIRST PIN sits exactly on a pitch multiple, so snap-by-first-pin placement + * puts every hole on the canvas grid — which is what makes drop-to-connect + * degenerate to exact coordinate matches. + * + * Pin naming: main grid `a1…j63` (rows a–e top bank, f–j bottom bank, column + * numbers 1..N). Power rails: `t+1…`, `t-1…` (top), `b+1…`, `b-1…` (bottom). + * Buses (internally common groups): each column's [a…e] and [f…j], plus one + * bus per rail — consumed by buildNets via `busesFor`. + * + * Registered into the (legacy, M1-era) partsLibrary at Circuit View mount; + * the M2+ pack registry will take over ownership without changing geometry. + */ + +import type { PartDef } from '../../lib/partsLibrary' +import { GRID_BB } from '../core/model' + +const P = GRID_BB // 9.6 px hole pitch + +export interface BreadboardSpec { + type: string + label: string + cols: number + rails: boolean +} + +export const BREADBOARDS: BreadboardSpec[] = [ + { type: 'breadboard-mini', label: 'Breadboard (mini)', cols: 17, rails: false }, + { type: 'breadboard-half', label: 'Breadboard (half+)', cols: 30, rails: true }, + { type: 'breadboard-full', label: 'Breadboard (full+)', cols: 63, rails: true } +] + +export function isBreadboard(type: string): boolean { + return type.startsWith('breadboard-') +} + +const ROWS_TOP = ['a', 'b', 'c', 'd', 'e'] +const ROWS_BOT = ['f', 'g', 'h', 'i', 'j'] + +interface Layout { + w: number + h: number + holeX: (col: number) => number + mainY: (rowIdx: number, bank: 'top' | 'bot') => number + railY: Record<'t-' | 't+' | 'b+' | 'b-', number> | null +} + +function layout(spec: BreadboardSpec): Layout { + const holeX = (col: number): number => P * 2 + (col - 1) * P + const w = P * 2 + spec.cols * P + P // margin + columns + margin + if (!spec.rails) { + // mini: rows a–e at 1P..5P, channel, f–j at 7P..11P + return { + w, + h: P * 12.5, + holeX, + mainY: (i, bank) => (bank === 'top' ? P * (1 + i) : P * (7 + i)), + railY: null + } + } + // railed: t- 1P, t+ 2P · a–e 4P..8P · channel · f–j 10P..14P · b+ 16P, b- 17P + return { + w, + h: P * 18.5, + holeX, + mainY: (i, bank) => (bank === 'top' ? P * (4 + i) : P * (10 + i)), + railY: { 't-': P * 1, 't+': P * 2, 'b+': P * 16, 'b-': P * 17 } + } +} + +export interface GeneratedBreadboard { + def: PartDef + buses: string[][] +} + +export function generateBreadboard(spec: BreadboardSpec): GeneratedBreadboard { + const L = layout(spec) + const pins: Record = {} + const buses: string[][] = [] + + for (let c = 1; c <= spec.cols; c++) { + const topBus: string[] = [] + const botBus: string[] = [] + ROWS_TOP.forEach((r, i) => { + pins[`${r}${c}`] = [L.holeX(c), L.mainY(i, 'top')] + topBus.push(`${r}${c}`) + }) + ROWS_BOT.forEach((r, i) => { + pins[`${r}${c}`] = [L.holeX(c), L.mainY(i, 'bot')] + botBus.push(`${r}${c}`) + }) + buses.push(topBus, botBus) + } + if (L.railY) { + for (const rail of ['t-', 't+', 'b+', 'b-'] as const) { + const bus: string[] = [] + for (let c = 1; c <= spec.cols; c++) { + const name = `${rail[0]}${rail[1]}${c}` // "t-3", "b+12" + pins[name] = [L.holeX(c), L.railY[rail]] + bus.push(name) + } + buses.push(bus) + } + } + + const def: PartDef = { + type: spec.type, + label: spec.label, + family: 'Breadboards', + builtin: true, + views: { breadboard: { svg: renderSvg(spec, L), w: L.w, h: L.h, pins } } + } + return { def, buses } +} + +/** buses per breadboard type (memoized), for buildNets({ busesFor }). */ +const busCache = new Map() +export function breadboardBuses(type: string): string[][] | undefined { + if (!isBreadboard(type)) return undefined + if (!busCache.has(type)) { + const spec = BREADBOARDS.find((s) => s.type === type) + if (!spec) return undefined + busCache.set(type, generateBreadboard(spec).buses) + } + return busCache.get(type) +} + +// ── SVG ────────────────────────────────────────────────────────────────────── + +function renderSvg(spec: BreadboardSpec, L: Layout): string { + const parts: string[] = [] + const hole = (x: number, y: number): string => + `` + + // body + parts.push( + `` + ) + // center channel + const chTop = L.mainY(4, 'top') + P * 0.55 + const chBot = L.mainY(0, 'bot') - P * 0.55 + parts.push( + ``, + ``, + `` + ) + // rail stripes + rail holes + if (L.railY) { + const stripe = (y: number, color: string): string => + `` + parts.push( + stripe(L.railY['t-'] - P * 0.55, '#3b6fd4'), + stripe(L.railY['t+'] + P * 0.55, '#d84a44'), + stripe(L.railY['b+'] - P * 0.55, '#d84a44'), + stripe(L.railY['b-'] + P * 0.55, '#3b6fd4') + ) + for (let c = 1; c <= spec.cols; c++) { + for (const rail of ['t-', 't+', 'b+', 'b-'] as const) { + parts.push(hole(L.holeX(c), L.railY[rail])) + } + } + } + // main holes + for (let c = 1; c <= spec.cols; c++) { + ROWS_TOP.forEach((_, i) => parts.push(hole(L.holeX(c), L.mainY(i, 'top')))) + ROWS_BOT.forEach((_, i) => parts.push(hole(L.holeX(c), L.mainY(i, 'bot')))) + } + // row letters + const letterY: [string, number][] = [ + ...ROWS_TOP.map((r, i): [string, number] => [r, L.mainY(i, 'top')]), + ...ROWS_BOT.map((r, i): [string, number] => [r, L.mainY(i, 'bot')]) + ] + for (const [r, y] of letterY) { + parts.push( + `${r}` + ) + } + // column numbers every 5 + for (let c = 1; c <= spec.cols; c++) { + if (c !== 1 && c % 5 !== 0) continue + const x = L.holeX(c) + const yTop = L.mainY(0, 'top') - P * 0.75 + const yBot = L.mainY(4, 'bot') + P * 0.95 + parts.push( + `${c}`, + `${c}` + ) + } + return `${parts.join('')}` +} diff --git a/src/renderer/src/circuit/parts/fzpz.ts b/src/renderer/src/circuit/parts/fzpz.ts new file mode 100644 index 0000000..ec87f74 --- /dev/null +++ b/src/renderer/src/circuit/parts/fzpz.ts @@ -0,0 +1,370 @@ +/** + * circuit/parts/fzpz — in-app `.fzpz` drop-import (M2, spec §7). + * + * A .fzpz is a ZIP of one `part..fzp` (XML metadata + connector list) + * plus its view SVGs stored flat as `svg...svg`. Fritzing does not + * store pin coordinates in the .fzp — connectors name an svgId/terminalId and + * the real point lives *inside* the SVG, under an arbitrary transform stack. + * We resolve each connector by walking the SVG DOM, accumulating ancestor + * transforms, then scaling viewBox units to pixels @ 96 DPI (Wokwi space). + * + * The conversion mirrors scripts/fritzing-import.mjs (the offline bulk + * importer) — same anchors, same fallbacks — so a drop-imported part matches + * what a regenerated default pack would produce. Uses the browser DOMParser, + * so this module is renderer-only (don't import it from core/ or tests). + */ + +import type { PartDef, PartView, ViewKind } from '../../lib/partsLibrary' +import { applyMat, matMul, minifySvg, parseTransform, toPx, type Mat, type Pt2 } from './svgUnits' +import { unzip } from './zip' + +export interface FzpzResult { + def: PartDef + /** connector ids we could not resolve to a point, per view */ + warnings: string[] +} + +interface FzpConnector { + id: string + name: string + views: Partial> +} + +interface Fzp { + moduleId: string + title: string + family: string + viewImage: Partial> + connectors: FzpConnector[] +} + +const VIEW_TAG: Record = { + breadboard: 'breadboardView', + schematic: 'schematicView' +} + +const slug = (s: string): string => + s + .normalize('NFKD') + .replace(/ModuleID$/i, '') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'part' + +const round = (n: number): number => Math.round(n * 100) / 100 + +// ── DOM walking (browser DOM, same shape as the script's xmldom walk) ──────── + +function* walk(node: Element): Generator { + yield node + for (let c = node.firstElementChild; c; c = c.nextElementSibling) yield* walk(c) +} + +function getById(root: Element, id: string): Element | null { + for (const el of walk(root)) if (el.getAttribute('id') === id) return el + return null +} + +/** Cumulative transform matrix from the root down to (and incl.) el. */ +function ctmFor(el: Element): Mat { + const chain: Element[] = [] + let n: Element | null = el + while (n && n.nodeType === 1) { + chain.push(n) + n = n.parentElement + } + let m: Mat = [1, 0, 0, 1, 0, 0] + for (let i = chain.length - 1; i >= 0; i--) { + const tf = chain[i].getAttribute('transform') + if (tf) m = matMul(m, parseTransform(tf)) + } + return m +} + +interface Anchor extends Pt2 { + line?: [Pt2, Pt2] +} + +/** Local-space anchor point of a shape element (before transforms). */ +function localAnchor(el: Element): Anchor | null { + const tag = el.localName.toLowerCase() + const num = (a: string): number => parseFloat(el.getAttribute(a) ?? '') + if (tag === 'rect') { + const x = num('x') || 0 + const y = num('y') || 0 + const w = num('width') || 0 + const h = num('height') || 0 + return { x: x + w / 2, y: y + h / 2 } + } + if (tag === 'circle' || tag === 'ellipse') return { x: num('cx') || 0, y: num('cy') || 0 } + if (tag === 'line') { + const x1 = num('x1') || 0 + const y1 = num('y1') || 0 + const x2 = num('x2') || 0 + const y2 = num('y2') || 0 + return { + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + line: [ + { x: x1, y: y1 }, + { x: x2, y: y2 } + ] + } + } + if (tag === 'polygon' || tag === 'polyline') { + const pts = (el.getAttribute('points') || '').trim().split(/[\s,]+/).map(parseFloat) + if (pts.length >= 2) return bboxCenter(chunk(pts)) + } + if (tag === 'path') { + const nums = (el.getAttribute('d') || '').match(/-?[\d.]+(?:e-?\d+)?/gi) + if (nums && nums.length >= 2) return bboxCenter(chunk(nums.map(parseFloat))) + } + if (tag === 'g') { + for (let c = el.firstElementChild; c; c = c.nextElementSibling) { + const a = localAnchor(c) + if (a) { + const m = parseTransform(c.getAttribute('transform')) + return applyMat(m, a.x, a.y) + } + } + } + return null +} + +function chunk(arr: number[]): Pt2[] { + const out: Pt2[] = [] + for (let i = 0; i + 1 < arr.length; i += 2) out.push({ x: arr[i], y: arr[i + 1] }) + return out +} + +function bboxCenter(pts: Pt2[]): Pt2 { + let minx = Infinity + let miny = Infinity + let maxx = -Infinity + let maxy = -Infinity + for (const p of pts) { + minx = Math.min(minx, p.x) + miny = Math.min(miny, p.y) + maxx = Math.max(maxx, p.x) + maxy = Math.max(maxy, p.y) + } + return { x: (minx + maxx) / 2, y: (miny + maxy) / 2 } +} + +// ── fzp parsing ────────────────────────────────────────────────────────────── + +function parseFzp(xml: string): Fzp { + const doc = new DOMParser().parseFromString(xml, 'text/xml') + const mod = doc.getElementsByTagName('module')[0] + if (!mod) throw new Error('no in .fzp') + + let family = '' + const propsEl = mod.getElementsByTagName('properties')[0] + if (propsEl) { + for (const p of Array.from(propsEl.getElementsByTagName('property'))) { + if ((p.getAttribute('name') || '').toLowerCase() === 'family') + family = (p.textContent || '').trim() + } + } + + const viewImage: Partial> = {} + for (const vname of ['breadboardView', 'schematicView', 'iconView']) { + const v = mod.getElementsByTagName(vname)[0] + if (!v) continue + const layers = v.getElementsByTagName('layers')[0] + if (layers) viewImage[vname] = layers.getAttribute('image') ?? undefined + } + + const connectors: FzpConnector[] = [] + const connsEl = mod.getElementsByTagName('connectors')[0] + if (connsEl) { + for (const c of Array.from(connsEl.getElementsByTagName('connector'))) { + const id = c.getAttribute('id') || '' + const views: FzpConnector['views'] = {} + for (const vname of ['breadboardView', 'schematicView']) { + const v = c.getElementsByTagName(vname)[0] + if (!v) continue + const p = v.getElementsByTagName('p')[0] + if (!p) continue + views[vname] = { + svgId: p.getAttribute('svgId') ?? undefined, + terminalId: p.getAttribute('terminalId') ?? undefined, + legId: p.getAttribute('legId') ?? undefined + } + } + connectors.push({ id, name: c.getAttribute('name') || id, views }) + } + } + + const title = (mod.getElementsByTagName('title')[0]?.textContent || '').trim() + return { moduleId: mod.getAttribute('moduleId') || '', title, family, viewImage, connectors } +} + +// ── per-view SVG extraction ────────────────────────────────────────────────── + +function extractView( + fzp: Fzp, + view: ViewKind, + svgText: string +): { view: PartView; unresolved: string[] } | null { + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') + const svg = doc.getElementsByTagName('svg')[0] + if (!svg) return null + + let vb = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(parseFloat) + const wPx = toPx(svg.getAttribute('width')) + const hPx = toPx(svg.getAttribute('height')) + if (vb.length !== 4 || vb.some(Number.isNaN)) { + if (wPx != null && hPx != null) vb = [0, 0, wPx, hPx] + else return null + } + const [vbx, vby, vbw, vbh] = vb + const sx = wPx != null && vbw ? wPx / vbw : 1 + const sy = hPx != null && vbh ? hPx / vbh : sx + const center = { x: vbx + vbw / 2, y: vby + vbh / 2 } + + const tag = VIEW_TAG[view] + const pins: Record = {} + const legPins: string[] = [] + const unresolved: string[] = [] + const usedNames = new Set() + for (const c of fzp.connectors) { + const cv = c.views[tag] + if (!cv) { + unresolved.push(c.id) + continue + } + // terminal is the precise wire point in schematic; pin element in breadboard + const ids = + view === 'schematic' ? [cv.terminalId, cv.svgId] : [cv.svgId, cv.terminalId] + let pt: Pt2 | null = null + for (const id of ids) { + if (!id) continue + const el = getById(svg, id) + if (!el) continue + const local = localAnchor(el) + if (!local) continue + const m = ctmFor(el) + if (local.line) { + // pick the line end farther from drawing centre (the free/wire end) + const ends = local.line.map((p) => applyMat(m, p.x, p.y)) + pt = ends.reduce((far, e) => + (e.x - center.x) ** 2 + (e.y - center.y) ** 2 > + (far.x - center.x) ** 2 + (far.y - center.y) ** 2 + ? e + : far + ) + } else { + pt = applyMat(m, local.x, local.y) + } + break + } + if (!pt) { + unresolved.push(c.id) + continue + } + let name = c.name || c.id + let n = name + let i = 2 + while (usedNames.has(n)) n = `${name}.${i++}` + usedNames.add(n) + pins[n] = [round((pt.x - vbx) * sx), round((pt.y - vby) * sy)] + // bendable rubber-band leg (LED/resistor class parts) — see scripts/ + // fritzing-import.mjs for the matching bulk-importer half of this. + if (cv.legId) legPins.push(n) + } + if (!Object.keys(pins).length) return null + + svg.removeAttribute('width') + svg.removeAttribute('height') + if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + if (!svg.getAttribute('viewBox')) svg.setAttribute('viewBox', vb.join(' ')) + const svgStr = minifySvg(new XMLSerializer().serializeToString(svg)) + + return { + view: { + svg: svgStr, + w: round(wPx != null ? wPx : vbw * sx), + h: round(hPx != null ? hPx : vbh * sy), + pins, + ...(legPins.length ? { legs: legPins } : {}) + }, + unresolved: unresolved.map((id) => `${view}: ${id}`) + } +} + +function extractIcon(svgText: string): string | null { + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') + const svg = doc.getElementsByTagName('svg')[0] + if (!svg) return null + const w = toPx(svg.getAttribute('width')) + const h = toPx(svg.getAttribute('height')) + svg.removeAttribute('width') + svg.removeAttribute('height') + if (!svg.getAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + if (!svg.getAttribute('viewBox') && w != null && h != null) + svg.setAttribute('viewBox', `0 0 ${w} ${h}`) + return minifySvg(new XMLSerializer().serializeToString(svg)) +} + +// ── entry point ────────────────────────────────────────────────────────────── + +/** Convert a `.fzpz` archive into a registrable PartDef. Throws with a + * human-readable message when the archive is not a usable Fritzing part. */ +export async function importFzpz(bytes: Uint8Array, fileName?: string): Promise { + const entries = await unzip(bytes) + const byName = new Map(entries.map((e) => [e.name, e])) + + const fzpEntry = entries.find((e) => e.name.toLowerCase().endsWith('.fzp')) + if (!fzpEntry) throw new Error('no .fzp inside the archive — is this a Fritzing part?') + const decoder = new TextDecoder() + const fzp = parseFzp(decoder.decode(fzpEntry.data)) + + // "part..fzp" → ; fall back to the dropped filename / title + const base = + /^part\.(.+)\.fzp$/i.exec(fzpEntry.name)?.[1] ?? + fileName?.replace(/\.fzpz$/i, '') ?? + fzp.title + const type = slug(base) + + // view image "breadboard/foo.svg" is stored flat as "svg.breadboard.foo.svg" + const svgTextFor = (image: string | undefined): string | null => { + if (!image) return null + const flat = `svg.${image.replace(/\//g, '.')}` + const entry = + byName.get(flat) ?? + entries.find((e) => e.name.toLowerCase() === flat.toLowerCase()) ?? + entries.find((e) => e.name.toLowerCase().endsWith(image.split('/').pop()!.toLowerCase())) + return entry ? decoder.decode(entry.data) : null + } + + const views: PartDef['views'] = {} + const warnings: string[] = [] + for (const view of ['breadboard', 'schematic'] as ViewKind[]) { + const svgText = svgTextFor(fzp.viewImage[VIEW_TAG[view]]) + if (!svgText) continue + const res = extractView(fzp, view, svgText) + if (!res) continue + views[view] = res.view + warnings.push(...res.unresolved) + } + if (!Object.keys(views).length) + throw new Error('no usable view (missing SVGs or unresolvable pins)') + + const iconText = svgTextFor(fzp.viewImage.iconView) + const icon = + (iconText ? extractIcon(iconText) : null) ?? + (views.breadboard?.svg && views.breadboard.svg.length < 4000 + ? views.breadboard.svg + : undefined) ?? + undefined + + const def: PartDef = { + type, + label: fzp.title || type, + family: fzp.family || 'Imported', + icon, + views + } + return { def, warnings } +} diff --git a/src/renderer/src/circuit/parts/netLabels.ts b/src/renderer/src/circuit/parts/netLabels.ts new file mode 100644 index 0000000..a903231 --- /dev/null +++ b/src/renderer/src/circuit/parts/netLabels.ts @@ -0,0 +1,122 @@ +/** + * circuit/parts/netLabels — generated glyphs for schematic net labels (§8.4). + * + * A net label is a 1-pin entity whose virtual pin is ":1"; labels sharing a + * name merge into one net (the mechanism in core/nets.ts). `ground` is just the + * name 'GND' drawn with the classic three-bar glyph; `power` is an up-flag with + * the rail name (5V, 3V3, VCC…); `net` is a named tag (SDA, OUT…). Ink follows + * the theme via var(--text-strong); the image exporter inlines it at export. + * + * Pins land on the 9.6 px major grid after snapNetLabel (spec §4). + */ + +import type { PartView } from '../../lib/partsLibrary' +import { pinWorld, snapPlacementToPinGrid } from '../core/geometry' +import { GRID_BB, type NetLabel, type NetLabelKind, type Placement, type Pt } from '../core/model' + +const P = GRID_BB // 9.6 +const INK = 'var(--text-strong)' +const STROKE = 2 +export const NET_LABEL_PIN = '1' + +export interface NetLabelKindSpec { + kind: NetLabelKind + /** default net name assigned on placement */ + name: string + /** palette label */ + label: string +} + +/** Palette entries for the "Net Labels" section. */ +export const NET_LABEL_KINDS: NetLabelKindSpec[] = [ + { kind: 'ground', name: 'GND', label: 'Ground' }, + { kind: 'power', name: '5V', label: 'Power 5V' }, + { kind: 'power', name: '3V3', label: 'Power 3V3' }, + { kind: 'net', name: 'NET', label: 'Named net' } +] + +const cache = new Map() + +export function netLabelView(kind: NetLabelKind, name: string): PartView { + const key = `${kind}:${name}` + const hit = cache.get(key) + if (hit) return hit + const v = kind === 'ground' ? groundGlyph() : kind === 'power' ? powerGlyph(name) : netGlyph(name) + cache.set(key, v) + return v +} + +export function netLabelVisualOf(label: NetLabel): PartView { + return netLabelView(label.kind, label.name) +} + +/** World position of a label's single connection pin. */ +export function netLabelPinWorld(label: NetLabel, placement?: Placement): Pt { + const pl = placement ?? label.sch + const v = netLabelVisualOf(label) + return pinWorld(v.pins[NET_LABEL_PIN], pl, v.w, v.h) +} + +/** Snap a label placement so its pin lands on the major grid. */ +export function snapNetLabel(kind: NetLabelKind, name: string, pl: Placement): Placement { + const v = netLabelView(kind, name) + const { x, y } = snapPlacementToPinGrid(pl, v.pins[NET_LABEL_PIN], v.w, v.h, GRID_BB) + return { ...pl, x, y } +} + +function wrap(inner: string, w: number, h: number, pin: [number, number]): PartView { + return { + svg: `${inner}`, + w, + h, + pins: { [NET_LABEL_PIN]: pin } + } +} + +function groundGlyph(): PartView { + const w = 2 * P + const h = 2 * P + const cx = w / 2 + const bars: [number, number][] = [ + [7, P], + [4.5, P + 3.4], + [2, P + 6.8] + ] + const inner = + `` + + bars + .map( + ([hw, y]) => + `` + ) + .join('') + return wrap(inner, w, h, [cx, 0]) +} + +function powerGlyph(name: string): PartView { + const w = Math.max(2 * P, name.length * 6.2 + 10) + const h = 2 * P + const cx = w / 2 + const inner = + `${escape(name)}` + + `` + + `` + return wrap(inner, w, h, [cx, 2 * P]) +} + +function netGlyph(name: string): PartView { + const stub = P + const tagW = name.length * 6.2 + 12 + const w = stub + tagW + const h = 2 * P + const cy = P + const inner = + `` + + `` + + `${escape(name)}` + return wrap(inner, w, h, [0, cy]) +} + +function escape(s: string): string { + return s.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c] as string) +} diff --git a/src/renderer/src/circuit/parts/packs.ts b/src/renderer/src/circuit/parts/packs.ts new file mode 100644 index 0000000..e3a1479 --- /dev/null +++ b/src/renderer/src/circuit/parts/packs.ts @@ -0,0 +1,191 @@ +/** + * circuit/parts/packs — GitHub-distributed parts packs (M2 leftover, spec + * §5.3/§5.4, "Boards-Manager pattern"). + * + * This targets the app's ACTUAL current parts pipeline — the legacy + * `lib/partsLibrary` PartDef (v1) persisted via `lib/userParts.ts` (B7) — + * not the aspirational v2 `registry.ts`/pack.json-with-zips design in the + * spec, which needs the still-unbuilt M2 parts registry migration first. A + * pack here is just a manifest of hosted PartDef v1 JSON files (exactly + * what `scripts/fritzing-import.mjs` / the in-app `.fzpz` importer already + * emit); installing one calls the same `saveUserPart` every other part + * source uses. See docs/tinyparts-pack-setup.md for how to host one. + * + * Formats: + * index.json { schema: 1, packs: [{ id, name, version, description?, url }] } + * pack.json { schema: 1, id, name, version, parts: [{ type, file }] } + * parts/*.json a plain PartDef (registerPart-compatible) + * + * `file`/`url` may be relative — resolved against the manifest/index's own + * URL, so a pack can ship as a self-contained folder of relative paths. + */ + +import type { PartDef } from '../../lib/partsLibrary' +import { saveUserPart } from '../../lib/userParts' + +export interface PackIndexEntry { + id: string + name: string + version: string + description?: string + url: string +} + +export interface PackIndex { + schema: number + packs: PackIndexEntry[] +} + +export interface PackPartRef { + type: string + file: string +} + +export interface PackManifest { + schema: number + id: string + name: string + version: string + description?: string + parts: PackPartRef[] +} + +/** Seeded per the M2 decision log ("new tinyparts repo under + * Mister-Industries"); harmless to fetch — a 404 just surfaces as an error + * row in the UI until the repo/index is published. */ +export const DEFAULT_INDEX_URL = + 'https://raw.githubusercontent.com/Mister-Industries/tinyparts/main/index.json' + +const LS_INDEX_URLS = 'tinystudio.packs.indexUrls' +const LS_INSTALLED = 'tinystudio.packs.installed' + +function resolveUrl(file: string, base: string): string { + try { + return new URL(file, base).toString() + } catch { + return file + } +} + +async function fetchJson(url: string): Promise { + let res: Response + try { + res = await fetch(url, { cache: 'no-store' }) + } catch (e) { + throw new Error(`network error fetching ${url}: ${e instanceof Error ? e.message : String(e)}`) + } + if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`) + try { + return await res.json() + } catch { + throw new Error(`${url} did not return valid JSON`) + } +} + +export async function fetchIndex(url: string): Promise { + const json = await fetchJson(url) + if (!json || typeof json !== 'object' || !Array.isArray((json as PackIndex).packs)) + throw new Error(`${url} is not a valid pack index (expected { packs: [...] })`) + const idx = json as PackIndex + // resolve each pack's manifest url relative to the index itself + return { ...idx, packs: idx.packs.map((p) => ({ ...p, url: resolveUrl(p.url, url) })) } +} + +export async function fetchManifest(url: string): Promise { + const json = await fetchJson(url) + if ( + !json || + typeof json !== 'object' || + typeof (json as PackManifest).id !== 'string' || + !Array.isArray((json as PackManifest).parts) + ) + throw new Error(`${url} is not a valid pack manifest (expected { id, parts: [...] })`) + return json as PackManifest +} + +function isPartDef(v: unknown): v is PartDef { + return ( + !!v && + typeof v === 'object' && + typeof (v as PartDef).type === 'string' && + typeof (v as PartDef).label === 'string' && + !!(v as PartDef).views && + typeof (v as PartDef).views === 'object' + ) +} + +export interface InstallResult { + installed: string[] + failed: { type: string; error: string }[] +} + +/** Install every part in a manifest via the existing saveUserPart path + * (registers + persists to IndexedDB, same as the Parts Editor / .fzpz + * import). Continues past individual part failures. */ +export async function installPack( + manifest: PackManifest, + manifestUrl: string, + onProgress?: (done: number, total: number) => void +): Promise { + const installed: string[] = [] + const failed: { type: string; error: string }[] = [] + let done = 0 + for (const ref of manifest.parts) { + try { + const url = resolveUrl(ref.file, manifestUrl) + const json = await fetchJson(url) + if (!isPartDef(json)) throw new Error(`${url} is not a valid part definition`) + await saveUserPart(json) + installed.push(ref.type) + } catch (e) { + failed.push({ type: ref.type, error: e instanceof Error ? e.message : String(e) }) + } finally { + done++ + onProgress?.(done, manifest.parts.length) + } + } + if (installed.length) markInstalled(manifest.id, manifest.version) + return { installed, failed } +} + +// ── settings (index URL list + installed-version tracking) ───────────────── + +export function getIndexUrls(): string[] { + try { + const raw = localStorage.getItem(LS_INDEX_URLS) + if (!raw) return [DEFAULT_INDEX_URL] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) && parsed.every((u) => typeof u === 'string') + ? parsed + : [DEFAULT_INDEX_URL] + } catch { + return [DEFAULT_INDEX_URL] + } +} + +export function setIndexUrls(urls: string[]): void { + try { + localStorage.setItem(LS_INDEX_URLS, JSON.stringify(urls)) + } catch { + /* quota / privacy mode — session-only */ + } +} + +export function getInstalledPacks(): Record { + try { + const raw = localStorage.getItem(LS_INSTALLED) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +function markInstalled(id: string, version: string): void { + const map = getInstalledPacks() + map[id] = version + try { + localStorage.setItem(LS_INSTALLED, JSON.stringify(map)) + } catch { + /* quota / privacy mode */ + } +} diff --git a/src/renderer/src/circuit/parts/resistorBands.ts b/src/renderer/src/circuit/parts/resistorBands.ts new file mode 100644 index 0000000..2cc5001 --- /dev/null +++ b/src/renderer/src/circuit/parts/resistorBands.ts @@ -0,0 +1,117 @@ +/** + * circuit/parts/resistorBands — resistor color-code decoration. + * + * The Fritzing resistor art carries clean band ids (`band_1_st`, `band_2_nd`, + * `band_rd_multiplier`; `gold_band` is tolerance and stays gold). When a part + * has a `resistance` attr we recolor those bands to the 4-band code, so the + * breadboard art always tells the truth about the value. Pure string work + * (regex on the id'd elements), cached per (svg, value). + */ + +/** "4.7k", "220", "1M", "10 Ω" → ohms (number), or null if unparseable. */ +export function parseOhms(v: string | number | boolean | undefined): number | null { + if (v === undefined || typeof v === 'boolean') return null + if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? v : null + const s = v + .trim() + .replace(/[ΩΩ]|ohms?/gi, '') + .replace(/\s+/g, '') + const m = /^([0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?)(Meg|meg|MEG|[GgkKMmunp])?$/.exec(s) + if (!m) return null + const base = parseFloat(m[1]) + const suffix = m[2] + const mult = !suffix + ? 1 + : /^meg$/i.test(suffix) + ? 1e6 + : { G: 1e9, g: 1e9, k: 1e3, K: 1e3, M: 1e6, m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[ + suffix as 'G' | 'g' | 'k' | 'K' | 'M' | 'm' | 'u' | 'n' | 'p' + ] + const ohms = base * (mult ?? 1) + return Number.isFinite(ohms) && ohms > 0 ? ohms : null +} + +/** IEC 60062 digit colors 0–9 (hexes tuned to the Fritzing art palette). */ +export const DIGIT_COLORS = [ + '#000000', // 0 black + '#8A3D06', // 1 brown + '#C40808', // 2 red + '#E87800', // 3 orange + '#F5D100', // 4 yellow + '#189E30', // 5 green + '#2456D6', // 6 blue + '#7C2BC1', // 7 violet + '#8C8C8C', // 8 grey + '#F5F5F5' // 9 white +] as const + +const GOLD = '#AD9F4E' +const SILVER = '#BFBFBF' + +export interface BandColors { + d1: string + d2: string + mult: string +} + +/** 4-band colors for a value (two significant digits + multiplier). */ +export function bandColorsFor(ohms: number): BandColors | null { + if (!Number.isFinite(ohms) || ohms <= 0) return null + let exp = Math.floor(Math.log10(ohms)) - 1 + let sig = Math.round(ohms / Math.pow(10, exp)) + if (sig >= 100) { + sig = Math.round(sig / 10) + exp += 1 + } + if (sig < 10) { + // values < 10 Ω land here (e.g. 4.7 → sig 47, exp −1 — already handled); + // a degenerate rounding can still under-run, renormalize + sig *= 10 + exp -= 1 + } + const d1 = Math.floor(sig / 10) + const d2 = sig % 10 + let mult: string + if (exp >= 0 && exp <= 9) mult = DIGIT_COLORS[exp] + else if (exp === -1) mult = GOLD + else if (exp === -2) mult = SILVER + else return null + return { d1: DIGIT_COLORS[d1], d2: DIGIT_COLORS[d2], mult } +} + +const BAND_IDS: [keyof BandColors, string][] = [ + ['d1', 'band_1_st'], + ['d2', 'band_2_nd'], + ['mult', 'band_rd_multiplier'] +] + +/** True when this art carries recolorable band elements. */ +export function hasResistorBands(svg: string): boolean { + return svg.includes('band_1_st') +} + +const cache = new Map() + +/** Recolor the value bands of resistor art for a given resistance attr. + * Unparseable/absent values return the art untouched. */ +export function decorateResistor( + svg: string, + resistance: string | number | boolean | undefined +): string { + const ohms = parseOhms(resistance) + if (ohms === null || !hasResistorBands(svg)) return svg + const colors = bandColorsFor(ohms) + if (!colors) return svg + const key = `${colors.d1}|${colors.d2}|${colors.mult}|${svg.length}|${svg.slice(0, 64)}` + const hit = cache.get(key) + if (hit !== undefined) return hit + let out = svg + for (const [slot, id] of BAND_IDS) { + out = out.replace( + new RegExp(`(<[^>]*\\bid="${id}"[^>]*?\\bfill=")[^"]*(")`), + `$1${colors[slot]}$2` + ) + } + cache.set(key, out) + return out +} diff --git a/src/renderer/src/circuit/parts/simParts.ts b/src/renderer/src/circuit/parts/simParts.ts new file mode 100644 index 0000000..2051d4b --- /dev/null +++ b/src/renderer/src/circuit/parts/simParts.ts @@ -0,0 +1,101 @@ +/** + * circuit/parts/simParts — builtin simulation sources (M4, spec §10.3). + * + * Generated two-pin source symbols (DC voltage, sine, DC current) in the + * classic circle style. Both views get the same art so a source is placeable + * on the breadboard too (its pins seat in holes like any part). Pins land on + * the 9.6 grid; ink follows the theme via var(--text-strong) like symbols.ts. + * + * The netlist generator maps these by type (sim-vdc / sim-vsin / sim-idc); + * editable attrs (voltage, amplitude, frequency, current) ride Part attrs and + * are exposed in the Inspector like any other attr. + */ + +import type { PartDef, PartView } from '../../lib/partsLibrary' +import { GRID_BB } from '../core/model' + +const P = GRID_BB // 9.6 +const INK = 'var(--text-strong)' +const STROKE = 2 + +export interface SimSourceSpec { + type: string + label: string + /** default attrs applied on placement (editable in the Inspector) */ + attrs: Record + glyph: (cx: number, cy: number, r: number) => string +} + +export const SIM_SOURCES: SimSourceSpec[] = [ + { + type: 'sim-vdc', + label: 'DC voltage', + attrs: { voltage: '5' }, + glyph: (cx, cy, r) => + `` + + `` + + `` + }, + { + type: 'sim-vsin', + label: 'Sine source', + attrs: { amplitude: '1', frequency: '1k' }, + glyph: (cx, cy, r) => + `` + }, + { + type: 'sim-idc', + label: 'DC current', + attrs: { current: '10m' }, + glyph: (cx, cy, r) => + `` + + `` + } +] + +const cache = new Map() + +export function generateSimSource(spec: SimSourceSpec): PartDef { + const hit = cache.get(spec.type) + if (hit) return hit + + const w = 4 * P + const h = 6 * P + const cx = 2 * P + const cy = 3 * P + const r = 1.5 * P + + const svg = + `` + + `` + + `` + + `` + + `+` + + spec.glyph(cx, cy, r) + + `` + + const view: PartView = { + svg, + w, + h, + pins: { '+': [cx, 0], '-': [cx, h] } + } + const def: PartDef = { + type: spec.type, + label: spec.label, + family: 'Source', + builtin: true, + views: { breadboard: view, schematic: view } + } + cache.set(spec.type, def) + return def +} + +export function isSimSource(type: string): boolean { + return SIM_SOURCES.some((s) => s.type === type) +} + +/** Default attrs for a just-placed source (Inspector-editable). */ +export function simSourceDefaultAttrs(type: string): Record | undefined { + return SIM_SOURCES.find((s) => s.type === type)?.attrs +} diff --git a/src/renderer/src/circuit/parts/simProbes.ts b/src/renderer/src/circuit/parts/simProbes.ts new file mode 100644 index 0000000..e8fbe2a --- /dev/null +++ b/src/renderer/src/circuit/parts/simProbes.ts @@ -0,0 +1,112 @@ +/** + * circuit/parts/simProbes — builtin sim probes (M4 leftover, spec §10.3). + * + * Three placeable 1/2-pin builtins, CircuitLab-flag style, same art both + * views (seatable on a breadboard like any part): + * - sim-probe-v voltage probe (1 pin: `+`) + * - sim-probe-vdiff diff. voltage (2 pins: `+`, `-`) + * - sim-probe-i current probe (2 pins: `in`, `out` — wired in + * series with the branch to measure) + * + * Voltage/diff probes need no SPICE element (ngspice already reports every + * node's voltage under `.op`/`.tran`/`.ac`) — core/netlist.ts marks them + * `transparent`. The current probe DOES need a real element: it emits a 0V + * series voltage source so ngspice reports `i(v)` through it (an ideal + * ammeter). Diff-probe subtraction and probe labeling live in core/probes.ts. + */ + +import type { PartDef, PartView } from '../../lib/partsLibrary' +import { GRID_BB } from '../core/model' + +const P = GRID_BB // 9.6 +const INK = 'var(--text-strong)' +const FLAG = '#f0b429' // CircuitLab-style amber flag + +export type ProbeKind = 'voltage' | 'diff' | 'current' + +export interface ProbeSpec { + type: string + label: string + kind: ProbeKind +} + +export const SIM_PROBES: ProbeSpec[] = [ + { type: 'sim-probe-v', label: 'Voltage Probe', kind: 'voltage' }, + { type: 'sim-probe-vdiff', label: 'Diff. Voltage Probe', kind: 'diff' }, + { type: 'sim-probe-i', label: 'Current Probe', kind: 'current' } +] + +const cache = new Map() + +/** Small triangular pennant + stem from y=0 to y=stemLen, CircuitLab-style. */ +function flag(cx: number, stemLen: number): string { + const pennantH = Math.min(1.1 * P, stemLen * 0.6) + return ( + `` + + `` + ) +} + +export function generateSimProbe(spec: ProbeSpec): PartDef { + const hit = cache.get(spec.type) + if (hit) return hit + + let view: PartView + if (spec.kind === 'current') { + // inline ammeter: two pins on a horizontal line through a circled "A" + const w = 4 * P + const h = 2 * P + const cy = P + const r = 0.85 * P + const svg = + `` + + `` + + `` + + `` + + `A` + + `` + view = { svg, w, h, pins: { in: [0, cy], out: [w, cy] } } + } else if (spec.kind === 'diff') { + const stem = 2.4 * P + const w = 3.6 * P + const h = stem + 0.8 * P + const svg = + `` + + `${flag(0, stem)}` + + `${flag(0, stem)}` + + `+ −` + + `` + view = { svg, w, h, pins: { '+': [0.3 * P, stem], '-': [2 * P, stem] } } + } else { + const stem = 2.4 * P + const w = 2.4 * P + const h = stem + const svg = + `` + flag(0.4 * P, stem) + `` + view = { svg, w, h, pins: { '+': [0.4 * P, stem] } } + } + + const def: PartDef = { + type: spec.type, + label: spec.label, + family: 'Probe', + builtin: true, + views: { breadboard: view, schematic: view } + } + cache.set(spec.type, def) + return def +} + +export function isSimProbe(type: string): boolean { + return SIM_PROBES.some((s) => s.type === type) +} + +export function probeKindOf(type: string): ProbeKind | undefined { + return SIM_PROBES.find((s) => s.type === type)?.kind +} + +/** Default attrs for a just-placed probe (Inspector-editable `label`). */ +export function simProbeDefaultAttrs(type: string): Record | undefined { + const spec = SIM_PROBES.find((s) => s.type === type) + return spec ? { label: spec.label } : undefined +} diff --git a/src/renderer/src/circuit/parts/svg.ts b/src/renderer/src/circuit/parts/svg.ts new file mode 100644 index 0000000..e6d2daa --- /dev/null +++ b/src/renderer/src/circuit/parts/svg.ts @@ -0,0 +1,85 @@ +/** + * circuit/parts/svg — SVG utilities for part artwork. + * + * M1 ships the pieces the image exporter needs (B6 fix): id-namespacing so + * two parts whose SVGs both define e.g. `id="g"` don't corrupt each other in + * a composed export, plus small size/escape helpers. The full sanitizer and + * the procedural breadboard generator land in M2 (spec §5). + * + * Pure string transforms — no DOM — so they run in tests, workers, exports. + */ + +/** + * Prefix every id defined in an SVG string (and every internal reference to + * one: url(#…), href="#…", xlink:href="#…") with `ns-`. External hrefs are + * untouched. + */ +export function namespaceSvgIds(svg: string, ns: string): string { + const ids = new Set() + for (const m of svg.matchAll(/\bid\s*=\s*"([^"]+)"/g)) ids.add(m[1]) + for (const m of svg.matchAll(/\bid\s*=\s*'([^']+)'/g)) ids.add(m[1]) + if (ids.size === 0) return svg + const has = (id: string): boolean => ids.has(id) + + // rewrite #id selectors inside
+ {editable && ( + void addPartAt(type)} + onAddNetLabel={(kind, name) => addNetLabel(kind as NetLabelKind, name)} + onEditPart={(type) => void editExisting(type)} + onNewPart={() => setEditorPart(null)} + onOpenPacks={() => setShowPacks(true)} + /> + )} + +
+ {/* left toolbar: edit toggle + undo/redo */} +
+ + {editable && ( + <> + + + + )} +
+ + {/* view toggle: Breadboard | Schematic */} +
+ {( + [ + ['bb', 'Breadboard'], + ['sch', 'Schematic'] + ] as [ViewId, string][] + ).map(([v, label]) => ( + + ))} +
+ + {/* unplaced tray: parts that only exist in the other view */} + {trayParts.length > 0 && ( +
+ unplaced here: + {trayParts.map((part) => ( + + ))} +
+ )} + + {/* right toolbar: export / grid / code */} +
+ + + + + {onOpenCode && ( + + )} +
+ + {/* zoom cluster */} +
+ + + {Math.round(cam.scale * 100)}% + + + +
+ + {/* only surfaces when something needs attention: unwired nets / unplaced + parts / ERC (click for details), plus the one-time migration note. */} + {(problems.length > 0 || migrated || warnings.length > 0) && ( +
+ {problems.length > 0 && ( + + )} + {migrated && ( + + migrated from diagram.json + + )} + {warnings.length > 0 && ( + + {warnings.length} migration note{warnings.length > 1 ? 's' : ''} + + )} +
+ )} + + {/* ERC panel (spec §9): non-blocking findings list */} + {showErc && ( +
+
+ + Electrical rule check + + +
+
+ {erc.length === 0 ? ( +
+ No issues found. +
+ ) : ( + erc.map((i) => ) + )} +
+
+ )} + + void addPartAt(type, at)} + onDropNetLabel={(kind, name, at) => addNetLabel(kind, name, at)} + onImportFiles={(files, at) => void importFzpzFiles(files, at)} + annotations={simAnnotations} + simVoltageForNet={simVoltageForNet} + onRequestEdit={enterEdit} + /> + + {showSim && ( + getPart(t)?.family} + onClose={() => setShowSim(false)} + onResult={setSim} + onSelectIssue={selectSimIssue} + /> + )} + + {/* watermark — matches the header wordmark: thin 'tiny', bold 'Studio' */} +
+ tiny + Studio +
+
+ + {editable && ( + + )} + + {editorPart !== undefined && ( + setEditorPart(undefined)} + onSave={(def: PartDef) => { + void saveUserPart(def) + bumpDefs() + setEditorPart(undefined) + }} + /> + )} + + {showPacks && ( + setShowPacks(false)} onInstalled={() => bumpDefs()} /> + )} +
+ ) +} + +function ErcRow({ + issue, + onSelect +}: { + issue: ErcIssue + onSelect: (i: ErcIssue) => void +}): React.JSX.Element { + const color = + issue.severity === 'error' + ? 'text-status-danger' + : issue.severity === 'warning' + ? 'text-status-warning' + : 'text-text-muted' + const Icon = + issue.severity === 'error' ? CircleAlert : issue.severity === 'warning' ? TriangleAlert : Info + const clickable = !!(issue.ref?.part || issue.ref?.wire || issue.ref?.label) + return ( + + ) +} diff --git a/src/renderer/src/circuit/views/canvas/Canvas.tsx b/src/renderer/src/circuit/views/canvas/Canvas.tsx new file mode 100644 index 0000000..3eda10d --- /dev/null +++ b/src/renderer/src/circuit/views/canvas/Canvas.tsx @@ -0,0 +1,1818 @@ +/** + * circuit/views/canvas/Canvas — the interactive breadboard scene (M1). + * + * Owns: camera (zoom/pan/fit), pointer routing, selection + marquee, wire + * drawing/editing state machines, part drag with frozen-bend reroutes, and + * rendering (parts, glossy-tube wires, junction dots, handles, preview). + * The gesture set is a 1:1 port of DiagramEditor's editor behaviors, re-said + * as Commands against the CircuitStore — same editor, now with undo. + * + * Document mutations ONLY go through store.dispatch(command). Ephemeral state + * (camera, hover, armed wire, marquee, edit buffer) never touches the doc. + */ + +import { CircuitBoard, Zap } from 'lucide-react' +import React from 'react' +import { buildClipboard, materializePaste, parseClipboard } from '../../core/clipboard' +import * as cmd from '../../core/commands' +import { + GRID_BB, + GRID_SCH, + isJunction, + isPendingJunction, + type CircuitDoc, + type NetLabelKind, + type Placement, + type Pt, + type ViewId, + type WireEnd +} from '../../core/model' +import { describeNet, type NetModel } from '../../core/nets' +import { isBreadboard } from '../../parts/breadboard' +import { netLabelVisualOf, snapNetLabel } from '../../parts/netLabels' +import { + calculateOrthogonalPath, + clampOntoSegment, + dragSegment, + hitWire, + isStraightRoute, + journeyFromPoints, + simplifyWirePoints, + tAtPoint, + vertexDrag, + type WireHit +} from '../../core/routing' +import type { CircuitStore } from '../../core/store' +import { + collectFrozen, + freePasteOffset, + holeAt, + makeEndResolver, + pinAtWorld, + partArtFor, + reroutesFor, + rotateBoardAssemblyCmd, + rotateNetLabelCmd, + seatedPartsOn, + snapBB, + viewBounds, + visualFor, + wireGeometry, + type FrozenWire, + type RatsnestSegment, + type Seat +} from '../partsAdapter' + +// wire look (identical to DiagramEditor / tinySchematic) +const WIRE_W = 2.8 +const WIRE_SCH_W = 1 // schematic ink: thin single stroke (no outline/glow) +const WIRE_OUTLINE_W = WIRE_W + 1.8 +const WIRE_GLOW_W = WIRE_W + 5 +const WIRE_CORNER = 4 +const NET_GLOW = 'rgba(243, 203, 0, 0.30)' + +export interface Selection { + parts: Set + wires: Set + /** selected net-label ids (schematic) */ + labels?: Set +} +export const emptySel = (): Selection => ({ + parts: new Set(), + wires: new Set(), + labels: new Set() +}) + +export interface Cam { + scale: number + tx: number + ty: number +} + +export interface CanvasHandle { + fit: () => void + zoomCenter: (factor: number) => void + /** World coordinate at the viewport centre (palette double-click drops). */ + centerWorld: () => Pt +} + +type HandleKind = 'vertex' | 'edge' | 'endpoint' + +interface Armed { + from: string // "part:pin" — drawing always starts on a pin + points: Pt[] + straight: boolean +} + +function darken(hex: string, f = 0.55): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) + if (!m) return 'rgba(0,0,0,0.45)' + const n = parseInt(m[1], 16) + return `rgb(${Math.round(((n >> 16) & 255) * f)}, ${Math.round(((n >> 8) & 255) * f)}, ${Math.round((n & 255) * f)})` +} + +function roundedPath(points: Pt[], r = WIRE_CORNER): string { + if (points.length < 2) return '' + if (points.length === 2) return `M${points[0].x} ${points[0].y} L${points[1].x} ${points[1].y}` + let d = `M${points[0].x} ${points[0].y}` + for (let i = 1; i < points.length - 1; i++) { + const p = points[i - 1] + const c = points[i] + const n = points[i + 1] + const d1 = Math.hypot(c.x - p.x, c.y - p.y) || 1 + const d2 = Math.hypot(n.x - c.x, n.y - c.y) || 1 + const rr = Math.max(0, Math.min(r, d1 / 2, d2 / 2)) + const a = { x: c.x - ((c.x - p.x) / d1) * rr, y: c.y - ((c.y - p.y) / d1) * rr } + const b = { x: c.x + ((n.x - c.x) / d2) * rr, y: c.y + ((n.y - c.y) / d2) * rr } + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}` + } + const last = points[points.length - 1] + d += ` L${last.x} ${last.y}` + return d +} + +export function Canvas({ + store, + doc, + view, + editable, + grid, + sel, + setSel, + wireColor, + netModel, + seats, + rats, + defsTick, + cam, + setCam, + handleRef, + onDropPart, + onDropNetLabel, + onImportFiles, + annotations, + simVoltageForNet, + onRequestEdit +}: { + store: CircuitStore + doc: CircuitDoc + view: ViewId + editable: boolean + grid: boolean + sel: Selection + setSel: (s: Selection) => void + wireColor: string + netModel: NetModel + /** derived breadboard seating (drop-to-connect), for seat marks + sticky */ + seats: Seat[] + /** unrouted-in-this-view net bridges (dashed guidance lines) */ + rats: RatsnestSegment[] + /** bumps when lazy part defs finish loading (geometry depends on them) */ + defsTick: number + cam: Cam + setCam: React.Dispatch> + handleRef: React.Ref + onDropPart: (type: string, at: Pt) => void + onDropNetLabel: (kind: NetLabelKind, name: string, at: Pt) => void + /** dropped OS files (.fzpz part import) */ + onImportFiles?: (files: File[], at: Pt) => void + /** world-anchored value chips (DC sim annotations) */ + annotations?: { x: number; y: number; text: string }[] + /** DC (.op) net voltage lookup, for the breadboard hole tooltip */ + simVoltageForNet?: (netIdx: number) => string | undefined + /** tray "attach to cursor" placement: id being placed + drop callback */ + /** double-clicking a component asks the shell to enter edit mode */ + onRequestEdit: () => void +}): React.JSX.Element { + const scale = cam.scale + // schematic wires bend on the fine grid; parts still snap pins to major + const wireGrid = view === 'sch' ? GRID_SCH : GRID_BB + const snap = (v: number): number => Math.round(v / wireGrid) * wireGrid + const ink = 'var(--text-strong)' + const viewRef = React.useRef(null) + const innerRef = React.useRef(null) + + const [armed, setArmed] = React.useState(null) + const [straight, setStraight] = React.useState(false) + const [hoverPin, setHoverPin] = React.useState<{ id: string; pin: string } | null>(null) + const [hoverHole, setHoverHole] = React.useState<{ id: string; pin: string; pos: Pt } | null>( + null + ) + const [hoverWire, setHoverWire] = React.useState(null) + const [mouse, setMouse] = React.useState({ x: 0, y: 0 }) + const [editPts, setEditPts] = React.useState(null) + const [marquee, setMarquee] = React.useState<{ a: Pt; b: Pt } | null>(null) + // live placement overrides while a part drag is in flight (render source) + const [dragOverrides, setDragOverrides] = React.useState | null>(null) + + const pan = React.useRef(null) + const partDrag = React.useRef<{ + ids: string[] + grabbed: string + offX: number + offY: number + orig: Map + frozen: FrozenWire[] + /** set once the pointer actually displaces — distinguishes board-hole clicks */ + moved: boolean + } | null>(null) + const handleDrag = React.useRef<{ kind: HandleKind } | null>(null) + const labelDrag = React.useRef<{ id: string; ox: number; oy: number } | null>(null) + const netLabelDrag = React.useRef<{ + id: string + offX: number + offY: number + frozen: FrozenWire[] + moved: boolean + } | null>(null) + const suppressClick = React.useRef(false) + + // ── geometry ──────────────────────────────────────────────────────────────── + + const resolve = React.useMemo( + () => makeEndResolver(doc, dragOverrides ?? undefined, view), + [doc, dragOverrides, defsTick, view] + ) + const wireGeom = React.useMemo( + () => wireGeometry(doc, view, dragOverrides ?? undefined), + [doc, dragOverrides, defsTick, view] + ) + const geomForHit = React.useMemo( + () => wireGeom.filter((g) => g.pts.length >= 2).map((g) => ({ id: g.w.id, points: g.pts })), + [wireGeom] + ) + + const canvasPoint = (e: { clientX: number; clientY: number }): Pt => { + const r = innerRef.current!.getBoundingClientRect() + return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale } + } + const placementOf = (id: string): Placement | undefined => + dragOverrides?.get(id) ?? doc.parts.find((p) => p.id === id)?.[view] + + // selected wire edit buffer stays in sync with the doc (undo, external edits) + const soloWire = sel.wires.size === 1 && sel.parts.size === 0 ? [...sel.wires][0] : null + React.useEffect(() => { + if (handleDrag.current) return + if (!soloWire) { + setEditPts(null) + return + } + const g = wireGeom.find((x) => x.w.id === soloWire) + setEditPts(g && g.pts.length >= 2 ? g.pts : null) + }, [soloWire, wireGeom]) + + // resolve v1-migrated *pending junctions* once geometry exists (M0 gap) + const pendingDone = React.useRef(false) + React.useEffect(() => { + if (view !== 'bb') return // v1 migration produced bb wires only + if (pendingDone.current) return + const pending = doc.wires.filter((w) => isPendingJunction(w.from) || isPendingJunction(w.to)) + if (pending.length === 0) { + pendingDone.current = true + return + } + if (doc.parts.some((p) => p.bb && !visualFor(p.type, 'bb'))) return // defs still loading + const fixes: cmd.Command[] = [] + for (const w of pending) { + const fix = (end: WireEnd): WireEnd => { + if (!isPendingJunction(end)) return end + const others = geomForHit.filter((g) => g.id !== w.id) + const hit = hitWire(end.x, end.y, others, 0.75) // 8 world px tolerance + if (!hit) return end // stays pending; renders at its raw coordinate + const host = others.find((g) => g.id === hit.id)! + return { wire: hit.id, t: tAtPoint(host.points, { x: end.x, y: end.y }) } + } + const from = fix(w.from) + const to = fix(w.to) + if (from !== w.from || to !== w.to) fixes.push(cmd.setWireEnds(w.id, from, to)) + } + pendingDone.current = true + if (fixes.length) store.dispatch(cmd.composite('Resolve migrated junctions', fixes)) + }, [doc, geomForHit, store, defsTick]) + + // ── net highlight ─────────────────────────────────────────────────────────── + + const highlightNet = React.useMemo(() => { + if (hoverPin) { + const i = netModel.pinToNet.get(`${hoverPin.id}:${hoverPin.pin}`) + if (i != null) return i + } + const wid = hoverWire ?? soloWire + if (wid != null) { + const i = netModel.wireToNet.get(wid) + if (i != null) return i + } + return -1 + }, [hoverPin, hoverWire, soloWire, netModel]) + + // ── camera ────────────────────────────────────────────────────────────────── + + const fit = React.useCallback((): void => { + const el = viewRef.current + if (!el) return + const b = viewBounds(doc, view) + if (!b) return + const pad = 48 + const bw = b.maxX - b.minX + pad * 2 + const bh = b.maxY - b.minY + pad * 2 + const f = Math.max(0.25, Math.min(3, Math.min(el.clientWidth / bw, el.clientHeight / bh))) + setCam({ + scale: f, + tx: (el.clientWidth - bw * f) / 2 - (b.minX - pad) * f, + ty: (el.clientHeight - bh * f) / 2 - (b.minY - pad) * f + }) + }, [doc, setCam, view]) + + const zoomAt = (factor: number, sx: number, sy: number): void => + setCam((c) => { + const ns = Math.max(0.25, Math.min(3, c.scale * factor)) + const k = ns / c.scale + return { scale: ns, tx: sx - (sx - c.tx) * k, ty: sy - (sy - c.ty) * k } + }) + + React.useImperativeHandle( + handleRef, + () => ({ + fit, + zoomCenter: (factor: number) => { + const el = viewRef.current + if (el) zoomAt(factor, el.clientWidth / 2, el.clientHeight / 2) + }, + centerWorld: () => { + const el = viewRef.current + if (!el) return { x: 0, y: 0 } + return { + x: (el.clientWidth / 2 - cam.tx) / scale, + y: (el.clientHeight / 2 - cam.ty) / scale + } + } + }), + [fit, cam, scale] + ) + + const didFit = React.useRef(false) + React.useEffect(() => { + if (didFit.current) return + if (doc.parts.length === 0 || doc.parts.every((p) => !p[view] || visualFor(p.type, view))) { + fit() + didFit.current = true + } + }) + + // ── part drag (frozen bends; multi-select moves together) ─────────────────── + + const onPartDown = (e: React.PointerEvent, partId: string): void => { + if ((e.target as HTMLElement).closest('.pin-hit')) return + e.stopPropagation() + if (!editable) { + setSel({ parts: new Set([partId]), wires: new Set() }) + return + } + if (e.shiftKey) { + const parts = new Set(sel.parts) + if (parts.has(partId)) parts.delete(partId) + else parts.add(partId) + setSel({ parts, wires: new Set() }) + return + } + const part = doc.parts.find((p) => p.id === partId) + // sticky boards: dragging a breadboard takes its seated parts along (bb) + const stickyIds = + view === 'bb' && part && isBreadboard(part.type) + ? [partId, ...seatedPartsOn(partId, seats)] + : [partId] + const moveIds = sel.parts.has(partId) ? [...sel.parts] : stickyIds + if (!sel.parts.has(partId)) setSel({ parts: new Set([partId]), wires: new Set() }) + + const grabbedPl = placementOf(partId) + if (!grabbedPl) return + const r = innerRef.current!.getBoundingClientRect() + const orig = new Map() + for (const id of moveIds) { + const pl = doc.parts.find((p) => p.id === id)?.[view] + if (pl) orig.set(id, pl) + } + partDrag.current = { + ids: [...orig.keys()], + grabbed: partId, + offX: (e.clientX - r.left) / scale - grabbedPl.x, + offY: (e.clientY - r.top) / scale - grabbedPl.y, + orig, + frozen: collectFrozen(doc, new Set(orig.keys()), view), + moved: false + } + window.addEventListener('pointermove', onPartMove) + window.addEventListener('pointerup', onPartUp) + } + + const onPartMove = (e: PointerEvent): void => { + const d = partDrag.current + if (!d) return + const r = innerRef.current!.getBoundingClientRect() + const grabbedOrig = d.orig.get(d.grabbed)! + const part = doc.parts.find((p) => p.id === d.grabbed) + if (!part) return + const raw: Placement = { + ...grabbedOrig, + x: (e.clientX - r.left) / scale - d.offX, + y: (e.clientY - r.top) / scale - d.offY + } + const snapped = snapBB(part.type, raw, view) + const delta = { x: snapped.x - grabbedOrig.x, y: snapped.y - grabbedOrig.y } + if (delta.x !== 0 || delta.y !== 0) d.moved = true + if (!d.moved) return + + const placements = new Map() + for (const [id, pl] of d.orig) { + placements.set(id, { ...pl, x: pl.x + delta.x, y: pl.y + delta.y }) + } + setDragOverrides(placements) + + const reroutes = reroutesFor(doc, d.frozen, placements, delta, view) + const cmds = [...placements.entries()].map(([id, pl], i) => + cmd.placePart(id, view, pl, i === 0 ? reroutes : []) + ) + store.dispatch( + cmd.composite( + d.ids.length === 1 ? `Move ${d.grabbed}` : `Move ${d.ids.length} parts`, + cmds, + `move:${view}:${d.ids.slice().sort().join(',')}` + ) + ) + } + + const onPartUp = (e: PointerEvent): void => { + const d = partDrag.current + partDrag.current = null + setDragOverrides(null) + window.removeEventListener('pointermove', onPartMove) + window.removeEventListener('pointerup', onPartUp) + // a press on a breadboard that never displaced = a hole interaction + if (d && !d.moved && editable && view === 'bb') { + const part = doc.parts.find((p) => p.id === d.grabbed) + if (part && isBreadboard(part.type)) { + const cp = canvasPoint(e) + const hole = holeAt(doc, d.grabbed, cp.x, cp.y, 8 / scale) + if (hole) pinInteract(`${d.grabbed}:${hole.pin}`, hole.pos) + } + } + } + + // drag a part's title label (offset stored on the bb placement) + const onLabelDown = (e: React.PointerEvent, partId: string): void => { + if (!editable) return + e.stopPropagation() + setSel({ parts: new Set([partId]), wires: new Set() }) + const start = canvasPoint(e) + const pl = placementOf(partId) + if (!pl) return + const off = pl.labelOffset || [0, 0] + labelDrag.current = { id: partId, ox: off[0] - start.x, oy: off[1] - start.y } + const move = (ev: PointerEvent): void => { + const ld = labelDrag.current + if (!ld) return + const cur = doc.parts.find((p) => p.id === ld.id)?.[view] + if (!cur) return + const cp = canvasPoint(ev) + const offset: [number, number] = [Math.round(ld.ox + cp.x), Math.round(ld.oy + cp.y)] + store.dispatch(cmd.placePart(ld.id, view, { ...cur, labelOffset: offset }, [], true)) + } + const up = (): void => { + labelDrag.current = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // drag a bendable leg tip (LED/resistor class parts, breadboard view only — + // M2 leftover, spec §6.2/§7.4). `base` is the pin's leg offset at grab time + // ([0,0] if it was resting). Live-dispatches like onLabelDown (merged + // commands per move) so wires re-anchor to the tip in real time without a + // separate dragOverrides path — nothing else needs to know this isn't a + // normal edit. A real (>=3px) drag suppresses the trailing click so it + // doesn't also arm/complete a wire from this pin. + const beginLegDrag = ( + e: React.PointerEvent, + partId: string, + pin: string, + base: [number, number] + ): void => { + const startClient = { x: e.clientX, y: e.clientY } + let moved = false + const move = (ev: PointerEvent): void => { + const dxs = (ev.clientX - startClient.x) / scale + const dys = (ev.clientY - startClient.y) / scale + if (!moved && Math.hypot(dxs, dys) < 3) return + moved = true + const cur = doc.parts.find((p) => p.id === partId)?.[view] + if (!cur) return + // legOffset is added to the local pin coord BEFORE rotate/flip + // (core/geometry pinWorld) — invert that so dragging the tip on + // screen moves it the direction the user is actually dragging. + const rad = (-(cur.rotate ?? 0) * Math.PI) / 180 + let ldx = dxs * Math.cos(rad) - dys * Math.sin(rad) + let ldy = dxs * Math.sin(rad) + dys * Math.cos(rad) + if (cur.flip) ldx = -ldx + const next: [number, number] = [ + Math.round((base[0] + ldx) * 100) / 100, + Math.round((base[1] + ldy) * 100) / 100 + ] + store.dispatch( + cmd.placePart( + partId, + view, + { ...cur, legs: { ...(cur.legs ?? {}), [pin]: next } }, + [], + true + ) + ) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + if (moved) suppressClick.current = true + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // drag / select a schematic net label (moves its sch placement + reroutes wires) + const onNetLabelDown = (e: React.PointerEvent, id: string): void => { + if ((e.target as HTMLElement).closest('.pin-hit')) return + e.stopPropagation() + if (!editable) { + setSel({ parts: new Set(), wires: new Set(), labels: new Set([id]) }) + return + } + if (e.shiftKey) { + const labels = new Set(sel.labels ?? []) + if (labels.has(id)) labels.delete(id) + else labels.add(id) + setSel({ parts: new Set(), wires: new Set(), labels }) + return + } + setSel({ parts: new Set(), wires: new Set(), labels: new Set([id]) }) + const label = doc.netLabels?.find((l) => l.id === id) + if (!label) return + const start = canvasPoint(e) + netLabelDrag.current = { + id, + offX: start.x - label.sch.x, + offY: start.y - label.sch.y, + frozen: collectFrozen(doc, new Set([id]), 'sch'), + moved: false + } + const move = (ev: PointerEvent): void => { + const d = netLabelDrag.current + if (!d) return + const cur = doc.netLabels?.find((l) => l.id === d.id) + if (!cur) return + const cp = canvasPoint(ev) + const snapped = snapNetLabel(cur.kind, cur.name, { + ...cur.sch, + x: cp.x - d.offX, + y: cp.y - d.offY + }) + if (snapped.x !== cur.sch.x || snapped.y !== cur.sch.y) d.moved = true + const overrides = new Map([[d.id, snapped]]) + setDragOverrides(overrides) + const reroutes = reroutesFor(doc, d.frozen, overrides, { x: 0, y: 0 }, 'sch') + store.dispatch(cmd.moveNetLabel(d.id, snapped, reroutes, true)) + } + const up = (): void => { + netLabelDrag.current = null + setDragOverrides(null) + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + // ── wire drawing ──────────────────────────────────────────────────────────── + + const finalizeSegment = (target: Pt, clean: boolean): { pts: Pt[]; str: boolean } => { + const pts = [...armed!.points] + const last = pts[pts.length - 1] + const str = view === 'bb' && (armed!.straight || straight) // sch: orthogonal only + if (str) pts.push(target) + else pts.push(...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1)) + return { pts: simplifyWirePoints(pts), str } + } + + const commitWire = (from: WireEnd, to: WireEnd, pts: Pt[], str: boolean): void => { + store.dispatch( + cmd.addWire({ + from, + to, + view, + ...(view === 'bb' ? { color: wireColor } : {}), // schematic wires are ink + route: journeyFromPoints(pts, str) + }) + ) + } + + /** Shared pin gesture: arm on first pin, finalize on second (also used by + * breadboard holes, which have no per-pin hit divs). */ + const pinInteract = (ref: string, pos: Pt): void => { + if (!armed) { + setArmed({ from: ref, points: [pos], straight: false }) + setSel(emptySel()) + return + } + if (armed.from === ref) { + setArmed(null) + return + } + const { pts, str } = finalizeSegment(pos, true) + commitWire(armed.from, ref, pts, str) + setArmed(null) + } + + const onPinClick = (e: React.MouseEvent, partId: string, pin: string): void => { + if (!editable) return + e.stopPropagation() + const ref = `${partId}:${pin}` + const pos = resolve(ref) + if (pos) pinInteract(ref, pos) + } + + // tap a junction onto an existing wire at the click point → {wire, t} + const tapJunction = (hit: WireHit): void => { + const host = geomForHit.find((g) => g.id === hit.id) + if (!host || !armed) return + const jp = clampOntoSegment(hit.p1, hit.p2, snap(mouse.x), snap(mouse.y)) + const t = tAtPoint(host.points, jp) + const { pts, str } = finalizeSegment(jp, false) + commitWire(armed.from, { wire: hit.id, t }, pts, str) + setArmed(null) + } + + const onCanvasClick = (e: React.MouseEvent): void => { + if (suppressClick.current) { + suppressClick.current = false + return + } + if (armed) { + const cp = canvasPoint(e) + const hit = hitWire(cp.x, cp.y, geomForHit, scale) + if (hit) { + setMouse(cp) + tapJunction(hit) + return + } + const target = { x: snap(cp.x), y: snap(cp.y) } + const last = armed.points[armed.points.length - 1] + const seg = straight + ? [last, target] + : calculateOrthogonalPath(last.x, last.y, target.x, target.y, false) + setArmed({ + ...armed, + points: [...armed.points, ...seg.slice(1)], + straight: armed.straight || straight + }) + return + } + if (!e.shiftKey) setSel(emptySel()) + } + + // ── selected-wire editing (segment / vertex / endpoint handles) ───────────── + + const resolveEndpointTarget = (wx: number, wy: number, selfId: string): WireEnd | null => { + const pin = pinAtWorld(doc, wx, wy, 8 / scale, view) + if (pin) return `${pin.id}:${pin.pin}` + const others = geomForHit.filter((g) => g.id !== selfId) + const hit = hitWire(wx, wy, others, scale) + if (hit) { + const host = others.find((g) => g.id === hit.id)! + const jp = clampOntoSegment(hit.p1, hit.p2, snap(wx), snap(wy)) + return { wire: hit.id, t: tAtPoint(host.points, jp) } + } + return null // v2 has no free-point endpoints — caller reverts + } + + const startHandleDrag = ( + e: React.PointerEvent, + kind: HandleKind, + idx: number, + end?: 0 | 1 + ): void => { + if (!editable || !soloWire || !editPts) return + e.stopPropagation() + const wireId = soloWire + const wire = doc.wires.find((w) => w.id === wireId) + if (!wire) return + const orig = editPts + const str = isStraightRoute(wire.route) + const axis: 'horizontal' | 'vertical' = + kind === 'edge' && Math.abs(orig[idx].y - orig[idx + 1].y) < 0.001 ? 'horizontal' : 'vertical' + const endIdx = end === 0 ? 0 : orig.length - 1 + let live = orig + let lastWorld = { x: 0, y: 0 } + handleDrag.current = { kind } + const move = (ev: PointerEvent): void => { + const cp = canvasPoint(ev) + if (kind === 'vertex') { + live = vertexDrag(orig, idx, snap(cp.x), snap(cp.y)) + } else if (kind === 'edge') { + live = dragSegment(orig, idx, axis, snap(cp.x), snap(cp.y)) + } else { + lastWorld = cp + const p = { x: snap(cp.x), y: snap(cp.y) } + live = orig.map((pt, k) => (k === endIdx ? p : pt)) + } + setEditPts(live) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + handleDrag.current = null + if (kind === 'endpoint') { + const tgt = resolveEndpointTarget(lastWorld.x, lastWorld.y, wireId) + const origEnd = end === 0 ? wire.from : wire.to + const finalEnd = tgt ?? origEnd // no valid target → endpoint stays put + const rp = resolve(finalEnd) + if (rp) live = live.map((pt, k) => (k === endIdx ? rp : pt)) + store.dispatch( + cmd.setWireEnds( + wireId, + end === 0 ? finalEnd : undefined, + end === 1 ? finalEnd : undefined, + journeyFromPoints(simplifyWirePoints(live), str) + ) + ) + } else { + store.dispatch(cmd.rerouteWire(wireId, journeyFromPoints(simplifyWirePoints(live), str))) + } + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + + const onWireDoubleClick = (e: React.MouseEvent, wireId: string): void => { + if (!editable) return + e.stopPropagation() + const g = wireGeom.find((x) => x.w.id === wireId) + if (!g || g.pts.length < 2) return + const str = isStraightRoute(g.w.route) + const cp = canvasPoint(e) + const handleR = 7 / scale + for (let k = 1; k < g.pts.length - 1; k++) { + if (Math.hypot(g.pts[k].x - cp.x, g.pts[k].y - cp.y) < handleR) { + const next = simplifyWirePoints(g.pts.filter((_, j) => j !== k)) + store.dispatch(cmd.rerouteWire(wireId, journeyFromPoints(next, str))) + setEditPts(next) + return + } + } + const hit = hitWire(cp.x, cp.y, [{ id: wireId, points: g.pts }], scale) + if (hit) { + const next = g.pts.slice() + next.splice(hit.segmentIndex + 1, 0, { x: snap(cp.x), y: snap(cp.y) }) + store.dispatch(cmd.rerouteWire(wireId, journeyFromPoints(next, str))) + setSel({ parts: new Set(), wires: new Set([wireId]) }) + setEditPts(next) + } + } + + const onWireClick = (e: React.MouseEvent, wireId: string): void => { + e.stopPropagation() + if (armed) { + const cp = canvasPoint(e) + setMouse(cp) + const hit = hitWire( + cp.x, + cp.y, + geomForHit.filter((g) => g.id === wireId), + scale + ) + if (hit) tapJunction(hit) + return + } + if (e.shiftKey && editable) { + const wires = new Set(sel.wires) + if (wires.has(wireId)) wires.delete(wireId) + else wires.add(wireId) + setSel({ parts: sel.parts, wires }) + return + } + setSel({ parts: new Set(), wires: new Set([wireId]) }) + } + + // ── background: pan / marquee ─────────────────────────────────────────────── + + const onViewPointerDown = (e: React.PointerEvent): void => { + const panButton = + e.button === 1 || (e.button === 0 && e.altKey) || (e.button === 0 && !editable) + if (panButton) { + e.preventDefault() + pan.current = { x: e.clientX, y: e.clientY } + const move = (ev: PointerEvent): void => { + const p = pan.current + if (!p) return + setCam((c) => ({ ...c, tx: c.tx + (ev.clientX - p.x), ty: c.ty + (ev.clientY - p.y) })) + pan.current = { x: ev.clientX, y: ev.clientY } + suppressClick.current = true + } + const up = (): void => { + pan.current = null + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + return + } + // left-drag on empty canvas in edit mode (not while drawing) = marquee. + // Parts/pins/wires/handles all stopPropagation, so reaching here on the + // view div (or the zero-size scene div) means the press hit empty canvas. + if (e.button === 0 && editable && !armed) { + const start = canvasPoint(e) + const additive = e.shiftKey + let rect: { a: Pt; b: Pt } | null = null + const move = (ev: PointerEvent): void => { + rect = { a: start, b: canvasPoint(ev) } + setMarquee(rect) + } + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + setMarquee(null) + if (!rect) return // plain click — onCanvasClick handles deselect + const x0 = Math.min(rect.a.x, rect.b.x) + const y0 = Math.min(rect.a.y, rect.b.y) + const x1 = Math.max(rect.a.x, rect.b.x) + const y1 = Math.max(rect.a.y, rect.b.y) + if (x1 - x0 < 3 && y1 - y0 < 3) return + suppressClick.current = true + const parts = new Set(additive ? sel.parts : []) + for (const part of doc.parts) { + const pl = part[view] + if (!pl) continue + const vis = visualFor(part.type, view) + if (!vis) continue + if (pl.x < x1 && pl.x + vis.v.w > x0 && pl.y < y1 && pl.y + vis.v.h > y0) + parts.add(part.id) + } + const wires = new Set(additive ? sel.wires : []) + for (const g of wireGeom) { + if (g.pts.some((p) => p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1)) wires.add(g.w.id) + } + const labels = new Set(additive ? (sel.labels ?? []) : []) + if (view === 'sch') { + for (const label of doc.netLabels ?? []) { + const pl = label.sch + const v = netLabelVisualOf(label) + if (pl.x < x1 && pl.x + v.w > x0 && pl.y < y1 && pl.y + v.h > y0) labels.add(label.id) + } + } + setSel({ parts, wires, labels }) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } + } + + const onCanvasMove = (e: React.PointerEvent): void => { + if (armed) setMouse(canvasPoint(e)) + } + + const onDrop = (e: React.DragEvent): void => { + e.preventDefault() + const files = Array.from(e.dataTransfer.files ?? []).filter((f) => /\.fzpz$/i.test(f.name)) + if (files.length && onImportFiles) { + onImportFiles(files, canvasPoint(e)) + return + } + const type = e.dataTransfer.getData('text/tinystudio-part') + if (type) { + onDropPart(type, canvasPoint(e)) + return + } + const nl = e.dataTransfer.getData('text/tinystudio-netlabel') + if (nl && view === 'sch') { + const idx = nl.indexOf(':') + onDropNetLabel(nl.slice(0, idx) as NetLabelKind, nl.slice(idx + 1), canvasPoint(e)) + } + } + + // ── keyboard: undo/redo, delete, rotate, nudge, clipboard, Esc, Shift ─────── + + /** + * Rotate a breadboard as a rigid assembly (bb only): the board, its seated + * parts, and the wires between them all turn 90 deg about the board's centre, + * so the whole layout is preserved rather than the wires rerouting. Seating is + * grid-aligned, so a 90 deg turn about centre maps holes->holes and pins re-seat. + */ + const rotateBoardAssembly = React.useCallback( + (boardId: string): void => { + const c = rotateBoardAssemblyCmd(doc, boardId, seats) + if (c) store.dispatch(c) + }, + [doc, store, seats] + ) + + const rotateSelection = React.useCallback((): void => { + const ids = [...sel.parts] + if (!ids.length) { + // schematic: R rotates selected net labels (90° steps, wires reroute) + if (view !== 'sch') return + const cmds: cmd.Command[] = [] + for (const id of sel.labels ?? []) { + const c = rotateNetLabelCmd(doc, id) + if (c) cmds.push(c) + } + if (cmds.length) + store.dispatch(cmds.length === 1 ? cmds[0] : cmd.composite('Rotate labels', cmds)) + return + } + // a lone breadboard rotates as a rigid assembly (carries seated parts + wires) + if ( + view === 'bb' && + ids.length === 1 && + isBreadboard(doc.parts.find((p) => p.id === ids[0])?.type ?? '') + ) { + rotateBoardAssembly(ids[0]) + return + } + const frozen = collectFrozen(doc, new Set(ids), view) + const placements = new Map() + const cmds: cmd.Command[] = [] + for (const id of ids) { + const cur = doc.parts.find((p) => p.id === id)?.[view] + if (!cur) continue + const next = (((((cur.rotate ?? 0) + 90) % 360) + 360) % 360) as 0 | 90 | 180 | 270 + placements.set(id, { ...cur, rotate: next || undefined }) + } + const reroutes = reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view) + let first = true + for (const [id, pl] of placements) { + cmds.push(cmd.placePart(id, view, pl, first ? reroutes : [])) + first = false + } + if (cmds.length) + store.dispatch( + cmd.composite(`Rotate ${ids.length > 1 ? `${ids.length} parts` : ids[0]}`, cmds) + ) + }, [doc, sel, store, view, rotateBoardAssembly]) + + const nudgeSelection = React.useCallback( + (dx: number, dy: number): void => { + const ids = [...sel.parts] + if (!ids.length) return + const frozen = collectFrozen(doc, new Set(ids), view) + const placements = new Map() + for (const id of ids) { + const cur = doc.parts.find((p) => p.id === id)?.[view] + if (cur) placements.set(id, { ...cur, x: cur.x + dx, y: cur.y + dy }) + } + const reroutes = reroutesFor(doc, frozen, placements, { x: dx, y: dy }, view) + let first = true + const cmds: cmd.Command[] = [] + for (const [id, pl] of placements) { + cmds.push(cmd.placePart(id, view, pl, first ? reroutes : [])) + first = false + } + if (cmds.length) + store.dispatch(cmd.composite('Nudge', cmds, `nudge:${ids.slice().sort().join(',')}`)) + }, + [doc, sel, store, view] + ) + + const deleteSelection = React.useCallback((): void => { + const cmds: cmd.Command[] = [] + if (sel.parts.size) cmds.push(cmd.deleteParts([...sel.parts])) + if (sel.wires.size) cmds.push(cmd.deleteWires([...sel.wires])) + if (sel.labels?.size) for (const id of sel.labels) cmds.push(cmd.deleteNetLabel(id)) + if (!cmds.length) return + store.dispatch(cmds.length === 1 ? cmds[0] : cmd.composite('Delete selection', cmds)) + setSel(emptySel()) + }, [sel, store, setSel]) + + const copySelection = React.useCallback(async (): Promise => { + const payload = buildClipboard(doc, sel.parts, sel.wires) + if (!payload) return false + try { + await navigator.clipboard.writeText(JSON.stringify(payload)) + return true + } catch { + return false + } + }, [doc, sel]) + + const pastePayload = React.useCallback( + (payload: NonNullable>): void => { + const { parts, wires, idMap } = materializePaste( + doc, + payload, + freePasteOffset(doc, payload.parts, view) + ) + if (!parts.length && !wires.length) return + store.dispatch( + cmd.composite(`Paste ${parts.length} part${parts.length === 1 ? '' : 's'}`, [ + ...parts.map((p) => cmd.addPart(p)), + ...wires.map((w) => cmd.addWire(w)) + ]) + ) + setSel({ parts: new Set(idMap.values()), wires: new Set(wires.map((w) => w.id)) }) + }, + [doc, store, setSel] + ) + + React.useEffect(() => { + const down = (e: KeyboardEvent): void => { + if (document.activeElement && /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) + return + // Space = straight-wire modifier (Shift is reserved for shift-select). + if (e.key === ' ') { + setStraight(true) + e.preventDefault() + return + } + if (e.key === 'Escape') { + setArmed(null) + setSel(emptySel()) + return + } + if (!editable) return + const mod = e.ctrlKey || e.metaKey + if (mod && (e.key === 'z' || e.key === 'Z')) { + e.preventDefault() + if (e.shiftKey) store.redo() + else store.undo() + return + } + if (mod && (e.key === 'y' || e.key === 'Y')) { + e.preventDefault() + store.redo() + return + } + if (mod && (e.key === 'c' || e.key === 'C')) { + void copySelection() + return + } + if (mod && (e.key === 'x' || e.key === 'X')) { + void copySelection().then((ok) => { + if (ok) deleteSelection() + }) + return + } + if (mod && (e.key === 'v' || e.key === 'V')) { + void navigator.clipboard.readText().then((text) => { + const payload = parseClipboard(text) + if (payload) pastePayload(payload) + }) + return + } + if (mod && (e.key === 'd' || e.key === 'D')) { + e.preventDefault() + const payload = buildClipboard(doc, sel.parts, sel.wires) + if (payload) pastePayload(payload) + return + } + if (e.key === 'r' || e.key === 'R') { + if (sel.parts.size) { + e.preventDefault() + rotateSelection() + } + return + } + if ((e.key === 'f' || e.key === 'F') && view === 'sch' && sel.parts.size) { + // horizontal mirror — schematic only (spec §6.3 / B13) + e.preventDefault() + const ids = [...sel.parts] + const frozen = collectFrozen(doc, new Set(ids), view) + const placements = new Map() + for (const id of ids) { + const cur = doc.parts.find((p) => p.id === id)?.[view] + if (cur) placements.set(id, { ...cur, flip: cur.flip ? undefined : true }) + } + const reroutes = reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view) + let first = true + const cmds: cmd.Command[] = [] + for (const [id, pl] of placements) { + cmds.push(cmd.placePart(id, view, pl, first ? reroutes : [])) + first = false + } + if (cmds.length) store.dispatch(cmd.composite('Flip', cmds)) + return + } + if (e.key === 'Delete' || e.key === 'Backspace') { + deleteSelection() + return + } + if (e.key.startsWith('Arrow') && sel.parts.size) { + e.preventDefault() + const step = (view === 'sch' ? GRID_SCH : GRID_BB) * (e.shiftKey ? 5 : 1) + const d = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, -step], + ArrowDown: [0, step] + }[e.key] + if (d) nudgeSelection(d[0], d[1]) + } + } + const up = (e: KeyboardEvent): void => { + if (e.key === ' ') setStraight(false) + } + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + } + }, [ + editable, + sel, + doc, + store, + setSel, + view, + copySelection, + pastePayload, + deleteSelection, + rotateSelection, + nudgeSelection + ]) + + // ── derived render bits ───────────────────────────────────────────────────── + + const previewPts = React.useMemo((): Pt[] | null => { + if (!armed) return null + const last = armed.points[armed.points.length - 1] + let target = { x: snap(mouse.x), y: snap(mouse.y) } + let clean = false + if (hoverPin) { + const pos = resolve(`${hoverPin.id}:${hoverPin.pin}`) + if (pos) { + target = pos + clean = true + } + } else if (hoverHole) { + target = hoverHole.pos + clean = true + } + if (armed.straight || straight) return [...armed.points, target] + return [ + ...armed.points, + ...calculateOrthogonalPath(last.x, last.y, target.x, target.y, clean).slice(1) + ] + }, [armed, mouse, hoverPin, hoverHole, straight, resolve]) + + // junction solder dots (junction endpoints take the host wire's color) + const junctionDots = React.useMemo(() => { + const out: { key: string; pt: Pt; color: string }[] = [] + const fallback = view === 'sch' ? ink : '#2fa46a' + const colorOf = new Map(doc.wires.map((w) => [w.id, w.color || fallback])) + doc.wires + .filter((w) => w.view === view) + .forEach((w) => + [w.from, w.to].forEach((end, i) => { + if (!isJunction(end)) return + const p = resolve(end) + if (!p) return + const color = isPendingJunction(end) + ? w.color || fallback + : colorOf.get(end.wire) || w.color || fallback + out.push({ key: `${w.id}:${i}`, pt: p, color }) + }) + ) + return out + }, [doc, resolve]) + + // Contextual hint only while actively wiring/editing — no idle 'scroll to + // zoom' / 'view-only' bubbles cluttering the canvas. + const hint: { icon: React.JSX.Element | null; text: string } | null = + editable && armed + ? { + icon: , + text: `Click a pin or wire to connect · click for a bend${view === 'bb' ? ' · hold Space for straight' : ''} · Esc to cancel` + } + : editable && soloWire + ? { + icon: null, + text: 'Drag a handle to reshape · double-click to add/remove a bend · Del to remove' + } + : null + + return ( +
+
{ + const r = viewRef.current!.getBoundingClientRect() + zoomAt(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - r.left, e.clientY - r.top) + }} + onPointerDown={onViewPointerDown} + onPointerMove={onCanvasMove} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={onDrop} + onClick={onCanvasClick} + > +
+ {/* wires */} + + {/* ratsnest: connected elsewhere, not yet drawn here (spec §8.2) */} + {rats.map((r, i) => ( + + ))} + {wireGeom.map(({ w, pts: raw }) => { + const pts = soloWire === w.id && editPts ? editPts : raw + if (pts.length < 2) return null + const selected = sel.wires.has(w.id) + const onHotNet = highlightNet >= 0 && netModel.wireToNet.get(w.id) === highlightNet + const core = view === 'sch' ? ink : w.color || '#2fa46a' + const outline = view === 'sch' ? 'rgba(0,0,0,0.45)' : darken(w.color || '#2fa46a') + // schematic ink is a single thin stroke — no color outline, no glow. + const coreW = view === 'sch' ? WIRE_SCH_W : WIRE_W + const d = roundedPath(pts) + return ( + setHoverWire(w.id)} + onPointerLeave={() => setHoverWire((h) => (h === w.id ? null : h))} + onClick={(e) => onWireClick(e, w.id)} + onDoubleClick={(e) => onWireDoubleClick(e, w.id)} + > + {/* transparent fat stroke keeps thin schematic lines clickable */} + + {onHotNet && ( + + )} + {view !== 'sch' && ( + + )} + + {selected && ( + + + + )} + + ) + })} + + {junctionDots.map((j) => ( + + ))} + + {previewPts && ( + + )} + + {/* handles on the solo-selected wire */} + {editable && soloWire && editPts && ( + + {editPts.slice(0, -1).map((p, i) => { + const q = editPts[i + 1] + const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } + const horizontal = Math.abs(p.y - q.y) < 0.001 + return ( + startHandleDrag(e, 'edge', i)} + /> + ) + })} + {editPts.slice(1, -1).map((p, i) => ( + startHandleDrag(e, 'vertex', i + 1)} + /> + ))} + {[0, 1].map((end) => { + const p = end === 0 ? editPts[0] : editPts[editPts.length - 1] + return ( + startHandleDrag(e, 'endpoint', 0, end as 0 | 1)} + /> + ) + })} + + )} + + {/* drop-to-connect seat marks (green) + hovered hole */} + {editable && + view === 'bb' && + seats.map((s) => ( + + ))} + {hoverHole && view === 'bb' && ( + + )} + + {/* marquee */} + {marquee && ( + + )} + + + {/* parts */} + {doc.parts.map((part) => { + const pl = placementOf(part.id) + if (!pl) return null + // breadboards are transparent on the schematic — their row/rail + // buses still merge nets globally, but they render as no part here. + if (view === 'sch' && isBreadboard(part.type)) return null + const vis = visualFor(part.type, view) + if (!vis) { + return ( +
+ {part.type}… +
+ ) + } + const selected = sel.parts.has(part.id) + const labelOff = pl.labelOffset || [0, 0] + return ( +
onPartDown(e, part.id)} + onDoubleClick={(e) => { + e.stopPropagation() + if (!editable) onRequestEdit() + }} + onPointerMove={(e) => { + if (!editable || view !== 'bb' || !isBreadboard(part.type) || partDrag.current) + return + const cp = canvasPoint(e) + const hole = holeAt(doc, part.id, cp.x, cp.y, 6 / Math.min(scale, 1)) + setHoverHole(hole ? { id: part.id, pin: hole.pin, pos: hole.pos } : null) + }} + onPointerLeave={() => setHoverHole((h) => (h?.id === part.id ? null : h))} + onClick={(e) => e.stopPropagation()} + onContextMenu={(e) => { + if (!editable) return + e.preventDefault() + // breadboards rotate as a rigid assembly (seated parts + wires + // turn with the board); other parts rotate in place. + if (view === 'bb' && isBreadboard(part.type)) { + rotateBoardAssembly(part.id) + return + } + const cur = doc.parts.find((p) => p.id === part.id)?.[view] + if (!cur) return + const next = (((((cur.rotate ?? 0) + 90) % 360) + 360) % 360) as + | 0 + | 90 + | 180 + | 270 + const frozen = collectFrozen(doc, new Set([part.id]), view) + const placements = new Map([[part.id, { ...cur, rotate: next || undefined }]]) + store.dispatch( + cmd.placePart( + part.id, + view, + placements.get(part.id), + reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view) + ) + ) + }} + > +
+ {editable && + Object.keys(vis.v.pins).length <= 60 && + Object.keys(vis.v.pins).map((pin) => { + const [restX, restY] = vis.v.pins[pin] + const isLeg = view === 'bb' && (vis.v.legs?.includes(pin) ?? false) + const legOff = isLeg ? pl.legs?.[pin] : undefined + const px = restX + (legOff?.[0] ?? 0) + const py = restY + (legOff?.[1] ?? 0) + const armedHere = armed?.from === `${part.id}:${pin}` + const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin + const netIdx = netModel.pinToNet.get(`${part.id}:${pin}`) + const onHotNet = highlightNet >= 0 && netIdx === highlightNet + // a pin sharing a net with anything else is connected — its + // "open lead" dot disappears (still clickable to re-wire). + const connected = + netIdx !== undefined && (netModel.nets[netIdx]?.length ?? 0) >= 2 + const on = armedHere || hovered + const showDot = on || onHotNet || !connected + return ( +
{ + e.stopPropagation() + if (editable && isLeg && !armed) + beginLegDrag(e, part.id, pin, legOff ?? [0, 0]) + }} + onDoubleClick={(e) => { + if (!editable || !isLeg || !legOff) return + e.stopPropagation() + const cur = doc.parts.find((p) => p.id === part.id)?.[view] + if (!cur?.legs?.[pin]) return + const legs = { ...cur.legs } + delete legs[pin] + store.dispatch( + cmd.placePart(part.id, view, { + ...cur, + legs: Object.keys(legs).length ? legs : undefined + }) + ) + }} + onPointerEnter={() => setHoverPin({ id: part.id, pin })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) + } + onClick={(e) => { + if (suppressClick.current) { + suppressClick.current = false + e.stopPropagation() + return + } + onPinClick(e, part.id, pin) + }} + > + + {onHotNet && !on && ( + + )} + {showDot && ( + + )} + +
+ ) + })} + {view === 'bb' && vis.v.legs && vis.v.legs.length > 0 && ( + // bent-leg indicator: a simple ink line from the rest pin to + // the dragged tip, overlaid on the (static) Fritzing art — + // real per-instance leg-path warping is future work. + + {vis.v.legs.map((pin) => { + const off = pl.legs?.[pin] + if (!off || (off[0] === 0 && off[1] === 0)) return null + const [rx, ry] = vis.v.pins[pin] + return ( + + ) + })} + + )} +
onLabelDown(e, part.id)} + > + {String(part.attrs?.label ?? part.id)} +
+
+ ) + })} + + {/* schematic net labels (GND / power / named) */} + {view === 'sch' && + (doc.netLabels ?? []).map((label) => { + const pl = dragOverrides?.get(label.id) ?? label.sch + const v = netLabelVisualOf(label) + const selected = sel.labels?.has(label.id) ?? false + const [px, py] = v.pins['1'] + const armedHere = armed?.from === `${label.id}:1` + const hovered = hoverPin?.id === label.id && hoverPin?.pin === '1' + const netIdx = netModel.pinToNet.get(`${label.id}:1`) + const connected = netIdx !== undefined && (netModel.nets[netIdx]?.length ?? 0) >= 2 + const on = armedHere || hovered + const showDot = on || !connected + return ( +
onNetLabelDown(e, label.id)} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => { + e.stopPropagation() + if (!editable) onRequestEdit() + }} + > +
+ {editable && ( +
e.stopPropagation()} + onPointerEnter={() => setHoverPin({ id: label.id, pin: '1' })} + onPointerLeave={() => + setHoverPin((h) => (h?.id === label.id && h?.pin === '1' ? null : h)) + } + onClick={(e) => onPinClick(e, label.id, '1')} + > + + {showDot && ( + + )} + +
+ )} +
+ ) + })} + + {/* hole tooltip: name + net members */} + {hoverHole && ( +
+ {hoverHole.pin} + {(() => { + const idx = netModel.pinToNet.get(`${hoverHole.id}:${hoverHole.pin}`) + if (idx == null) return '' + const v = simVoltageForNet?.(idx) + return ` · net: ${describeNet(netModel, idx)}${v ? ` · ${v}` : ''}` + })()} +
+ )} + + {/* sim annotations (node voltages after a DC run) */} + {annotations?.map((a, i) => ( +
+ {a.text} +
+ ))} + + {doc.parts.length === 0 && ( +
+ +
+ {editable + ? 'Drag parts from the components rail — or double-click one to drop it here.' + : 'Empty circuit — click edit to start placing parts.'} +
+
+ )} +
+
+ + {/* transient action hint (only while wiring/reshaping) */} + {hint && ( +
+ + {hint.icon} {hint.text} + +
+ )} +
+ ) +} + +export type { Armed } diff --git a/src/renderer/src/circuit/views/exportImage.ts b/src/renderer/src/circuit/views/exportImage.ts new file mode 100644 index 0000000..3d83fb4 --- /dev/null +++ b/src/renderer/src/circuit/views/exportImage.ts @@ -0,0 +1,200 @@ +/** + * circuit/views/exportImage — compose a scene (breadboard OR schematic) into a + * standalone SVG and download it as .svg or rasterized .png @2×. Part SVG ids + * are namespaced per instance (fixes B6). Schematic wires are single ink + * strokes; net labels are rendered from their glyphs. + */ + +import { isBreadboard } from '../parts/breadboard' +import { netLabelVisualOf } from '../parts/netLabels' +import { escapeXml, namespaceSvgIds, prepareSvgForEmbed, svgNs } from '../parts/svg' +import { isJunction, type CircuitDoc, type ViewId } from '../core/model' +import { makeEndResolver, partArtFor, viewBounds, visualFor, wireGeometry } from './partsAdapter' + +const WIRE_W = 2.8 +const WIRE_OUTLINE_W = WIRE_W + 1.8 +const WIRE_SCH_W = 1 +const INK = 'var(--text-strong)' + +function darken(hex: string, f = 0.55): string { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) + if (!m) return 'rgba(0,0,0,0.45)' + const n = parseInt(m[1], 16) + return `rgb(${Math.round(((n >> 16) & 255) * f)}, ${Math.round(((n >> 8) & 255) * f)}, ${Math.round((n & 255) * f)})` +} + +function roundedPath(points: { x: number; y: number }[], r = 4): string { + if (points.length < 2) return '' + if (points.length === 2) return `M${points[0].x} ${points[0].y} L${points[1].x} ${points[1].y}` + let d = `M${points[0].x} ${points[0].y}` + for (let i = 1; i < points.length - 1; i++) { + const p = points[i - 1] + const c = points[i] + const n = points[i + 1] + const d1 = Math.hypot(c.x - p.x, c.y - p.y) || 1 + const d2 = Math.hypot(n.x - c.x, n.y - c.y) || 1 + const rr = Math.max(0, Math.min(r, d1 / 2, d2 / 2)) + const a = { x: c.x - ((c.x - p.x) / d1) * rr, y: c.y - ((c.y - p.y) / d1) * rr } + const b = { x: c.x + ((n.x - c.x) / d2) * rr, y: c.y + ((n.y - c.y) / d2) * rr } + d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}` + } + const last = points[points.length - 1] + d += ` L${last.x} ${last.y}` + return d +} + +/** Embed one part/label svg at a placement, ids namespaced, rotate+flip applied. */ +function embed( + svg: string, + ns: string, + x: number, + y: number, + w: number, + h: number, + rotate?: number, + flip?: boolean +): string { + const inner = prepareSvgForEmbed(namespaceSvgIds(svg, svgNs(ns))).replace( + '${inner}` : `${inner}` +} + +/** Build the standalone scene SVG for a view (exported for tests; default bb). */ +export function composeSceneSvg(doc: CircuitDoc, bg: string, view: ViewId = 'bb'): string | null { + const bounds = viewBounds(doc, view) + if (!bounds) return null + const sch = view === 'sch' + const pad = 36 + const minX = bounds.minX - pad + const minY = bounds.minY - pad + const maxX = bounds.maxX + pad + 60 + const maxY = bounds.maxY + pad + 48 // room for the larger watermark + const W = Math.round(maxX - minX) + const H = Math.round(maxY - minY) + + const wires = wireGeometry(doc, view) + .map(({ w, pts }) => { + if (pts.length < 2) return '' + const d = roundedPath(pts) + if (sch) + return `` + const core = w.color || '#2fa46a' + return `` + }) + .join('') + + const resolve = makeEndResolver(doc, undefined, view) + const dots = doc.wires + .filter((w) => w.view === view) + .flatMap((w) => + [w.from, w.to].filter(isJunction).map((j) => { + const p = resolve(j) + return p + ? `` + : '' + }) + ) + .join('') + + const partSvg = (part: (typeof doc.parts)[number]): string => { + const pl = part[view] + if (!pl) return '' + if (sch && isBreadboard(part.type)) return '' // breadboards are transparent in sch + const vis = visualFor(part.type, view) + if (!vis) return '' + const art = partArtFor(part, vis, view) + const g = embed(art, part.id, pl.x, pl.y, vis.v.w, vis.v.h, pl.rotate, sch && pl.flip) + const off = pl.labelOffset || [0, 0] + const labelText = String(part.attrs?.label ?? part.id) + const label = `${escapeXml(labelText)}` + return `${g}${label}` + } + // boards paint first — under the wires and every other part + const boardsSvg = doc.parts.filter((p) => isBreadboard(p.type)).map(partSvg).join('') + const partsSvg = doc.parts.filter((p) => !isBreadboard(p.type)).map(partSvg).join('') + + const labelsSvg = sch + ? (doc.netLabels ?? []) + .map((label) => { + const v = netLabelVisualOf(label) + return embed(v.svg, label.id, label.sch.x, label.sch.y, v.w, v.h, label.sch.rotate) + }) + .join('') + : '' + + const watermark = `tinyStudio` + return `${boardsSvg}${wires}${dots}${partsSvg}${labelsSvg}${watermark}` +} + +/** + * Inline every var(--token) with its computed value — builtin art and schematic + * ink use design-system variables that only resolve inside the app stylesheet. + */ +function resolveCssVars(svg: string): string { + const cs = getComputedStyle(document.documentElement) + return svg.replace(/var\((--[A-Za-z0-9_-]+)\)/g, (all, name) => { + const v = cs.getPropertyValue(name).trim() + return v || all + }) +} + +function download(blob: Blob, name: string): void { + const a = document.createElement('a') + const href = URL.createObjectURL(blob) + a.href = href + a.download = name + a.click() + setTimeout(() => URL.revokeObjectURL(href), 1000) +} + +function bgFor(view: ViewId): string { + const token = view === 'sch' ? '--bg' : '--bg-sunken' + return (getComputedStyle(document.documentElement).getPropertyValue(token) || '#1e1f22').trim() +} + +export function exportSvg(doc: CircuitDoc, view: ViewId = 'bb'): void { + const svg = composeSceneSvg(doc, bgFor(view), view) + if (!svg) return + const name = view === 'sch' ? 'circuit-schematic.svg' : 'circuit.svg' + download(new Blob([resolveCssVars(svg)], { type: 'image/svg+xml;charset=utf-8' }), name) +} + +export function exportPng(doc: CircuitDoc, view: ViewId = 'bb'): void { + const raw = composeSceneSvg(doc, bgFor(view), view) + if (!raw) return + const svg = resolveCssVars(raw) + const m = /width="(\d+)" height="(\d+)"/.exec(svg) + const W = m ? parseInt(m[1], 10) : 800 + const H = m ? parseInt(m[2], 10) : 600 + const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })) + const img = new Image() + img.onload = () => { + const sf = 2 + const canvas = document.createElement('canvas') + canvas.width = W * sf + canvas.height = H * sf + const ctx = canvas.getContext('2d') + if (!ctx) { + URL.revokeObjectURL(url) + return + } + ctx.scale(sf, sf) + ctx.drawImage(img, 0, 0) + URL.revokeObjectURL(url) + canvas.toBlob((png) => { + if (png) download(png, view === 'sch' ? 'circuit-schematic.png' : 'circuit.png') + }, 'image/png') + } + img.onerror = () => { + URL.revokeObjectURL(url) + console.error('circuit PNG export: SVG failed to rasterize') + } + img.src = url +} diff --git a/src/renderer/src/circuit/views/inspector/Inspector.tsx b/src/renderer/src/circuit/views/inspector/Inspector.tsx new file mode 100644 index 0000000..73103b6 --- /dev/null +++ b/src/renderer/src/circuit/views/inspector/Inspector.tsx @@ -0,0 +1,590 @@ +/** + * circuit/views/inspector/Inspector — right-rail editing for the selection. + * Part: refdes (rename with uniqueness check), label, location, rotation, + * typed attrs. Wire: color, net info. Multi-select: summary + delete. + * All edits dispatch Commands (undoable); moves/rotations reroute wires with + * frozen bends like canvas drags do. + */ + +import { CircuitBoard, FlipHorizontal2, Plus, RotateCw, Spline, Trash2 } from 'lucide-react' +import React from 'react' +import { getPart } from '../../../lib/partsLibrary' +import * as cmd from '../../core/commands' +import type { + CircuitDoc, + CircuitPart, + CircuitWire, + NetLabel, + NetLabelKind, + Placement, + ViewId +} from '../../core/model' +import type { NetModel } from '../../core/nets' +import { simAttrsFor } from '../../core/netlist' +import { isValidRefdes } from '../../core/refdes' +import type { CircuitStore } from '../../core/store' +import { isBreadboard } from '../../parts/breadboard' +import { + collectFrozen, + implicitSeats, + reroutesFor, + rotateBoardAssemblyCmd, + rotateNetLabelCmd +} from '../partsAdapter' +import { WIRE_COLORS } from '../palette/Palette' +import type { Selection } from '../canvas/Canvas' + +const field = + 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' +const rowLabel = 'text-[11px] text-text-muted' + +function titleCase(s: string): string { + return s + .toLowerCase() + .replace(/\b([a-z])/g, (m) => m.toUpperCase()) + .replace(/\bIc\b/, 'IC') +} + +export function InspectorRail({ + doc, + store, + sel, + setSel, + netModel, + view +}: { + doc: CircuitDoc + store: CircuitStore + sel: Selection + setSel: (s: Selection) => void + netModel: NetModel + view: ViewId +}): React.JSX.Element { + const part = + sel.parts.size === 1 && sel.wires.size === 0 + ? doc.parts.find((p) => p.id === [...sel.parts][0]) + : undefined + const wire = + sel.wires.size === 1 && sel.parts.size === 0 + ? doc.wires.find((w) => w.id === [...sel.wires][0]) + : undefined + const multi = sel.parts.size + sel.wires.size > 1 + const label = + sel.parts.size === 0 && sel.wires.size === 0 && (sel.labels?.size ?? 0) === 1 + ? doc.netLabels?.find((l) => l.id === [...(sel.labels ?? [])][0]) + : undefined + + return ( +
+
+ Inspector +
+
+ {part ? ( + + ) : wire ? ( + + ) : multi ? ( + + ) : label ? ( + + ) : ( +
+ Select a component to edit its refdes, location, rotation, and properties — or a wire to + recolor it. Shift-click or drag a marquee for multi-select. +
+ )} +
+
+ ) +} + +function PartInspector({ + part, + doc, + store, + setSel, + view +}: { + part: CircuitPart + doc: CircuitDoc + store: CircuitStore + setSel: (s: Selection) => void + view: ViewId +}): React.JSX.Element { + const def = getPart(part.type) + const [refdes, setRefdes] = React.useState(part.id) + const [newAttr, setNewAttr] = React.useState('') + React.useEffect(() => setRefdes(part.id), [part.id]) + + const refdesTaken = refdes !== part.id && doc.parts.some((p) => p.id === refdes) + const refdesBad = !isValidRefdes(refdes) || refdesTaken + + const commitRefdes = (): void => { + if (refdes === part.id) return + if (refdesBad) { + setRefdes(part.id) + return + } + store.dispatch(cmd.renamePart(part.id, refdes)) + setSel({ parts: new Set([refdes]), wires: new Set() }) + } + + const pv = part[view] + const moveTo = (x: number, y: number): void => { + if (!pv) return + const frozen = collectFrozen(doc, new Set([part.id]), view) + const pl: Placement = { ...pv, x, y } + const placements = new Map([[part.id, pl]]) + store.dispatch( + cmd.placePart( + part.id, + view, + pl, + reroutesFor(doc, frozen, placements, { x: x - pv.x, y: y - pv.y }, view) + ) + ) + } + + const setRotation = (deg: number): void => { + if (!pv) return + const next = (((deg % 360) + 360) % 360) as 0 | 90 | 180 | 270 + // Breadboards rotate as a rigid assembly (board + seated parts + wires + // between them) — same path as the canvas R / right-click gesture. + if (view === 'bb' && isBreadboard(part.type)) { + const steps = ((next - (pv.rotate ?? 0)) / 90 + 4) % 4 + const c = rotateBoardAssemblyCmd(doc, part.id, implicitSeats(doc), steps) + if (c) store.dispatch(c) + return + } + const frozen = collectFrozen(doc, new Set([part.id]), view) + const pl: Placement = { ...pv, rotate: next || undefined } + const placements = new Map([[part.id, pl]]) + store.dispatch( + cmd.placePart(part.id, view, pl, reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view)) + ) + } + + const toggleFlip = (): void => { + if (!pv) return + const frozen = collectFrozen(doc, new Set([part.id]), view) + const pl: Placement = { ...pv, flip: pv.flip ? undefined : true } + const placements = new Map([[part.id, pl]]) + store.dispatch( + cmd.placePart(part.id, view, pl, reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, view)) + ) + } + + const wiresTouching = doc.wires.filter((w) => + [w.from, w.to].some((e) => typeof e === 'string' && e.split(':')[0] === part.id) + ).length + + const simAttrs = simAttrsFor(part.type, def?.family) + const simKeys = new Set(simAttrs.map((a) => a.key)) + const propRows = Object.entries(part.attrs || {}).filter( + ([k]) => k !== 'label' && !simKeys.has(k) + ) + + return ( + <> +
+ + setRefdes(e.target.value)} + onBlur={commitRefdes} + onKeyDown={(e) => e.key === 'Enter' && commitRefdes()} + /> +
+
+ Label (display) + + store.dispatch(cmd.setPartAttr(part.id, 'label', e.target.value || undefined)) + } + /> +
+
+ Type + {part.type} + Family + {def?.family ? titleCase(def.family) : '—'} + Wires + {wiresTouching} +
+ + {pv && ( + <> +
+ + Location ({view === 'bb' ? 'breadboard' : 'schematic'}) + +
+ moveTo(parseFloat(e.target.value) || 0, pv.y)} + /> + moveTo(pv.x, parseFloat(e.target.value) || 0)} + /> +
+
+ +
+ Rotation +
+ + + {view === 'sch' && ( + + )} +
+
+ + )} + + {simAttrs.length > 0 && ( +
+ Simulation + {simAttrs.map((a) => ( +
+ + {a.label} + + + store.dispatch( + cmd.setPartAttr(part.id, a.key, e.target.value === '' ? undefined : e.target.value) + ) + } + /> + {a.hint && ( + {a.hint} + )} +
+ ))} +
+ )} + +
+ Properties + {propRows.length === 0 && ( +
+ No properties yet. Add one below — e.g. a resistor’s{' '} + value. +
+ )} + {propRows.map(([k, val]) => ( +
+ + {k} + + store.dispatch(cmd.setPartAttr(part.id, k, e.target.value))} + /> + +
+ ))} +
+ setNewAttr(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newAttr.trim()) { + store.dispatch(cmd.setPartAttr(part.id, newAttr.trim(), '')) + setNewAttr('') + } + }} + /> + +
+
+ + + + ) +} + +function WireInspector({ + wire, + store, + setSel, + netModel +}: { + wire: CircuitWire + store: CircuitStore + setSel: (s: Selection) => void + netModel: NetModel +}): React.JSX.Element { + const netIdx = netModel.wireToNet.get(wire.id) + const netPins = netIdx != null ? (netModel.nets[netIdx]?.length ?? 0) : 0 + const color = wire.color || '#2fa46a' + return ( + <> +
+ + Wire +
+
+ Color +
+ {WIRE_COLORS.map((c) => ( +
+
+
+ Net pins + {netPins} +
+ + + ) +} + +function MultiInspector({ + sel, + store, + setSel +}: { + sel: Selection + store: CircuitStore + setSel: (s: Selection) => void +}): React.JSX.Element { + return ( + <> +
+ {sel.parts.size} part{sel.parts.size === 1 ? '' : 's'} · {sel.wires.size} wire + {sel.wires.size === 1 ? '' : 's'} selected +
+
+ Drag to move together · R to rotate · arrows to nudge · Ctrl+C / Ctrl+D to copy or duplicate + · Del to remove. +
+ + + ) +} + +function LabelInspector({ + label, + doc, + store, + setSel +}: { + label: NetLabel + doc: CircuitDoc + store: CircuitStore + setSel: (s: Selection) => void +}): React.JSX.Element { + const [name, setName] = React.useState(label.name) + React.useEffect(() => setName(label.name), [label.id, label.name]) + + const commitName = (): void => { + const n = name.trim() + if (!n || n === label.name) { + setName(label.name) + return + } + store.dispatch(cmd.updateNetLabel(label.id, { name: n })) + } + + const setKind = (kind: NetLabelKind): void => { + const patch = kind === 'ground' ? { kind, name: 'GND' } : { kind } + store.dispatch(cmd.updateNetLabel(label.id, patch)) + } + + return ( +
+
+ + Net label +
+ + + +

+ Labels sharing a name join the same net — a clean way to wire power and ground without long + wires. +

+ +
+ ) +} diff --git a/src/renderer/src/circuit/views/packs/PackManager.tsx b/src/renderer/src/circuit/views/packs/PackManager.tsx new file mode 100644 index 0000000..ca2700b --- /dev/null +++ b/src/renderer/src/circuit/views/packs/PackManager.tsx @@ -0,0 +1,247 @@ +/** + * circuit/views/packs/PackManager — GitHub parts-pack installer UI (M2 + * leftover, spec §5.4 "Boards-Manager pattern"). See parts/packs.ts for the + * fetch/validate/install logic and docs/tinyparts-pack-setup.md for how to + * host an index + packs. + * + * Settings → Parts Libraries in spirit, but scoped into the Circuit view + * since that's the only place parts matter today: a list of index URLs + * (add/remove, persisted), each expanding to its packs with an + * Install/Update button and per-part progress + failure summary. + */ + +import { Download, Loader2, Package, Plus, RefreshCw, TriangleAlert, X } from 'lucide-react' +import React from 'react' +import { toast } from 'sonner' +import { + fetchIndex, + fetchManifest, + getIndexUrls, + getInstalledPacks, + installPack, + setIndexUrls, + type PackIndexEntry +} from '../../parts/packs' + +interface IndexState { + loading: boolean + error?: string + packs: PackIndexEntry[] +} + +export function PackManager({ + onClose, + onInstalled +}: { + onClose: () => void + /** a part landed in the registry — caller should re-resolve/refresh */ + onInstalled: () => void +}): React.JSX.Element { + const [urls, setUrls] = React.useState(() => getIndexUrls()) + const [newUrl, setNewUrl] = React.useState('') + const [entries, setEntries] = React.useState>({}) + const [installing, setInstalling] = React.useState(null) + const [progress, setProgress] = React.useState<{ done: number; total: number } | null>(null) + const [installed, setInstalled] = React.useState(() => getInstalledPacks()) + + const refreshIndex = React.useCallback((url: string) => { + setEntries((e) => ({ ...e, [url]: { loading: true, packs: e[url]?.packs ?? [] } })) + fetchIndex(url) + .then((idx) => setEntries((e) => ({ ...e, [url]: { loading: false, packs: idx.packs } }))) + .catch((err) => + setEntries((e) => ({ + ...e, + [url]: { loading: false, error: err instanceof Error ? err.message : String(err), packs: [] } + })) + ) + }, []) + + React.useEffect(() => { + for (const url of urls) refreshIndex(url) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const addUrl = (): void => { + const u = newUrl.trim() + if (!u || urls.includes(u)) return + const next = [...urls, u] + setUrls(next) + setIndexUrls(next) + setNewUrl('') + refreshIndex(u) + } + + const removeUrl = (u: string): void => { + const next = urls.filter((x) => x !== u) + setUrls(next) + setIndexUrls(next) + setEntries((e) => { + const n = { ...e } + delete n[u] + return n + }) + } + + const install = async (pack: PackIndexEntry): Promise => { + setInstalling(pack.id) + setProgress(null) + try { + const manifest = await fetchManifest(pack.url) + const res = await installPack(manifest, pack.url, (done, total) => setProgress({ done, total })) + setInstalled(getInstalledPacks()) + if (res.installed.length) onInstalled() + if (res.failed.length) { + toast.error(`${pack.name}: ${res.installed.length} installed, ${res.failed.length} failed`, { + description: res.failed + .slice(0, 4) + .map((f) => `${f.type}: ${f.error}`) + .join('\n') + }) + } else { + toast.success(`${pack.name} v${manifest.version} installed`, { + description: `${res.installed.length} part${res.installed.length === 1 ? '' : 's'}` + }) + } + } catch (err) { + toast.error(`Couldn't install ${pack.name}`, { + description: err instanceof Error ? err.message : String(err) + }) + } finally { + setInstalling(null) + setProgress(null) + } + } + + const field = + 'flex-1 bg-bg-sunken border border-border-default rounded px-2 py-1.5 text-xs text-text-strong outline-none focus:border-brand' + + return ( +
+
e.stopPropagation()} + > +
+ + Parts Packs + + install additional components from a GitHub-hosted index + +
+ +
+ +
+ setNewUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addUrl()} + /> + +
+ +
+ {urls.length === 0 && ( +
+ No index URLs configured. Add one above to browse installable parts packs. +
+ )} + {urls.map((url) => { + const state = entries[url] + return ( +
+
+ + {url} + + + +
+
+ {!state &&
loading…
} + {state?.error && ( +
+ + {state.error} +
+ )} + {state && !state.error && !state.loading && state.packs.length === 0 && ( +
no packs listed
+ )} + {state?.packs.map((pack) => { + const curVersion = installed[pack.id] + const isInstalling = installing === pack.id + const upToDate = curVersion === pack.version + return ( +
+
+
+ {pack.name} v{pack.version} +
+ {pack.description && ( +
{pack.description}
+ )} +
+ {curVersion && ( + + {upToDate ? 'installed' : `v${curVersion} installed`} + + )} + +
+ ) + })} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/renderer/src/circuit/views/palette/Palette.tsx b/src/renderer/src/circuit/views/palette/Palette.tsx new file mode 100644 index 0000000..6f022fc --- /dev/null +++ b/src/renderer/src/circuit/views/palette/Palette.tsx @@ -0,0 +1,179 @@ +/** + * circuit/views/palette/Palette — the Fritzing-style components rail (M1). + * Reads the LEGACY partsLibrary manifest (replaced by the pack registry in + * M2); drag a tile onto the canvas or double-click to drop it at centre. + */ + +import { ChevronDown, ChevronRight, CircuitBoard, Package, Pencil, Plus } from 'lucide-react' +import React from 'react' +import { getPart, partsByFamily, type PartMeta } from '../../../lib/partsLibrary' +import type { ViewId } from '../../core/model' +import { NET_LABEL_KINDS, netLabelView } from '../../parts/netLabels' +import { schematicVisual } from '../../parts/symbols' + +export const WIRE_COLORS = ['#2fa46a', '#e5544b', '#42a5f5', '#f3cb00', '#ffffff', '#79818c'] + +function iconFor(meta: PartMeta, view: ViewId): string | undefined { + if (view === 'sch') { + const def = getPart(meta.type) + if (def) return schematicVisual(def).svg + } + return meta.icon +} + +function titleCase(s: string): string { + return s + .toLowerCase() + .replace(/\b([a-z])/g, (m) => m.toUpperCase()) + .replace(/\bIc\b/, 'IC') +} + +function Thumb({ svg, size = 40 }: { svg?: string; size?: number }): React.JSX.Element { + if (!svg) + return ( +
+ +
+ ) + return ( +
+ ) +} + +export function Palette({ + view, + wireColor, + onPickColor, + onAdd, + onAddNetLabel, + onEditPart, + onNewPart, + onOpenPacks +}: { + view: ViewId + wireColor: string + onPickColor: (c: string) => void + onAdd: (type: string) => void + onAddNetLabel: (kind: string, name: string) => void + onEditPart: (type: string) => void + onNewPart: () => void + onOpenPacks: () => void +}): React.JSX.Element { + const [collapsed, setCollapsed] = React.useState>(new Set()) + const families = partsByFamily() + const isOpen = (fam: string): boolean => !collapsed.has(fam) + const toggleFam = (fam: string): void => + setCollapsed((s) => { + const n = new Set(s) + if (n.has(fam)) n.delete(fam) + else n.add(fam) + return n + }) + + return ( +
+
+ Components + + +
+
+ Wire + {WIRE_COLORS.map((c) => ( +
+
+ {view === 'sch' && ( +
+
Net Labels
+
+ {NET_LABEL_KINDS.map((k) => ( +
+ e.dataTransfer.setData('text/tinystudio-netlabel', `${k.kind}:${k.name}`) + } + onDoubleClick={() => onAddNetLabel(k.kind, k.name)} + className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" + title={`${k.label} — drag onto the schematic (or double-click)`} + > + +
+ {k.label} +
+
+ ))} +
+
+ )} + {families.map((group) => ( +
+ + {isOpen(group.family) && ( +
+ {group.parts.map((c: PartMeta) => ( +
e.dataTransfer.setData('text/tinystudio-part', c.type)} + onDoubleClick={() => onAdd(c.type)} + className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" + title={`${c.label} · ${c.pins} pins — drag onto the canvas (or double-click)`} + > + +
+ {c.label} +
+ +
+ ))} +
+ )} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/circuit/views/partsAdapter.ts b/src/renderer/src/circuit/views/partsAdapter.ts new file mode 100644 index 0000000..5a240e3 --- /dev/null +++ b/src/renderer/src/circuit/views/partsAdapter.ts @@ -0,0 +1,710 @@ +/** + * circuit/views/partsAdapter — geometry glue between the v2 document and the + * LEGACY partsLibrary (breadboard view only). Temporary by design: the M2 + * parts registry (`circuit/parts/registry.ts`) replaces every import of + * `lib/partsLibrary` in here without touching the canvas. + * + * Everything geometric the editor needs lives here so Canvas, Inspector and + * the image exporter share one implementation: + * - pin world positions (rotation/flip/leg aware, via core/geometry) + * - wire endpoint resolution (pins, junctions, pending v1 junctions) + * - rendered wire polylines + * - frozen-bend reroutes for part moves (the collectFrozen behavior) + */ + +import { getPart, viewFor, type PartDef, type PartView } from '../../lib/partsLibrary' +import { schematicVisual } from '../parts/symbols' +import { netLabelPinWorld, netLabelVisualOf, snapNetLabel } from '../parts/netLabels' +import { decorateResistor, hasResistorBands } from '../parts/resistorBands' +import { pinWorld, snapPlacementToPinGrid } from '../core/geometry' +import { + GRID_BB, + isJunction, + isPendingJunction, + splitPinRef, + type CircuitDoc, + type CircuitPart, + type CircuitWire, + type Placement, + type Pt, + type ViewId, + type WireEnd +} from '../core/model' +import { + bendsFromJourney, + buildWirePoints, + isStraightRoute, + journeyFromPoints, + pointAtT, + wirePoints +} from '../core/routing' +import { + composite, + moveNetLabel, + placePart, + type Command, + type WireReroute +} from '../core/commands' + +export interface PartVisual { + def: PartDef + v: PartView +} + +// Fritzing part legs are baked into the breadboard art as +// `` (grey). Recolor those leg +// strokes to ink so bendable legs read as black, matching the part body. +const legCache = new Map() +function blackenLegs(svg: string): string { + if (!svg.includes('leg"')) return svg + const hit = legCache.get(svg) + if (hit !== undefined) return hit + const out = svg.replace(/]*\bid="connector\d+leg"[^>]*>/gi, (m) => + m.replace(/stroke="#[0-9a-fA-F]{6}"/g, 'stroke="#1A1A1A"') + ) + legCache.set(svg, out) + return out +} + +/** Visual for a part type in a view, or null while its def is still loading. + * Schematic falls back to a generated IC-box symbol (spec §5.1). */ +export function visualFor(type: string, view: ViewId): PartVisual | null { + const def = getPart(type) + if (!def) return null + if (view === 'sch') return { def, v: schematicVisual(def) } + const v = viewFor(def, 'breadboard') + return v ? { def, v: { ...v, svg: blackenLegs(v.svg) } } : null +} + +/** Breadboard visual (bb-fixed callers: seats, breadboard holes, exports). */ +export function bbVisual(type: string): PartVisual | null { + return visualFor(type, 'bb') +} + +/** First pin's local coordinate (the snap-by-pin alignment reference). */ +export function firstPinLocal(type: string, view: ViewId = 'bb'): [number, number] | undefined { + const vis = visualFor(type, view) + if (!vis) return undefined + return Object.values(vis.v.pins)[0] +} + +/** Snap a placement so its PINS land on the 9.6 px major grid (both views — + * spec §4 pin-on-grid contract; Fritzing behavior). */ +export function snapBB(type: string, placement: Placement, view: ViewId = 'bb'): Placement { + const vis = visualFor(type, view) + if (!vis) + return { + ...placement, + x: Math.round(placement.x / GRID_BB) * GRID_BB, + y: Math.round(placement.y / GRID_BB) * GRID_BB + } + const { x, y } = snapPlacementToPinGrid( + placement, + firstPinLocal(type, view), + vis.v.w, + vis.v.h, + GRID_BB + ) + return { ...placement, x, y } +} + +/** World position of a part's pin in a view (leg-tip aware in bb). */ +export function pinWorldOf( + part: CircuitPart, + pin: string, + placement?: Placement, + view: ViewId = 'bb' +): Pt | null { + const pl = placement ?? part[view] + if (!pl) return null + const vis = visualFor(part.type, view) + const local = vis?.v.pins[pin] + if (!vis || !local) return null + return pinWorld(local, pl, vis.v.w, vis.v.h, view === 'bb' ? pl.legs?.[pin] : undefined) +} + +export type EndResolver = (end: WireEnd, seen?: Set) => Pt | null + +/** + * Endpoint resolver over a doc, with optional per-part placement overrides + * (used mid-drag so wires track the moving part before the command commits). + * Pending v1 junctions resolve to their raw coordinate. + */ +export function makeEndResolver( + doc: CircuitDoc, + overrides?: Map, + view: ViewId = 'bb' +): EndResolver { + const wireById = new Map(doc.wires.map((w) => [w.id, w])) + const resolve: EndResolver = (end, seen = new Set()) => { + if (isJunction(end)) { + if (isPendingJunction(end)) return { x: end.x, y: end.y } + if (seen.has(end.wire)) return null + seen.add(end.wire) + const host = wireById.get(end.wire) + if (!host) return null + const s = resolve(host.from, seen) + const t = resolve(host.to, seen) + if (!s || !t) return null + return pointAtT(wirePoints(s, t, host.route), end.t) + } + const { part: partId, pin } = splitPinRef(end) + const part = doc.parts.find((p) => p.id === partId) + if (!part) { + // net labels are wire-connectable in the schematic (their virtual pin is + // ":1"); resolve to the glyph's connection point. + const label = view === 'sch' ? doc.netLabels?.find((l) => l.id === partId) : undefined + return label ? netLabelPinWorld(label, overrides?.get(partId) ?? label.sch) : null + } + return pinWorldOf(part, pin, overrides?.get(partId) ?? part[view], view) + } + return resolve +} + +export interface ResolvedWire { + w: CircuitWire + pts: Pt[] +} + +/** Rendered polylines for every wire of a view (empty pts when unresolvable). */ +export function wireGeometry( + doc: CircuitDoc, + view: ViewId, + overrides?: Map +): ResolvedWire[] { + const resolve = makeEndResolver(doc, overrides, view) + return doc.wires + .filter((w) => w.view === view) + .map((w) => { + const s = resolve(w.from) + const t = resolve(w.to) + return { w, pts: s && t ? wirePoints(s, t, w.route) : [] } + }) +} + +/** Topmost pin within `tol` world px of a world point. */ +export function pinAtWorld( + doc: CircuitDoc, + wx: number, + wy: number, + tol: number, + view: ViewId = 'bb' +): { id: string; pin: string; pos: Pt } | null { + for (let i = doc.parts.length - 1; i >= 0; i--) { + const part = doc.parts[i] + if (!part[view]) continue + const vis = visualFor(part.type, view) + if (!vis) continue + for (const pin of Object.keys(vis.v.pins)) { + const pos = pinWorldOf(part, pin, undefined, view) + if (pos && Math.hypot(pos.x - wx, pos.y - wy) < tol) return { id: part.id, pin, pos } + } + } + return null +} + +/** Bounding box of everything placed in a view, or null when empty. */ +export function viewBounds( + doc: CircuitDoc, + view: ViewId = 'bb' +): { minX: number; minY: number; maxX: number; maxY: number } | null { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const part of doc.parts) { + const pl = part[view] + if (!pl) continue + const vis = visualFor(part.type, view) + if (!vis) continue + minX = Math.min(minX, pl.x) + minY = Math.min(minY, pl.y) + maxX = Math.max(maxX, pl.x + vis.v.w) + maxY = Math.max(maxY, pl.y + vis.v.h) + } + for (const { pts } of wireGeometry(doc, view)) { + for (const p of pts) { + minX = Math.min(minX, p.x) + minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x) + maxY = Math.max(maxY, p.y) + } + } + return isFinite(minX) ? { minX, minY, maxX, maxY } : null +} + +// ── frozen-bend part moves (the collectFrozen behavior, v2 flavor) ─────────── + +export interface FrozenWire { + id: string + bends: Pt[] + straight: boolean + /** Both endpoints sit on moved parts — bends translate with the move. */ + both: boolean +} + +/** Snapshot the wires touching any of `ids` before a move begins. */ +export function collectFrozen( + doc: CircuitDoc, + ids: Set, + view: ViewId = 'bb' +): FrozenWire[] { + const resolve = makeEndResolver(doc, undefined, view) + const touches = (e: WireEnd): boolean => typeof e === 'string' && ids.has(splitPinRef(e).part) + const out: FrozenWire[] = [] + for (const w of doc.wires) { + if (w.view !== view) continue + const f = touches(w.from) + const t = touches(w.to) + if (!f && !t) continue + const s = resolve(w.from) + const e = resolve(w.to) + if (!s || !e) continue + out.push({ + id: w.id, + bends: bendsFromJourney(w.route, s, e), + straight: isStraightRoute(w.route), + both: f && t + }) + } + return out +} + +/** + * New journeys for frozen wires given the moved parts' new placements. + * Single-anchored wires keep their bends fixed in world space; wires between + * two moved parts translate their bends by `delta`. + */ +export function reroutesFor( + doc: CircuitDoc, + frozen: FrozenWire[], + placements: Map, + delta: Pt, + view: ViewId = 'bb', + /** rigid-assembly moves (board rotation): applied to both-ends-moved wires' + * bends instead of the delta translation. */ + transformBoth?: (p: Pt) => Pt +): WireReroute[] { + const resolve = makeEndResolver(doc, placements, view) + const wireById = new Map(doc.wires.map((w) => [w.id, w])) + const out: WireReroute[] = [] + for (const f of frozen) { + const w = wireById.get(f.id) + if (!w) continue + const s = resolve(w.from) + const t = resolve(w.to) + if (!s || !t) continue + const bends = f.both + ? f.bends.map((b) => + transformBoth ? transformBoth(b) : { x: b.x + delta.x, y: b.y + delta.y } + ) + : f.bends + out.push({ + wireId: f.id, + route: journeyFromPoints(buildWirePoints(s, bends, t, f.straight), f.straight) + }) + } + return out +} + +/** bb-fixed aliases (exportImage & friends). */ +export function bbWireGeometry( + doc: CircuitDoc, + overrides?: Map +): ResolvedWire[] { + return wireGeometry(doc, 'bb', overrides) +} +export function bbBounds( + doc: CircuitDoc +): { minX: number; minY: number; maxX: number; maxY: number } | null { + return viewBounds(doc, 'bb') +} + +// ── M2: breadboard seating (drop-to-connect, derived — never stored) ───────── + +import { SpatialHash } from '../core/geometry' +import { breadboardBuses, isBreadboard } from '../parts/breadboard' + +/** Seat radius: half a hole pitch (spec §7.3). */ +const SEAT_RADIUS = GRID_BB / 2 + +/** buses resolver for buildNets — breadboards today, PartDef v2 packs later. */ +export function circuitBuses(type: string): string[][] | undefined { + return breadboardBuses(type) +} + +export interface Seat { + /** part pin ref sitting in the hole, e.g. "R1:2" */ + pin: string + /** breadboard hole pin ref, e.g. "BB1:e12" */ + hole: string + pos: Pt +} + +/** + * Derive implicit pin-in-hole connections: a part seats as a rigid body — + * ALL of its pins must land within SEAT_RADIUS of a breadboard hole at the + * part's current placement/rotation, or NONE of them seat (Fritzing rule: + * a component either fully plugs in or it's just resting on top). This + * avoids a part with one pin near a hole and another dangling off-grid + * reporting a single spurious connection while silently leaving the rest + * of its pins unconnected. Grid-snapped placement makes the common case an + * exact coordinate match. + */ +export function implicitSeats(doc: CircuitDoc): Seat[] { + const hash = new SpatialHash() + let holes = 0 + for (const part of doc.parts) { + if (!part.bb || !isBreadboard(part.type)) continue + const vis = bbVisual(part.type) + if (!vis) continue + for (const pin of Object.keys(vis.v.pins)) { + const p = pinWorldOf(part, pin) + if (p) { + hash.insert(p, `${part.id}:${pin}`) + holes++ + } + } + } + if (!holes) return [] + const seats: Seat[] = [] + for (const part of doc.parts) { + if (!part.bb || isBreadboard(part.type)) continue + const vis = bbVisual(part.type) + const pins = vis ? Object.keys(vis.v.pins) : [] + if (!pins.length) continue + const candidates: Seat[] = [] + let allSeated = true + for (const pin of pins) { + const p = pinWorldOf(part, pin) + const hit = p ? hash.nearest(p, SEAT_RADIUS) : null + if (!hit) { + allSeated = false + break + } + candidates.push({ pin: `${part.id}:${pin}`, hole: hit.v, pos: hit.p }) + } + if (allSeated) seats.push(...candidates) + } + return seats +} + +/** Ids of parts seated on `boardId` (sticky-board moves, spec §7.3). */ +export function seatedPartsOn(boardId: string, seats: Seat[]): string[] { + const out = new Set() + for (const s of seats) { + if (splitPinRef(s.hole).part === boardId) out.add(splitPinRef(s.pin).part) + } + return [...out] +} + +/** Nearest hole of a specific breadboard part to a world point. */ +export function holeAt( + doc: CircuitDoc, + boardId: string, + wx: number, + wy: number, + tol: number +): { pin: string; pos: Pt } | null { + const part = doc.parts.find((p) => p.id === boardId) + if (!part?.bb) return null + const vis = bbVisual(part.type) + if (!vis) return null + let best: { pin: string; pos: Pt; d: number } | null = null + for (const pin of Object.keys(vis.v.pins)) { + const p = pinWorldOf(part, pin) + if (!p) continue + const d = Math.hypot(p.x - wx, p.y - wy) + if (d < tol && (!best || d < best.d)) best = { pin, pos: p, d } + } + return best ? { pin: best.pin, pos: best.pos } : null +} + +// ── M3: ratsnest (dual-view contract, spec §8.2) ───────────────────────────── + +import { buildNets, type NetModel } from '../core/nets' + +export interface RatsnestSegment { + a: Pt + b: Pt + /** index into the GLOBAL net model (for status counting). */ + net: number +} + +/** + * Dashed helper lines for nets that are electrically connected (globally — + * either view, buses, seating) but not yet drawn in `view`: for each global + * net, group its placed pins by this-view-only connectivity, then greedily + * bridge groups between their nearest pins. + */ +export function ratsnest(doc: CircuitDoc, view: ViewId, global: NetModel): RatsnestSegment[] { + // this-view connectivity: only this view's wires (+ physical buses; bb also + // gets derived seating — a seated pin needs no wire) + const viewDoc: CircuitDoc = { ...doc, wires: doc.wires.filter((w) => w.view === view) } + const viewNets = buildNets(viewDoc, { + busesFor: circuitBuses, + implicit: + view === 'bb' ? implicitSeats(doc).map((s): [string, string] => [s.pin, s.hole]) : undefined + }) + + const out: RatsnestSegment[] = [] + global.nets.forEach((pins, netIdx) => { + if (pins.length < 2) return + // placed, resolvable pins grouped by view-local component + const groups = new Map() + for (const pin of pins) { + const { part: partId } = splitPinRef(pin) + const part = doc.parts.find((p) => p.id === partId) + if (!part || !part[view]) continue + const pos = pinWorldOf(part, splitPinRef(pin).pin, undefined, view) + if (!pos) continue + const comp = viewNets.pinToNet.has(pin) ? `n${viewNets.pinToNet.get(pin)}` : `solo:${pin}` + if (!groups.has(comp)) groups.set(comp, []) + groups.get(comp)!.push({ pin, pos }) + } + if (groups.size < 2) return + // greedy nearest-group bridging (MST-ish, fine for editor guidance) + const remaining = [...groups.values()] + const connected = [remaining.shift()!] + while (remaining.length) { + let best: { gi: number; a: Pt; b: Pt; d: number } | null = null + remaining.forEach((group, gi) => { + for (const g of group) + for (const c of connected.flat()) { + const d = Math.hypot(g.pos.x - c.pos.x, g.pos.y - c.pos.y) + if (!best || d < best.d) best = { gi, a: c.pos, b: g.pos, d } + } + }) + if (!best) break + const chosen = best as { gi: number; a: Pt; b: Pt; d: number } + out.push({ a: chosen.a, b: chosen.b, net: netIdx }) + connected.push(remaining.splice(chosen.gi, 1)[0]) + } + }) + return out +} + +// ── ERC (view-side floating-pin check; merged with core/erc net rules) ──────── + +import type { ErcIssue } from '../core/erc' + +/** + * Info-level floating-pin findings that need pin geometry (hence view-side). + * Only parts that are PARTIALLY connected are reported — a fully-unwired part + * is just not placed yet and would only add noise. + */ +export function ercFloatingPins(doc: CircuitDoc, net: NetModel, view: ViewId): ErcIssue[] { + const out: ErcIssue[] = [] + for (const part of doc.parts) { + const pl = part[view] + if (!pl || isBreadboard(part.type)) continue + const vis = visualFor(part.type, view) + if (!vis) continue + const floating: string[] = [] + let connected = 0 + for (const pin of Object.keys(vis.v.pins)) { + const idx = net.pinToNet.get(`${part.id}:${pin}`) + if (idx !== undefined && net.nets[idx].length >= 2) connected++ + else floating.push(pin) + } + if (connected === 0) continue + for (const pin of floating) { + out.push({ + id: `float:${part.id}:${pin}`, + severity: 'info', + message: `${part.id}.${pin} is unconnected.`, + ref: { part: part.id, pin } + }) + } + } + return out +} + +// ── collision-avoidance placement (parity with the pre-rewrite editor) ──────── + +interface Box { + x: number + y: number + w: number + h: number +} + +function overlaps(a: Box, b: Box, gap = 0): boolean { + return ( + a.x < b.x + b.w + gap && a.x + a.w + gap > b.x && a.y < b.y + b.h + gap && a.y + a.h + gap > b.y + ) +} + +/** AABBs of everything already on the canvas in this view: parts, net labels, + * and thin boxes along wire segments (so drops don't land on top of wires). */ +export function occupiedBoxes(doc: CircuitDoc, view: ViewId, exclude?: Set): Box[] { + const out: Box[] = [] + for (const part of doc.parts) { + if (exclude?.has(part.id)) continue + const pl = part[view] + if (!pl) continue + const vis = visualFor(part.type, view) + if (!vis) continue + out.push({ x: pl.x, y: pl.y, w: vis.v.w, h: vis.v.h }) + } + if (view === 'sch') { + for (const l of doc.netLabels ?? []) { + if (exclude?.has(l.id)) continue + const v = netLabelVisualOf(l) + out.push({ x: l.sch.x, y: l.sch.y, w: v.w, h: v.h }) + } + } + for (const { pts } of wireGeometry(doc, view)) { + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i] + const b = pts[i + 1] + const x = Math.min(a.x, b.x) + const y = Math.min(a.y, b.y) + out.push({ x, y, w: Math.abs(a.x - b.x) || 1, h: Math.abs(a.y - b.y) || 1 }) + } + } + return out +} + +/** Grid-snapped placement for a new `type` near `preferred` that clears the + * existing scene. Spirals outward on the major grid until a free slot is found. */ +export function findFreePlacement( + doc: CircuitDoc, + type: string, + view: ViewId, + preferred: Pt, + exclude?: Set +): Placement { + const vis = visualFor(type, view) + const w = vis?.v.w ?? 80 + const h = vis?.v.h ?? 40 + const boxes = occupiedBoxes(doc, view, exclude) + const base = snapBB( + type, + { x: Math.round(preferred.x - w / 2), y: Math.round(preferred.y - h / 2) }, + view + ) + const step = Math.max(GRID_BB, Math.ceil(Math.max(w, h) / GRID_BB / 2) * GRID_BB) + const gap = GRID_BB + for (let ring = 0; ring < 60; ring++) { + for (let dx = -ring; dx <= ring; dx++) { + for (let dy = -ring; dy <= ring; dy++) { + if (ring > 0 && Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue + const cand = { x: base.x + dx * step, y: base.y + dy * step } + const box = { x: cand.x, y: cand.y, w, h } + if (!boxes.some((b) => overlaps(box, b, gap))) return { ...base, x: cand.x, y: cand.y } + } + } + } + return base +} + +/** A paste offset (applied to every pasted placement) that clears the scene. */ +export function freePasteOffset( + doc: CircuitDoc, + parts: { type: string; bb?: Placement; sch?: Placement }[], + view: ViewId, + step = GRID_BB * 2 +): Pt { + const boxes = occupiedBoxes(doc, view) + const items: Box[] = [] + for (const p of parts) { + const pl = p[view] + if (!pl) continue + const vis = visualFor(p.type, view) + if (!vis) continue + items.push({ x: pl.x, y: pl.y, w: vis.v.w, h: vis.v.h }) + } + if (!items.length) return { x: step, y: step } + for (let k = 1; k <= 60; k++) { + const off = { x: step * k, y: step * k } + const moved = items.map((b) => ({ x: b.x + off.x, y: b.y + off.y, w: b.w, h: b.h })) + if (!moved.some((m) => boxes.some((b) => overlaps(m, b, GRID_BB)))) return off + } + return { x: step, y: step } +} + +// ── rigid rotations (shared by Canvas gestures and the Inspector) ──────────── + +/** + * Rigid 90°-step rotation of a breadboard assembly (bb view): the board, its + * seated parts, and the wires *between* seated parts all turn about the + * board's centre, so the layout is preserved instead of the wires rerouting. + * Grid-aligned seating means holes map to holes, so pins re-seat exactly. + * Returns one undoable composite Command, or null if the board is unknown. + */ +export function rotateBoardAssemblyCmd( + doc: CircuitDoc, + boardId: string, + seats: Seat[], + steps = 1 +): Command | null { + const n = ((steps % 4) + 4) % 4 + if (!n) return null + const board = doc.parts.find((p) => p.id === boardId) + const bpl = board?.bb + const bvis = board && visualFor(board.type, 'bb') + if (!board || !bpl || !bvis) return null + const c = { x: bpl.x + bvis.v.w / 2, y: bpl.y + bvis.v.h / 2 } + // 90° CW about c (matching transformLocalPoint's rotation sense), n times. + const rot90 = (p: Pt): Pt => ({ x: c.x - (p.y - c.y), y: c.y + (p.x - c.x) }) + const rot = (p: Pt): Pt => { + let q = p + for (let i = 0; i < n; i++) q = rot90(q) + return q + } + const ids = [boardId, ...seatedPartsOn(boardId, seats)] + const placements = new Map() + for (const id of ids) { + const part = doc.parts.find((p) => p.id === id) + const cur = part?.bb + const vis = part && visualFor(part.type, 'bb') + if (!part || !cur || !vis) continue + const nr = ((((cur.rotate ?? 0) + 90 * n) % 360) + 360) % 360 + const rotate = (nr || undefined) as Placement['rotate'] + if (id === boardId) { + placements.set(id, { ...cur, rotate }) + } else { + const nc = rot({ x: cur.x + vis.v.w / 2, y: cur.y + vis.v.h / 2 }) + placements.set(id, { ...cur, x: nc.x - vis.v.w / 2, y: nc.y - vis.v.h / 2, rotate }) + } + } + const frozen = collectFrozen(doc, new Set(ids), 'bb') + const reroutes = reroutesFor(doc, frozen, placements, { x: 0, y: 0 }, 'bb', rot) + const cmds: Command[] = [] + let first = true + for (const [id, pl] of placements) { + cmds.push(placePart(id, 'bb', pl, first ? reroutes : [])) + first = false + } + return cmds.length ? composite(`Rotate ${boardId}`, cmds) : null +} + +/** + * Rotate a schematic net label 90° about its centre, snapping its pin back to + * the major grid and rerouting attached wires (frozen bends). Returns an + * undoable Command, or null if the label is unknown. + */ +export function rotateNetLabelCmd(doc: CircuitDoc, labelId: string): Command | null { + const label = doc.netLabels?.find((l) => l.id === labelId) + if (!label) return null + const nr = ((((label.sch.rotate ?? 0) + 90) % 360) + 360) % 360 + const snapped = snapNetLabel(label.kind, label.name, { + ...label.sch, + rotate: (nr || undefined) as Placement['rotate'] + }) + const frozen = collectFrozen(doc, new Set([labelId]), 'sch') + const reroutes = reroutesFor(doc, frozen, new Map([[labelId, snapped]]), { x: 0, y: 0 }, 'sch') + return moveNetLabel(labelId, snapped, reroutes) +} + +/** + * Per-part art: the type-level visual, decorated with instance attrs. + * Today that's resistor color bands following `attrs.resistance` (bb art + * carries `band_*` ids); everything else renders the stock art. + */ +export function partArtFor(part: CircuitPart, vis: PartVisual, view: ViewId): string { + if (view === 'bb' && part.attrs?.resistance !== undefined && hasResistorBands(vis.v.svg)) + return decorateResistor(vis.v.svg, part.attrs.resistance) + return vis.v.svg +} diff --git a/src/renderer/src/circuit/views/sim/Plot.tsx b/src/renderer/src/circuit/views/sim/Plot.tsx new file mode 100644 index 0000000..b713646 --- /dev/null +++ b/src/renderer/src/circuit/views/sim/Plot.tsx @@ -0,0 +1,232 @@ +/** + * circuit/views/sim/Plot — uPlot-backed waveform display (M4, spec §10.4). + * + * One component for the three sweep shapes: + * - transient: x = time (linear) + * - dc sweep: x = the swept source voltage (`v(v-sweep)`) + * - ac: x = frequency (log), traces = magnitude in dB, plus dashed + * phase traces on a right-hand degree axis + * + * uPlot gives cursors, drag-zoom (double-click resets), and a legend with + * click-to-toggle series for free. Theme colors are read from the design + * tokens at mount. CSV export lives here too (visible vectors, full data). + */ + +import React from 'react' +import uPlot from 'uplot' +import 'uplot/dist/uPlot.min.css' +import type { SimRun, SimVector } from '../../sim' + +export const TRACES = ['#4f9cf9', '#f36e6e', '#54c08a', '#e5b567', '#b78be5', '#5bc8c8', '#e08fd0', '#9aa76b'] + +export type PlotMode = 'tran' | 'dc' | 'ac' + +function cssVar(name: string, fallback: string): string { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim() + return v || fallback +} + +const mag = (re: number, im: number): number => Math.sqrt(re * re + im * im) +const db = (m: number): number => 20 * Math.log10(Math.max(m, 1e-20)) +const deg = (re: number, im: number): number => (Math.atan2(im, re) * 180) / Math.PI + +interface Prepared { + data: uPlot.AlignedData + series: uPlot.Series[] + xLabel: string + isLog: boolean + hasPhase: boolean +} + +function prepare( + run: SimRun, + mode: PlotMode, + labelFor?: (name: string) => string | undefined +): Prepared | null { + const x = + mode === 'ac' + ? run.vectors.find((v) => v.type === 'frequency') + : mode === 'dc' + ? (run.vectors.find((v) => v.name.toLowerCase() === 'v(v-sweep)') ?? run.vectors[0]) + : (run.vectors.find((v) => v.type === 'time') ?? run.vectors[0]) + if (!x) return null + const ys = run.vectors + .filter( + (v) => + v !== x && + v.name.toLowerCase() !== 'v(v-sweep)' && + (v.name.startsWith('v(') || v.name.startsWith('vdiff(')) + ) + .slice(0, TRACES.length) + if (!ys.length) return null + + const series: uPlot.Series[] = [{ label: mode === 'ac' ? 'Hz' : mode === 'dc' ? 'Vsweep' : 's' }] + const cols: number[][] = [] + + const isAc = mode === 'ac' && ys.some((v) => v.imag) + for (let i = 0; i < ys.length; i++) { + const v = ys[i] + const name = labelFor?.(v.name) ?? v.name + if (isAc) { + cols.push(v.values.map((re, k) => db(mag(re, v.imag?.[k] ?? 0)))) + series.push({ + label: `${name} dB`, + stroke: TRACES[i], + width: 1.4, + scale: 'y', + value: (_u, val) => (val == null ? '' : `${val.toFixed(1)} dB`) + }) + } else { + cols.push(v.values) + series.push({ + label: name, + stroke: TRACES[i], + width: 1.4, + scale: 'y', + value: (_u, val) => (val == null ? '' : `${fmtEng(val)}V`) + }) + } + } + if (isAc) { + for (let i = 0; i < ys.length; i++) { + const v = ys[i] + const name = labelFor?.(v.name) ?? v.name + cols.push(v.values.map((re, k) => deg(re, v.imag?.[k] ?? 0))) + series.push({ + label: `${name} °`, + stroke: TRACES[i], + width: 1, + dash: [4, 4], + scale: 'deg', + value: (_u, val) => (val == null ? '' : `${val.toFixed(1)}°`) + }) + } + } + return { + data: [x.values, ...cols] as uPlot.AlignedData, + series, + xLabel: mode === 'ac' ? 'frequency (Hz)' : mode === 'dc' ? 'sweep (V)' : 'time (s)', + isLog: mode === 'ac', + hasPhase: isAc + } +} + +export function fmtEng(v: number): string { + const a = Math.abs(v) + if (a >= 1e6) return `${(v / 1e6).toFixed(2)}M` + if (a >= 1e3) return `${(v / 1e3).toFixed(2)}k` + if (a >= 1) return v.toFixed(2) + if (a >= 1e-3) return `${(v * 1e3).toFixed(2)}m` + if (a >= 1e-6) return `${(v * 1e6).toFixed(2)}µ` + if (a === 0) return '0' + return `${(v * 1e9).toFixed(2)}n` +} + +export function SimPlot({ + run, + mode, + labelFor +}: { + run: SimRun + mode: PlotMode + /** override a vector's displayed legend name (e.g. a probe's label) */ + labelFor?: (name: string) => string | undefined +}): React.JSX.Element { + const host = React.useRef(null) + const plot = React.useRef(null) + const prepared = React.useMemo(() => prepare(run, mode, labelFor), [run, mode, labelFor]) + + React.useEffect(() => { + const el = host.current + if (!el || !prepared) return + const axisInk = cssVar('--text-muted', '#9aa1ab') + const gridInk = cssVar('--border-default', '#2a2f37') + + const make = (width: number): uPlot => { + const axes: uPlot.Axis[] = [ + { + label: prepared.xLabel, + stroke: axisInk, + labelSize: 14, + grid: { stroke: gridInk, width: 0.5 }, + ticks: { stroke: gridInk }, + values: prepared.isLog + ? (_u, splits) => splits.map((s) => fmtEng(s)) + : (_u, splits) => splits.map((s) => fmtEng(s)) + }, + { + scale: 'y', + stroke: axisInk, + grid: { stroke: gridInk, width: 0.5 }, + ticks: { stroke: gridInk }, + values: (_u, splits) => splits.map((s) => fmtEng(s)) + } + ] + if (prepared.hasPhase) + axes.push({ + scale: 'deg', + side: 1, + stroke: axisInk, + grid: { show: false }, + ticks: { stroke: gridInk }, + values: (_u, splits) => splits.map((s) => `${s}°`) + }) + return new uPlot( + { + width, + height: 220, + series: prepared.series, + scales: { + x: prepared.isLog ? { distr: 3, log: 10 } : { time: false }, + y: {}, + ...(prepared.hasPhase ? { deg: {} } : {}) + }, + axes, + legend: { live: true }, + cursor: { drag: { x: true, y: false } } + }, + prepared.data, + el + ) + } + + plot.current = make(Math.max(el.clientWidth || 640, 320)) + const ro = new ResizeObserver(() => { + const w = Math.max(el.clientWidth || 640, 320) + plot.current?.setSize({ width: w, height: 220 }) + }) + ro.observe(el) + return () => { + ro.disconnect() + plot.current?.destroy() + plot.current = null + } + }, [prepared]) + + if (!prepared) return
no voltage vectors to plot
+ return ( +
+
+ + drag to zoom · double-click to reset · click legend entries to toggle traces + +
+ ) +} + +/** CSV of every vector in the run (x first), raw numbers. */ +export function runToCsv(run: SimRun): string { + const cols: { name: string; values: number[] }[] = [] + for (const v of run.vectors as SimVector[]) { + if (v.imag) { + cols.push({ name: `${v.name} (mag)`, values: v.values.map((re, k) => mag(re, v.imag![k])) }) + cols.push({ name: `${v.name} (deg)`, values: v.values.map((re, k) => deg(re, v.imag![k])) }) + } else { + cols.push({ name: v.name, values: v.values }) + } + } + const n = Math.max(...cols.map((c) => c.values.length)) + const lines = [cols.map((c) => JSON.stringify(c.name)).join(',')] + for (let i = 0; i < n; i++) lines.push(cols.map((c) => c.values[i] ?? '').join(',')) + return lines.join('\n') + '\n' +} diff --git a/src/renderer/src/circuit/views/sim/SimPanel.tsx b/src/renderer/src/circuit/views/sim/SimPanel.tsx new file mode 100644 index 0000000..b8d7794 --- /dev/null +++ b/src/renderer/src/circuit/views/sim/SimPanel.tsx @@ -0,0 +1,508 @@ +/** + * circuit/views/sim/SimPanel — the M4 Simulate panel (spec §10.4, first cut). + * + * Docked at the bottom of the circuit area: analysis tabs (DC op / DC sweep / + * Transient / AC), parameters, Run/Cancel, results. DC (.op) lists node + * voltages and source currents — and the shell mirrors them onto the canvas + * as annotations; sweeps render in a uPlot chart (cursors, drag-zoom, + * legend-toggle) with CSV export. + * + * Analysis config persists in doc.sim via Commands (undoable, serialized). + */ + +import { CircuitBoard, Download, Loader2, Play, Square, X } from 'lucide-react' +import React from 'react' +import * as cmd from '../../core/commands' +import type { Analysis, CircuitDoc } from '../../core/model' +import { describeNet, type NetModel } from '../../core/nets' +import { generateNetlist, mapSimIssues, type NetlistResult, type SimIssueRef } from '../../core/netlist' +import { diffProbeVectors, probeLabelFor } from '../../core/probes' +import type { CircuitStore } from '../../core/store' +import { getSimBackend, SimError } from '../../sim' +import type { SimRun } from '../../sim' +import { runToCsv, SimPlot, type PlotMode } from './Plot' + +const field = + 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-20 text-xs' + +export interface SimState { + run: SimRun | null + netlist: NetlistResult | null +} + +export function SimPanel({ + doc, + netModel, + store, + familyOf, + onClose, + onResult, + onSelectIssue +}: { + doc: CircuitDoc + netModel: NetModel + store: CircuitStore + familyOf: (type: string) => string | undefined + onClose: () => void + /** surfaces the run to the shell (canvas DC annotations) */ + onResult: (s: SimState) => void + /** clicking a part/net chip on an error asks the shell to select it */ + onSelectIssue?: (refs: SimIssueRef) => void +}): React.JSX.Element { + const analysis: Analysis = doc.sim?.analyses?.[0] ?? { id: 'a1', kind: 'op' } + const [running, setRunning] = React.useState(false) + const [result, setResult] = React.useState(null) + const [gen, setGen] = React.useState(null) + const [error, setError] = React.useState<{ message: string; details?: string[] } | null>(null) + const [showNetlist, setShowNetlist] = React.useState(false) + const [autoRerun, setAutoRerun] = React.useState(false) + + const setAnalysis = (patch: Partial): void => { + store.dispatch(cmd.setAnalyses([{ ...analysis, ...patch }])) + } + + // sweepable sources, named the way the netlist emits them (VV1, II1…) + const sources = React.useMemo( + () => + doc.parts + .map((p) => { + const key = `${p.type} ${familyOf(p.type) ?? ''}` + if (/sim-vdc|voltage source|battery|sim-vsin|sine|waveform/i.test(key)) return `V${p.id}` + if (/sim-idc|current source/i.test(key)) return `I${p.id}` + return null + }) + .filter((n): n is string => n !== null), + [doc.parts, familyOf] + ) + + const runningRef = React.useRef(false) + + const run = async (): Promise => { + if (runningRef.current) return // one in-flight run at a time (esp. for auto-rerun) + runningRef.current = true + setRunning(true) + setError(null) + const g = generateNetlist(doc, netModel, { familyOf, title: 'tinyStudio circuit' }) + setGen(g) + try { + const raw = await getSimBackend().run(g.netlist, 20000) + // fold in synthetic diff-probe vectors (voltage/current probes need no + // extra work — ngspice already reports every node and probe source) + const diffs = diffProbeVectors(doc, netModel, g, raw) + const r: SimRun = diffs.length ? { ...raw, vectors: [...raw.vectors, ...diffs] } : raw + setResult(r) + onResult({ run: r, netlist: g }) + } catch (err) { + setResult(null) + onResult({ run: null, netlist: g }) + setError( + err instanceof SimError + ? { message: err.message, details: err.details } + : { message: err instanceof Error ? err.message : String(err) } + ) + } finally { + runningRef.current = false + setRunning(false) + } + } + + const cancel = (): void => { + getSimBackend().cancel() + runningRef.current = false + setRunning(false) + } + + // auto-rerun (spec/M4 leftover): once enabled, every doc change re-runs the + // active analysis after a short debounce — same "Run" path, so results and + // canvas DC annotations refresh without a manual click. Skipped while a run + // is already in flight; the trailing edit still gets its own debounce timer. + const runRef = React.useRef(run) + runRef.current = run + const lastAutoDoc = React.useRef(doc) + React.useEffect(() => { + if (!autoRerun || doc === lastAutoDoc.current) return + lastAutoDoc.current = doc + const t = setTimeout(() => void runRef.current(), 400) + return () => clearTimeout(t) + }, [doc, autoRerun]) + + const isOp = result != null && result.numPoints === 1 + + // probe labels (M4 leftover): a placed sim-probe-v/-vdiff/-i part's + // attrs.label, if any, stands in for the raw v(node)/vdiff(id)/i(v) + // vector name in the table and plot legend. + const labelFor = React.useCallback( + (vecName: string): string | undefined => + gen ? probeLabelFor(vecName, doc, netModel, gen) : undefined, + [gen, doc, netModel] + ) + + // error → part/net highlight mapping (M4 leftover): scan the engine's raw + // message lines for the device/node names this run's netlist used, so the + // offending elements can be selected on the canvas straight from the error. + const issueRefs: SimIssueRef | null = React.useMemo(() => { + if (!error || !gen) return null + const lines = error.details?.length ? error.details : [error.message] + const r = mapSimIssues(lines, gen) + return r.parts.length || r.nets.length ? r : null + }, [error, gen]) + + // "v(n1)" → the net's members ("R1:Pin 1 · LED1:anode"); "i(vv1)" → source + const describe = React.useCallback( + (vecName: string): string | undefined => { + if (!gen) return undefined + const m = /^v\((.+)\)$/i.exec(vecName) + if (m) { + const node = m[1] + const i = gen.nodeOfNet.findIndex((n) => n.toLowerCase() === node.toLowerCase()) + if (i < 0) return undefined + const members = netModel.nets[i] ?? [] + return members.slice(0, 4).join(' · ') + (members.length > 4 ? ' …' : '') + } + const im = /^i\((.+)\)$/i.exec(vecName) + if (im) return `current through ${im[1].toUpperCase()}` + return undefined + }, + [gen, netModel] + ) + + return ( +
+ {/* header row: analysis config + run */} +
+ + Simulate + +
+ {( + [ + ['op', 'DC'], + ['dc', 'Sweep'], + ['tran', 'Transient'], + ['ac', 'AC'] + ] as [Analysis['kind'], string][] + ).map(([kind, label]) => ( + + ))} +
+ + {analysis.kind === 'tran' && ( +
+ step + setAnalysis({ step: e.target.value })} + /> + stop + setAnalysis({ stop: e.target.value })} + /> + +
+ )} + + {analysis.kind === 'dc' && ( +
+ source + + from + setAnalysis({ from: e.target.value })} + /> + to + setAnalysis({ to: e.target.value })} + /> + step + setAnalysis({ step: e.target.value })} + /> +
+ )} + + {analysis.kind === 'ac' && ( +
+ + pts + setAnalysis({ points: e.target.value })} + /> + from + setAnalysis({ fstart: e.target.value })} + /> + to + setAnalysis({ fstop: e.target.value })} + /> + + Hz + +
+ )} + +
+ + {result && result.numPoints > 1 && ( + + )} + + {running ? ( + + ) : ( + + )} + +
+ + {/* body: results / errors / netlist */} +
+ {running && ( +
+ running… first run loads the engine + (~a few MB) +
+ )} + + {error && ( +
+
{error.message}
+ {error.details && error.details.length > 1 && ( +
+                {error.details.slice(0, 8).join('\n')}
+              
+ )} + {issueRefs && ( +
+ select: + {issueRefs.parts.map((id) => ( + + ))} + {issueRefs.nets.map((i) => ( + + ))} + {(issueRefs.parts.length > 1 || issueRefs.nets.length > 0) && ( + + )} +
+ )} +
+ )} + + {gen && (gen.warnings.length > 0 || gen.excluded.length > 0) && ( +
+ {gen.warnings.slice(0, 6).map((w, i) => ( +
{w}
+ ))} +
+ )} + + {isOp && result && } + {result && result.numPoints > 1 && ( + + )} + + {showNetlist && gen && ( +
+            {gen.netlist}
+          
+ )} + + {!running && !result && !error && ( +
+ Run a DC operating point to annotate the schematic with node voltages, or a transient + to plot waveforms. Boards aren't simulated — drive their pins with sources from + the palette. +
+ )} +
+
+ ) +} + +// ── DC table ───────────────────────────────────────────────────────────────── + +export function fmtSI(v: number, unit: string): string { + const a = Math.abs(v) + if (a >= 1e6) return `${(v / 1e6).toFixed(2)} M${unit}` + if (a >= 1e3) return `${(v / 1e3).toFixed(2)} k${unit}` + if (a >= 1) return `${v.toFixed(3)} ${unit}` + if (a >= 1e-3) return `${(v * 1e3).toFixed(2)} m${unit}` + if (a >= 1e-6) return `${(v * 1e6).toFixed(2)} µ${unit}` + if (a === 0) return `0 ${unit}` + return `${(v * 1e9).toFixed(2)} n${unit}` +} + +function OpTable({ + run, + describe, + labelFor +}: { + run: SimRun + describe: (vecName: string) => string | undefined + labelFor?: (vecName: string) => string | undefined +}): React.JSX.Element { + const rows = run.vectors + .filter((v) => v.values.length === 1) + .map((v) => { + const isV = v.name.startsWith('v(') || v.name.startsWith('vdiff(') + const unit = isV ? 'V' : 'A' + const label = labelFor?.(v.name) + return { + name: label ? `${label} (${v.name})` : v.name, + value: fmtSI(v.values[0], unit), + what: describe(v.name) + } + }) + return ( +
+ {rows.map((r) => ( + + {r.name} + {r.value} + + {r.what ?? ''} + + + ))} +
+ ) +} diff --git a/src/renderer/src/commands/fileCommands.ts b/src/renderer/src/commands/fileCommands.ts index b7d97f4..aa4cb74 100644 --- a/src/renderer/src/commands/fileCommands.ts +++ b/src/renderer/src/commands/fileCommands.ts @@ -2,6 +2,7 @@ import { Dispatch } from '@reduxjs/toolkit' import { fileSystem, FileSystemItem } from '@renderer/lib/fileSystem' import { fetchRepoFolder, loadAccount } from '@renderer/lib/github' import { VIRTUAL_PREFIX, virtualFileSystem } from '@renderer/lib/virtualFileSystem' +import { setDocsTab } from '@renderer/redux/editorSlice' import { BaseFileItem, closeFile, @@ -168,6 +169,9 @@ async function activateWorkspace(workspace: Workspace, dispatch: Dispatch): Prom // old workspace's tabs/tree/README/diagram. dispatch(closeWorkspace()) dispatch(openWorkspace(workspace)) + // Opening a project (folder or example) means there's now something to read — + // switch the docs panel off the Examples tab and onto Docs. + dispatch(setDocsTab('readme')) const readme = fileItems.find((file) => file.name === 'README.md') if (readme) { diff --git a/src/renderer/src/components/AIAssistant.tsx b/src/renderer/src/components/AIAssistant.tsx index 463f314..05e5414 100644 --- a/src/renderer/src/components/AIAssistant.tsx +++ b/src/renderer/src/components/AIAssistant.tsx @@ -194,8 +194,7 @@ export function AIAssistant(): React.JSX.Element { {/* Slim toolbar */}
- Studio AI - · Claude Opus 4.8 + Claude Opus 4.8
-
- {/* wire color — recolours the selected wire, else sets the next-wire colour */} -
- Wire - {WIRE_COLORS.map((c) => ( -
-
- {families.map((group) => ( -
- - {isOpen(group.family) && ( -
- {group.parts.map((c: PartMeta) => ( -
e.dataTransfer.setData('text/tinystudio-part', c.type)} - onDoubleClick={() => addPart(c.type)} - className="group relative flex flex-col items-center gap-1 p-2 rounded-lg border border-border-default bg-surface-card hover:bg-bg-sunken hover:-translate-y-px transition cursor-grab active:cursor-grabbing" - title={`${c.label} · ${c.pins} pins — drag onto the canvas (or double-click)`} - > - -
- {c.label} -
- -
- ))} -
- )} -
- ))} -
-
- )} - - {/* canvas + tools */} -
-
- -
- -
- - - -
- -
- - - {Math.round(scale * 100)}% - - - -
- -
{ - e.preventDefault() - e.dataTransfer.dropEffect = 'copy' - }} - onDrop={onDrop} - onClick={onCanvasClick} - > -
- - {diagram.connections.map((c, i) => { - const pts = selWire === i && editPts ? editPts : wirePts(c) - if (pts.length < 2) return null - const sel = selWire === i - const onHotNet = highlightNet >= 0 && netModel.connToNet[i] === highlightNet - const core = - view === 'schematic' ? 'var(--text-strong)' : (c[2] as string) || '#2fa46a' - const outline = view === 'schematic' ? 'rgba(0,0,0,0.45)' : darken(core) - const d = roundedPath(pts) - return ( - setHoverWire(i)} - onPointerLeave={() => setHoverWire((h) => (h === i ? null : h))} - onClick={(e) => onWireClick(e, i)} - onDoubleClick={(e) => onWireDoubleClick(e, i)} - > - {/* equipotential net glow */} - {onHotNet && ( - - )} - {/* darker outline → glossy tube */} - - - {sel && ( - - - - )} - - ) - })} - - {/* junction solder dots where a wire taps another wire */} - {junctions.map((j, k) => ( - - ))} - - {previewPts && ( - - )} - - {/* handles on the selected wire: bend (circle), segment (square), - endpoint (ring) — Fritzing-style */} - {editable && selWire != null && editPts && ( - - {editPts.slice(0, -1).map((p, i) => { - const q = editPts[i + 1] - const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } - const horizontal = Math.abs(p.y - q.y) < 0.001 - return ( - startHandleDrag(e, 'edge', i)} - /> - ) - })} - {editPts.slice(1, -1).map((p, i) => ( - startHandleDrag(e, 'vertex', i + 1)} - /> - ))} - {[0, 1].map((end) => { - const p = end === 0 ? editPts[0] : editPts[editPts.length - 1] - return ( - startHandleDrag(e, 'endpoint', 0, end as 0 | 1)} - /> - ) - })} - - )} - - - {diagram.parts.map((part) => { - const def = getPart(part.type) - const v = def && viewFor(def, view) - const [pLeft, pTop] = partXY(part) - if (!def || !v) { - return ( -
- {part.type}… -
- ) - } - const labelOff = (part.attrs?.labelOffset as [number, number]) || [0, 0] - return ( -
onPartDown(e, part)} - onClick={(e) => e.stopPropagation()} - onContextMenu={(e) => { - if (!editable) return - e.preventDefault() - rotatePart(part.id) - }} - > -
- {editable && - Object.keys(v.pins).map((pin) => { - const [px, py] = v.pins[pin] - const armedHere = armed && eqRef(armed.from, `${part.id}:${pin}`) - const hovered = hoverPin?.id === part.id && hoverPin?.pin === pin - const onHotNet = - highlightNet >= 0 && - netModel.pinToNet.get(`${part.id}:${pin}`) === highlightNet - const on = armedHere || hovered - return ( -
e.stopPropagation()} - onPointerEnter={() => setHoverPin({ id: part.id, pin })} - onPointerLeave={() => - setHoverPin((h) => (h?.id === part.id && h?.pin === pin ? null : h)) - } - onClick={(e) => onPinClick(e, part, pin)} - > - - {onHotNet && !on && ( - - )} - - -
- ) - })} - {/* draggable title label */} -
onLabelDown(e, part)} - > - {labelOf(part)} -
-
- ) - })} - - {diagram.parts.length === 0 && ( -
- -
- Drag parts from the components rail — or double-click one to drop it here. -
-
- )} -
-
- - {/* tinyStudio watermark — sits above the bottom UI, bottom-right, and is - baked into the exported image */} -
- tinyStudio -
- - {/* footer (kept clear of the zoom cluster on the right; wraps upward) */} -
- - Parts: {diagram.parts.length} · Connections: {diagram.connections.length} · Nets:{' '} - {netCount} - - {isAI && ( - - Auto-wired by Studio AI - - )} - {!editable ? ( - - View-only · hover a wire to light its net · click edit to change - - ) : armed ? ( - - Click a pin or wire to connect · click for a bend · hold Shift for - straight · Esc to cancel - - ) : selWire != null ? ( - - Drag a handle to reshape · double-click to add/remove a bend · pick a color · Del to - remove - - ) : ( - - Scroll to zoom · middle/Alt-drag to pan · click a pin to - wire - - )} -
-
- - {/* inspector (edit mode only) */} - {editable && ( -
-
- Inspector -
-
- {selectedPart ? ( - touches(c[0], selectedPart.id) || touches(c[1], selectedPart.id) - ).length - } - onLabel={(val) => patchAttr(selectedPart.id, 'label', val)} - onMove={(l, t) => movePartTo(selectedPart.id, l, t)} - onRotate={(deg) => setRotation(selectedPart.id, deg)} - onSetAttr={(k, val) => patchAttr(selectedPart.id, k, val)} - onRemoveAttr={(k) => removeAttr(selectedPart.id, k)} - onDelete={() => deletePart(selectedPart.id)} - newAttr={newAttr} - setNewAttr={setNewAttr} - /> - ) : selWire != null ? ( - = 0 - ? netModel.nets[netModel.connToNet[selWire]].length - : 0 - } - onColor={(c) => recolorWire(selWire, c)} - onDelete={() => deleteWire(selWire)} - /> - ) : ( -
- Select a component to edit its name, location, rotation, and properties — or a wire - to recolor it. -
- )} -
-
- )} - - {editorPart !== undefined && ( - setEditorPart(undefined)} - onSave={(def: PartDef) => { - registerPart(def) - force() - setEditorPart(undefined) - }} - /> - )} -
- ) -} - -// ── Inspector subcomponents ─────────────────────────────────────────────────── - -const field = - 'bg-bg-sunken border border-border-default rounded px-2 py-1 text-text-strong outline-none focus:border-brand w-full' -const rowLabel = 'text-[11px] text-text-muted' - -function Inspector({ - part, - def, - view, - xy, - connCount, - onLabel, - onMove, - onRotate, - onSetAttr, - onRemoveAttr, - onDelete, - newAttr, - setNewAttr -}: { - part: { id: string; type: string; rotate?: number; attrs?: Record } - def?: PartDef - view: ViewKind - xy: [number, number] - connCount: number - onLabel: (v: string) => void - onMove: (l: number, t: number) => void - onRotate: (deg: number) => void - onSetAttr: (k: string, v: string) => void - onRemoveAttr: (k: string) => void - onDelete: () => void - newAttr: string - setNewAttr: (v: string) => void -}): React.JSX.Element { - const label = (part.attrs?.label as string) ?? def?.label ?? part.type - // attrs to expose as editable properties (hide internal layout keys) - const propRows = Object.entries(part.attrs || {}).filter( - ([k]) => k !== 'label' && k !== 'labelOffset' - ) - return ( - <> -
- - onLabel(e.target.value)} - /> -
-
- Type - {part.type} - Family - {def?.family ? titleCase(def.family) : '—'} - Wires - {connCount} -
- -
- Location ({view}) -
- onMove(parseFloat(e.target.value) || 0, xy[1])} - /> - onMove(xy[0], parseFloat(e.target.value) || 0)} - /> -
-
- -
- Rotation -
- - -
-
- -
- Properties - {propRows.length === 0 && ( -
- No properties yet. Add one below — e.g. a resistor’s{' '} - value. -
- )} - {propRows.map(([k, val]) => ( -
- - {k} - - onSetAttr(k, e.target.value)} - /> - -
- ))} -
- setNewAttr(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && newAttr.trim()) { - onSetAttr(newAttr.trim(), '') - setNewAttr('') - } - }} - /> - -
-
- - - - ) -} - -function WireInspector({ - color, - colors, - netPins, - onColor, - onDelete -}: { - color: string - colors: string[] - netPins: number - onColor: (c: string) => void - onDelete: () => void -}): React.JSX.Element { - return ( - <> -
- - Wire -
-
- Color -
- {colors.map((c) => ( -
-
-
- Net pins - {netPins} -
- - - ) -} diff --git a/src/renderer/src/components/DocsPanel.tsx b/src/renderer/src/components/DocsPanel.tsx index 63705e5..1d33bb0 100644 --- a/src/renderer/src/components/DocsPanel.tsx +++ b/src/renderer/src/components/DocsPanel.tsx @@ -1,57 +1,72 @@ -import { selectOpenFiles, setPanelOpen, useAppDispatch, useAppSelector } from '@renderer/redux' -import { BookOpen, Sparkles, X, Zap } from 'lucide-react' -import { AIAssistant } from './AIAssistant' -import { ExamplesContent } from './ExamplesContent' -import { ReadmeContent } from './ReferenceContent' -import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/Tabs' - -export function DocsPanel(): React.JSX.Element { - const dispatch = useAppDispatch() - // On a fresh start with nothing open, land on Examples so there's somewhere to - // go; once a project is open, default to Docs. (Uncontrolled — only the initial - // tab; the user can switch freely after.) - const initialTab = useAppSelector(selectOpenFiles).length === 0 ? 'examples' : 'readme' - - const handleCloseDocsPanel = (): void => { - dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) - } - - return ( -
- -
- - - - Docs - - - - Examples - - - - Studio AI - - - -
- - - - - - - - - -
-
- ) -} \ No newline at end of file +import { + selectDocsTab, + setDocsTab, + setPanelOpen, + useAppDispatch, + useAppSelector +} from '@renderer/redux' +import type { DocsTab } from '@renderer/redux/editorSlice' +import { BookOpen, Sparkles, X, Zap } from 'lucide-react' +import { AIAssistant } from './AIAssistant' +import { ExamplesContent } from './ExamplesContent' +import { ReadmeContent } from './ReferenceContent' +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/Tabs' + +// All three tabs share the shared TabsTrigger's brand-blue underline/active +// state — the accent colors below live in the tab content instead (Examples' +// buttons are yellow, Studio AI's bubbles and send button are purple). + +export function DocsPanel(): React.JSX.Element { + const dispatch = useAppDispatch() + // Controlled: starts on 'examples' (see editorSlice initialState) and + // activateWorkspace() flips it to 'readme' whenever a folder or example + // is opened. The user can still switch freely afterward. + const activeTab = useAppSelector(selectDocsTab) + + const handleCloseDocsPanel = (): void => { + dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + } + + return ( +
+ dispatch(setDocsTab(v as DocsTab))} + className="size-full flex flex-col gap-0" + > +
+ + + + Docs + + + + Examples + + + + Studio AI + + + +
+ + + + + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/EditorPanel.tsx b/src/renderer/src/components/EditorPanel.tsx index 30edd1b..06dca72 100644 --- a/src/renderer/src/components/EditorPanel.tsx +++ b/src/renderer/src/components/EditorPanel.tsx @@ -33,7 +33,8 @@ import * as monaco from 'monaco-editor' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { notify as toast } from '@renderer/lib/notify' import { BlocklyEditor } from './BlocklyEditor' -import { DiagramEditor } from './DiagramEditor' +import { emptyDoc, parseCircuitFile, serializeDoc } from '../circuit' +import { CircuitViewV2 } from '../circuit/views/CircuitView' import { MonacoEditor, MonacoEditorRef } from './MonacoEditor' import { Dialog, @@ -50,12 +51,6 @@ import { Input } from './ui/Input' import { VisualPreview } from './VisualPreview' loader.config({ monaco }) - -const DEFAULT_DIAGRAM = JSON.stringify( - { version: 1, editor: 'tinystudio', parts: [], connections: [] }, - null, - 2 -) const DEFAULT_VISUAL = `// visual.js — Serial Plotter // Graphs the latest number printed over Serial (serialValue()) as a scrolling // line, auto-scaling to the data. Try Serial.println(analogRead(A0)) on the @@ -359,18 +354,66 @@ function CodeView(): React.JSX.Element { ) } -// ── Circuit view: full-window interactive diagram.json ────────────────────── +// ── Circuit view: full-window interactive circuit.json editor (v2) ────────── +// +// Circuit View v2 (docs/circuit-view-tech-spec.md) is the only editor as of +// M4 — the legacy DiagramEditor and its `diagram.json`-only path were removed +// once v2 reached parity + simulation. Any project still holding a v1 +// `diagram.json` is migrated on open (see CircuitView below). +/** + * v2 adoption (spec §4, §13): the native file is `circuit.json`. On first + * open of a project that only has a v1 `diagram.json`, migrate it on disk: + * write `circuit.json` (converted) and `diagram.json.bak` (verbatim copy). + */ function CircuitView(): React.JSX.Element { const workspace = useAppSelector((s) => s.file.workspace) - const dispatch = useAppDispatch() - const file = useProjectFile('diagram.json', () => DEFAULT_DIAGRAM) + const [ready, setReady] = useState(false) + const adopting = useRef(false) + + useEffect(() => { + setReady(false) + if (!workspace || adopting.current) return + if (findInTree(workspace.root, (i) => i.name === 'circuit.json')) { + setReady(true) + return + } + const diagram = findInTree(workspace.root, (i) => i.name === 'diagram.json') + if (!diagram) { + setReady(true) // fresh project — useProjectFile seeds an empty circuit.json + return + } + adopting.current = true + ;(async () => { + try { + const old = await fileSystem.readFile(diagram.path!) + const { doc } = parseCircuitFile(old) + await fileSystem.writeFile(`${workspace.path}/circuit.json`, serializeDoc(doc)) + await fileSystem.writeFile(`${workspace.path}/diagram.json.bak`, old) + await new RefreshWorkspaceCommand(workspace).execute() + } catch (e) { + console.error('circuit.json adoption failed:', e) + } finally { + adopting.current = false + setReady(true) + } + })() + }, [workspace]) if (!workspace) return + if (!ready) return + return +} + +function CircuitV2Inner(): React.JSX.Element { + const dispatch = useAppDispatch() + const makeDefault = useCallback(() => serializeDoc(emptyDoc()), []) + const file = useProjectFile('circuit.json', makeDefault) + if (!file) return return ( - dispatch(updateFileContent({ id: file.id, content }))} onOpenCode={() => { @@ -378,8 +421,7 @@ function CircuitView(): React.JSX.Element { dispatch(setEditorView('code')) }} onEditChange={(editing) => { - // entering edit mode: free horizontal space by closing the docs panel - if (editing) dispatch(setPanelOpen({ panel: 'docs', isOpen: false })) + dispatch(setPanelOpen({ panel: 'docs', isOpen: !editing })) }} /> ) diff --git a/src/renderer/src/components/ExamplesContent.tsx b/src/renderer/src/components/ExamplesContent.tsx index 75af2bc..d689f6d 100644 --- a/src/renderer/src/components/ExamplesContent.tsx +++ b/src/renderer/src/components/ExamplesContent.tsx @@ -49,12 +49,8 @@ export function ExamplesContent(): React.JSX.Element { return (
-
- - Examples -
-
+
{status === 'loading' && (
@@ -85,7 +81,7 @@ export function ExamplesContent(): React.JSX.Element { {example.board && (

Board: {example.board}

)} - diff --git a/src/renderer/src/components/Header.tsx b/src/renderer/src/components/Header.tsx index 21f85ba..4eda33d 100644 --- a/src/renderer/src/components/Header.tsx +++ b/src/renderer/src/components/Header.tsx @@ -11,9 +11,11 @@ import { GitHubAccountButton } from './GitHubAccountButton' * Top bar (38px). Brand wordmark + breadcrumb on the left; theme toggle, GitHub * account, and (on Electron) window controls on the right. * - * Theme-aware per the design system: in LIGHT mode the bar is the blue brand - * color with a white wordmark; in DARK mode it's the raised grey surface with - * the wordmark's "Studio" in brand blue. Inset controls flip with it. + * Theme-aware per the design system: in LIGHT mode the bar is the green bar + * accent color with a white wordmark; in DARK mode it's the raised grey + * surface with the wordmark's "Studio" in that same accent green. Inset + * controls flip with it. Uses --bar-accent (not --brand), so buttons and + * selection elsewhere stay on the blue brand color. */ export function Header(): React.JSX.Element { const [isMaximized, setIsMaximized] = useState(false) @@ -49,11 +51,11 @@ export function Header(): React.JSX.Element { return (
-

+

tinyStudio

{workspace && ( diff --git a/src/renderer/src/components/PartsEditor.tsx b/src/renderer/src/components/PartsEditor.tsx index d37f699..c51776c 100644 --- a/src/renderer/src/components/PartsEditor.tsx +++ b/src/renderer/src/components/PartsEditor.tsx @@ -5,13 +5,17 @@ * in the same schema as Fritzing-imported parts (pixel coords @ 96 DPI), so a * hand-made part wires up exactly like an imported one. * + * Both the breadboard and schematic views are editable via the view toggle — + * each keeps its own art, size, and pin positions (pin NAMES are the cross-view + * join key, so keep them consistent between views). + * * Styled with the tinyStudio design system: cold neutral surfaces, soft-elevation * floating dialog, dot-grid preview, brand-blue accents, sentence-case copy. */ import { Plus, Trash2, UploadCloud, X } from 'lucide-react' import React from 'react' -import type { PartDef } from '../lib/partsLibrary' +import type { PartDef, PartView, ViewKind } from '../lib/partsLibrary' const PX_PER_MM = 96 / 25.4 @@ -27,9 +31,26 @@ interface Pin { y: number } +interface ViewBuf { + svg: string + w: number + h: number + pins: Pin[] +} + const blankSvg = (w: number, h: number): string => `` +const seedView = (v: PartView | undefined, fallbackW = 80, fallbackH = 40): ViewBuf => + v + ? { + svg: v.svg, + w: v.w, + h: v.h, + pins: Object.entries(v.pins).map(([n, [x, y]]) => ({ name: n, x, y })) + } + : { svg: blankSvg(fallbackW, fallbackH), w: fallbackW, h: fallbackH, pins: [] } + export function PartsEditor({ initial, onClose, @@ -39,21 +60,27 @@ export function PartsEditor({ onClose: () => void onSave: (def: PartDef) => void }): React.JSX.Element { - // when editing an existing part, seed from its (breadboard) view - const seed = - initial && - (initial.views.breadboard || initial.views.schematic || Object.values(initial.views)[0]) const [name, setName] = React.useState(initial?.label || 'My Part') - const [w, setW] = React.useState(seed?.w || 80) - const [h, setH] = React.useState(seed?.h || 40) - const [svg, setSvg] = React.useState(seed?.svg || blankSvg(80, 40)) - const [pins, setPins] = React.useState( - seed ? Object.entries(seed.pins).map(([n, [x, y]]) => ({ name: n, x, y })) : [] - ) + const [bb, setBb] = React.useState(() => seedView(initial?.views.breadboard)) + const [sch, setSch] = React.useState(() => seedView(initial?.views.schematic)) + const [editView, setEditView] = React.useState('breadboard') const [sel, setSel] = React.useState(-1) const surfaceRef = React.useRef(null) const dragRef = React.useRef(-1) + const buf = editView === 'breadboard' ? bb : sch + const setBuf = editView === 'breadboard' ? setBb : setSch + const { svg, w, h, pins } = buf + + const patch = (p: Partial): void => setBuf((b) => ({ ...b, ...p })) + const setPins = (fn: (ps: Pin[]) => Pin[]): void => setBuf((b) => ({ ...b, pins: fn(b.pins) })) + + const switchView = (v: ViewKind): void => { + if (v === editView) return + setEditView(v) + setSel(-1) + } + // fit the part into the preview area (cap zoom so tiny parts stay visible) const scale = Math.min(440 / w, 320 / h, 14) @@ -131,15 +158,14 @@ export function PartsEditor({ nw = nw ?? vb[2] nh = nh ?? vb[3] } - if (nw && nh) { - setW(Math.round(nw)) - setH(Math.round(nh)) - } + if (!el.getAttribute('xmlns')) el.setAttribute('xmlns', 'http://www.w3.org/2000/svg') el.removeAttribute('width') el.removeAttribute('height') - if (!el.getAttribute('xmlns')) el.setAttribute('xmlns', 'http://www.w3.org/2000/svg') if (!el.getAttribute('viewBox') && nw && nh) el.setAttribute('viewBox', `0 0 ${nw} ${nh}`) - setSvg(el.outerHTML) + patch({ + svg: el.outerHTML, + ...(nw && nh ? { w: Math.round(nw), h: Math.round(nh) } : {}) + }) } reader.readAsText(file) } @@ -169,22 +195,36 @@ export function PartsEditor({ } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [sel, w, h]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sel, w, h, editView]) - const save = (): void => { - const pinMap: Record = {} - pins.forEach((p) => { - pinMap[p.name] = [p.x, p.y] + const toMap = (ps: Pin[]): Record => { + const m: Record = {} + ps.forEach((p) => { + m[p.name] = [p.x, p.y] }) + return m + } + + const canSave = bb.pins.length > 0 || sch.pins.length > 0 + + const save = (): void => { + const views: Partial> = {} + if (bb.pins.length || initial?.views.breadboard) + views.breadboard = { svg: bb.svg, w: bb.w, h: bb.h, pins: toMap(bb.pins) } + if (sch.pins.length || initial?.views.schematic) + views.schematic = { svg: sch.svg, w: sch.w, h: sch.h, pins: toMap(sch.pins) } + if (!views.breadboard && !views.schematic) + views.breadboard = { svg: bb.svg, w: bb.w, h: bb.h, pins: toMap(bb.pins) } onSave({ // editing keeps the original identity so it updates in place (not a copy) type: initial ? initial.type : slug(name), label: name, family: initial?.family || 'Custom', builtin: initial?.builtin, - // regenerate the palette icon from the current art so the thumbnail updates - icon: svg, - views: { breadboard: { svg, w, h, pins: pinMap } } + // palette icon prefers the breadboard art, falls back to schematic + icon: bb.pins.length ? bb.svg : sch.svg, + views }) } @@ -209,7 +249,29 @@ export function PartsEditor({ {initial ? `editing “${initial.label}”` : 'author a custom part'}
- + ))} +
+
@@ -270,6 +332,9 @@ export function PartsEditor({ onChange={(e) => setName(e.target.value)} /> +
+ Editing the {editView} view. +
@@ -292,8 +361,12 @@ export function PartsEditor({ value={h} onChange={(e) => { const nv = Math.max(1, parseInt(e.target.value, 10) || 1) - setH(nv) - if (svg.startsWith(' @@ -366,7 +439,7 @@ export function PartsEditor({ ))} {pins.length === 0 && (
- Click the preview to drop pins. + Click the preview to drop pins for the {editView} view.
)}
@@ -381,7 +454,7 @@ export function PartsEditor({