diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 68f6e5b..863eed0 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -61,7 +61,7 @@ The visual convention followed by the standard schematic symbols. _Avoid_: formal standards compliance claim, copied standard catalog **Symbol Terminal Role**: -A named connection role expected by a **Schematic Symbol**, such as `anode`, `cathode`, `collector`, `base`, `emitter`, `positive`, or `negative`. +A named connection role expected by a **Schematic Symbol**, such as `anode`, `cathode`, `collector`, `base`, `emitter`, `positive`, `negative`, `wiper`, or `reference`. _Avoid_: terminal name, instance ID **Symbol Terminal Mapping**: diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 245be57..552ac7c 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -48,3 +48,15 @@ Source: [examples/kitchen-sink.wire](../examples/kitchen-sink.wire) Source: [examples/bus-rail.wire](../examples/bus-rail.wire) ![Bus-rail layout demo](./assets/bus-rail.svg) + +## Potentiometer Voltage Divider + +Source: [examples/pot-divider.wire](../examples/pot-divider.wire) + +![Loaded potentiometer divider](./assets/pot-divider.svg) + +## Diode Variants + +Source: [examples/diode-variants.wire](../examples/diode-variants.wire) + +![Diode variants](./assets/diode-variants.svg) diff --git a/docs/MVP.md b/docs/MVP.md index df947ef..f3146d0 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -275,8 +275,13 @@ The MVP ships a small standard component library. | `Capacitor` | `1`, `2` | recommended `capacitance: capacitance` | `id`, `capacitance` | `capacitor` | | `PolarizedCapacitor` | `+`, `-` | recommended `capacitance: capacitance` | `id`, `capacitance` | `polarized-capacitor` | | `Inductor` | `1`, `2` | recommended `inductance: inductance` | `id`, `inductance` | `inductor` | +| `Potentiometer` | `1`, `W`, `2` | recommended `value: resistance` | `id`, `value` | `potentiometer` | +| `Rheostat` | `1`, `2` | recommended `value: resistance` | `id`, `value` | `rheostat` | | `Diode` | `A`, `C` | none | `id` | `diode` | | `LED` | `A`, `C` | optional `color: enum(red, green, blue, yellow, white, amber)` | `id` | `led` | +| `ZenerDiode` | `A`, `C` | optional `voltage: voltage` | `id`, `voltage` | `zener-diode` | +| `SchottkyDiode` | `A`, `C` | none | `id` | `schottky-diode` | +| `Photodiode` | `A`, `C` | none | `id` | `photodiode` | | `NPNTransistor` | `C`, `B`, `E` | none | `id` | `npn-transistor` | | `PNPTransistor` | `C`, `B`, `E` | none | `id` | `pnp-transistor` | | `Battery` | `+`, `-` | recommended `voltage: voltage` | `id`, `voltage` | `battery` | @@ -293,14 +298,20 @@ The MVP ships a small standard component library. | `PowerFlag` | `1` | recommended `name: string` | none | `power-flag` | | `IC` | from `pins=[...]` | recommended `pins: ic-pin-list` | `id` | `ic` | -Designator prefixes: `FerriteBead` → `FB`/`L`, `TVSDiode` → `D`/`TVS`, `Speaker` → -`LS`/`SP`, `Antenna` → `ANT`/`E`, `TestPoint` → `TP`, `PTC` → `F`/`RT`, -`PowerFlag` → `PWR`/`PR`/`PF`, `IC` → `U`/`IC`. - -`TVSDiode` maps `anode`→`A` and `cathode`→`C`; `Speaker` maps `positive`→`+` and -`negative`→`-`. `PowerFlag` draws its `name` (e.g. `VBAT`, `5V`, `3V3`, `VCC`) -inside the flag glyph; it is a visual rail flag only and does not create a hidden -global net. +Designator prefixes: `Potentiometer` → `R`/`RV`/`VR`, `Rheostat` → +`R`/`RV`/`VR`/`RH`, `ZenerDiode` → `D`/`ZD`, `SchottkyDiode` → `D`/`SD`, +`Photodiode` → `D`/`PD`, `FerriteBead` → `FB`/`L`, `TVSDiode` → `D`/`TVS`, +`Speaker` → `LS`/`SP`, `Antenna` → `ANT`/`E`, `TestPoint` → `TP`, `PTC` → +`F`/`RT`, `PowerFlag` → `PWR`/`PR`/`PF`, `IC` → `U`/`IC`. + +`Potentiometer` maps `wiper`→`W` (the two track ends `1`/`2` are +interchangeable); `ZenerDiode`, `SchottkyDiode`, and `Photodiode` map `anode`→`A` +and `cathode`→`C` like `Diode`. Potentiometer labels sit opposite the wiper, +using final terminal coordinates so orientation and direction hints preserve +clearance from the terminal paths. The wiper arrow touches the track midpoint. `TVSDiode` maps `anode`→`A` and `cathode`→`C`; +`Speaker` maps `positive`→`+` and `negative`→`-`. `PowerFlag` draws its `name` +(e.g. `VBAT`, `5V`, `3V3`, `VCC`) inside the flag glyph; it is a visual rail flag +only and does not create a hidden global net. MOSFETs and complex board modules such as Arduino boards are outside the MVP. @@ -707,3 +718,17 @@ Later extensions: - [ADR 0016](./adr/0016-browser-auto-render-post-mvp.md) records the browser auto-render scope decision. - [ADR 0017](./adr/0017-esm-only-node-20.md) records the runtime and module-format decision. - [ADR 0019](./adr/0019-build-time-markdown-integration.md) records the original static Markdown integration, superseded in part by ADR 0020. + +### Discrete branch routing + +In horizontal flows, vertical two-terminal parts can flip to put earlier +connections above later connections, with explicit power and ground symbols +taking priority. Potentiometers can mirror to face their connected load. +Adjacent facing terminals on the same axis connect directly when no component +lies between them. Ground connections routed to a lower rail first leave the +terminal outward and pass around the symbol bars. + +The diode gallery uses a series resistor ahead of a Zener bias rail, a +reverse-biased photodiode, and one explicit shared GND net. `ZenerDiode` accepts +an optional `voltage` quantity and displays it. The divider gallery shows a +rheostat loading the wiper output, with ground connections represented by labels. diff --git a/docs/assets/diode-variants.png b/docs/assets/diode-variants.png new file mode 100644 index 0000000..fec39f4 Binary files /dev/null and b/docs/assets/diode-variants.png differ diff --git a/docs/assets/diode-variants.svg b/docs/assets/diode-variants.svg new file mode 100644 index 0000000..708d97c --- /dev/null +++ b/docs/assets/diode-variants.svg @@ -0,0 +1,19 @@ + +Diode variants +A Schottky-protected 5V input feeds a current-limited 3.3V Zener rail and a reverse-biased photodiode with a sense resistor. + + + +5VD1R11kD23.3VD3R2100k + +SENSESENSEGNDGNDGND + \ No newline at end of file diff --git a/docs/assets/pot-divider.png b/docs/assets/pot-divider.png new file mode 100644 index 0000000..1629280 Binary files /dev/null and b/docs/assets/pot-divider.png differ diff --git a/docs/assets/pot-divider.svg b/docs/assets/pot-divider.svg new file mode 100644 index 0000000..d3033aa --- /dev/null +++ b/docs/assets/pot-divider.svg @@ -0,0 +1,18 @@ + +Loaded potentiometer divider +A 10k potentiometer divides 5V; a 4k7 rheostat loads its OUT node to ground. + + + +5VRV110kRH14k7 +GNDGNDGNDOUT + \ No newline at end of file diff --git a/docs/reviews/pr-67-v031.md b/docs/reviews/pr-67-v031.md new file mode 100644 index 0000000..0b55013 --- /dev/null +++ b/docs/reviews/pr-67-v031.md @@ -0,0 +1,177 @@ +# PR #67: overlap fix and 0.3.1 investigation + +## Current electrical and routing corrections + +The five follow-up review findings are now addressed. The diode gallery places +1 kΩ ahead of the shared 3.3 V Zener bias, connects the photodiode cathode to +that bias, and senses its anode through 100 kΩ to the same explicit GND net. +The divider description identifies the rheostat as an output load. Its vertical +potentiometer faces the load and `OUT` is visible. + +Both galleries use named ground connections to remove long return loops. +Adjacent facing terminals now connect directly. Vertical branches can flip +using connection order and explicit power/ground references. Lower-rail ground +connections leave the terminal outward and pass beside the bars. Vertical optical +component labels reserve room for the light arrows. + +All 173 tests pass, including the retained original collision fixture, a ground +approach regression, and checks of the example net memberships, polarity, +straight connection, visible output label, and absence of collinear overlaps or +foreign-terminal hits. Typecheck, lint, docs formatting, build, and example CLI +checks pass. The render comparisons for this revision are in `realistic/` on +the `demo/pr-67-v031-assets` branch. + +The sections below record earlier investigation and fix stages. + +## Follow-up fix + +The PR now places potentiometer labels on the side opposite the wiper using +final terminal coordinates, and extends the wiper arrow to the resistor track +midpoint. Both changes follow orientation and direction hints. The gallery +examples omit the redundant `Wiper output` and `Zener clamp` annotations that +intersected wires. General annotation collision avoidance remains unchanged. + +The new `issue-67.test.ts` checks label rectangles against actual net segments, +checks that labels stay inside the canvas, and verifies that the rendered arrow +tip lies on a rendered resistor segment. Before the fix, 12 of its 16 cases failed. +All 16 now pass across four directions and two orientations. All 170 project +tests pass, as do typecheck and lint. The existing Diode control is unchanged. + +The comparisons below under “Original investigation” are historical evidence +that merging 0.3.1 alone did not fix these problems. Current fix images live in +`docs/assets/pr-67-v031/fix/` on `demo/pr-67-v031-assets`. + +### Potentiometer divider + +Same source on pre-fix PR `b4578b2` and the fixed renderer. + +| Before fix | After fix | +| ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/pot-divider-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/pot-divider-after.png) | + +### Vertical potentiometer + +Same source on pre-fix PR `b4578b2` and the fixed renderer. + +| Before fix | After fix | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/pot-vertical-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/pot-vertical-after.png) | + +### Component catalog + +Same source on pre-fix PR `b4578b2` and the fixed renderer. + +| Before fix | After fix | +| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/catalog-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/fix/catalog-after.png) | + +## Original investigation + +Release 0.3.1 does not fix the visible collisions in PR #67's new components. +The four component PNGs are byte-identical before and after merging the release. +Their SVGs differ only in `data-wire-lang-version`. + +## Revisions and method + +- Before: original PR head `60b044d`. +- After: that head merged with release 0.3.1, `94896ab`, in commit `5a8b5c5`. +- Existing Diode control: release/main `94896ab` on the left, merged PR on the right. +- Every image uses the same source on both sides, rendered by the bundled + `scripts/wire-to-png.mjs` with Resvg at 1200 px and the same system fonts. +- New types cannot compile on release/main alone, so their before images use the + original PR head. The existing Diode control SVG is byte-identical on main and the PR. +- Images, SVGs, and exact source fixtures live on `demo/pr-67-v031-assets`, under + `docs/assets/pr-67-v031/`, outside the feature branch. + +## Findings + +| Fixture | Result after 0.3.1 | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Potentiometer divider | `RV1` and `10k` intersect the wiper connection; the annotation intersects a return wire. The wiper arrow still stops short of the track. | +| Vertical potentiometer | `RV1` and `10k` intersect the upper terminal connection. The wiper arrow still stops short of the track. | +| Diode variants | The `Zener clamp` annotation intersects a wire. | +| Catalog snapshot | Potentiometer labels intersect its wiper connection. The photodiode points left both before and after the merge. | + +The existing `collinearOverlaps` and `foreignTerminalHits` geometry helpers return +empty arrays for all four fixtures after the merge. These checks detect distinct +nets sharing a segment and wires passing through unrelated terminals. They do +not detect text collisions or the gap between the wiper arrow and resistor track. +Perpendicular wire crossings without a junction are not electrical connections. + +Release 0.3.1 adds routing for facing IC pin banks and reserves width for opposing +IC pin labels. These fixtures do not exercise that IC routing fix. This result +is limited to the rendered cases; it is not a claim that all routing is collision-free. + +The earlier audit comment describes proposed fixes that are not present in the +original PR head. Merging 0.3.1 does not apply those fixes. + +## Merge and validation + +Git merged release/main without textual conflicts. The catalog snapshot was stale: +it recorded version 0.2.0 and an old photodiode orientation. Actual renders from +`60b044d` already have the current orientation, confirmed by the unchanged PNG. +The snapshot was refreshed to match actual output after the merge. + +All 125 tests, typecheck, lint, docs formatting, and the full build passed for the +0.3.1 merge. All four component sources passed the bundled CLI with no diagnostics. +The seven issue #109 tests continue to cover the IC routing fix. + +Main advanced to `1abd724` during the investigation. That Markdown integration +was also merged without textual conflicts. The final combined branch passes +all 154 tests, typecheck, lint, docs formatting, and the full build. Its four +component SVGs match the 0.3.1-merge renders exactly. + +## Reproduce + +Build each revision, then run the same fixture through its bundled renderer: + +```sh +pnpm install --frozen-lockfile +pnpm --filter @wire-lang/core build:js +node scripts/wire-to-png.mjs /path/to/fixture.wire /tmp/output.png 1200 +``` + +Compare PNGs with `cmp before.png after.png`. For the four new-component fixtures, +normalize only `data-wire-lang-version` before comparing SVGs. + +## Render comparisons + +### Potentiometer divider + +[Source](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-divider.wire) + +| Original PR | PR with 0.3.1 | +| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-divider-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-divider-after.png) | + +### Vertical potentiometer + +[Source](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-vertical.wire) + +| Original PR | PR with 0.3.1 | +| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-vertical-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/pot-vertical-after.png) | + +### Diode variants + +[Source](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-variants.wire) + +| Original PR | PR with 0.3.1 | +| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-variants-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-variants-after.png) | + +### All new components + +[Source](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/catalog.wire) + +| Original PR | PR with 0.3.1 | +| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/catalog-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/catalog-after.png) | + +### Existing Diode control + +[Source](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-control.wire) + +| Main 0.3.1 | PR with 0.3.1 | +| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| ![Before](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-control-before.png) | ![After](https://raw.githubusercontent.com/eduardozf/wire-lang/demo/pr-67-v031-assets/docs/assets/pr-67-v031/diode-control-after.png) | diff --git a/examples/diode-variants.wire b/examples/diode-variants.wire new file mode 100644 index 0000000..221f1f6 --- /dev/null +++ b/examples/diode-variants.wire @@ -0,0 +1,21 @@ +schematic + title "Diode variants" + description "A Schottky-protected 5V input feeds a current-limited 3.3V Zener rail and a reverse-biased photodiode with a sense resistor." + component PWR1 PowerFlag name=5V + component D1 SchottkyDiode + component R1 Resistor value=1k + component D2 ZenerDiode voltage=3.3V + component D3 Photodiode + component R2 Resistor value=100k + component G1 GroundReference + net 5V: PWR1.1, D1.A + net LIMITED: D1.C, R1.1 + net BIAS: R1.2, D2.C, D3.C + net SENSE: D3.A, R2.1 + net GND: D2.A, R2.2, G1.GND + render D2 orientation=vertical + render D3 orientation=vertical + render R2 orientation=vertical + + render net GND style=label + render net SENSE style=label diff --git a/examples/pot-divider.wire b/examples/pot-divider.wire new file mode 100644 index 0000000..165a6b0 --- /dev/null +++ b/examples/pot-divider.wire @@ -0,0 +1,14 @@ +schematic + title "Loaded potentiometer divider" + description "A 10k potentiometer divides 5V; a 4k7 rheostat loads its OUT node to ground." + component PWR1 PowerFlag name=5V + component RV1 Potentiometer value=10k + component RH1 Rheostat value=4k7 + component G1 GroundReference + net 5V: PWR1.1, RV1.1 + net OUT: RV1.W, RH1.1 + net GND: RV1.2, RH1.2, G1.GND + render RV1 orientation=vertical + render RH1 orientation=vertical + render net GND style=label + annotation "OUT" near net OUT diff --git a/packages/core/src/layout/engine.ts b/packages/core/src/layout/engine.ts index 1c052e4..c738fc9 100644 --- a/packages/core/src/layout/engine.ts +++ b/packages/core/src/layout/engine.ts @@ -35,6 +35,7 @@ interface MC { /** A net member's terminal, tagged with its body center so drops can fan away from it. */ interface DropPoint extends MC { + component: string; centerMain: number; } @@ -55,6 +56,7 @@ interface Drop { railCross: number; centerMain: number; lane: number; + escapeCross?: number; } /** A multi-terminal net awaiting rail-track assignment and segment emission. */ @@ -192,6 +194,44 @@ export function layout(model: SchematicModel): LayoutModel { }; const mirrored = mirrorGeometry(entry.geom); if (cost(mirrored) < cost(entry.geom)) entry.geom = mirrored; + // In horizontal flows, vertical two-terminal branches read top to bottom: + // earlier partners feed the top and later partners receive the bottom. + // Explicit ground/power glyphs take precedence over declaration order. + if ( + !vertical && + entry.geom.terminals.length === 2 && + entry.geom.terminals[0]!.main === entry.geom.terminals[1]!.main + ) { + const crossCost = (geom: ComponentGeom): number => { + let total = 0; + for (const net of model.nets) { + const own = net.members.find((member) => member.component === entry.instance.id); + const terminal = geom.terminals.find((term) => term.name === own?.terminal); + if (!terminal) continue; + const ground = net.members.some( + (member) => placedById.get(member.component)?.instance.symbol === "ground-reference", + ); + const supply = net.members.some( + (member) => placedById.get(member.component)?.instance.symbol === "power-flag", + ); + if (ground || supply) { + total += (ground ? -terminal.cross : terminal.cross) * model.components.length; + continue; + } + for (const partner of net.members) { + if (partner.component === entry.instance.id) continue; + const other = partnerMain(partner.component, partner.terminal); + if (other !== undefined) total += terminal.cross * (other < entry.centerMain ? 1 : -1); + } + } + return total; + }; + const flipped = { + ...entry.geom, + terminals: entry.geom.terminals.map((term) => ({ ...term, cross: -term.cross })), + }; + if (crossCost(flipped) < crossCost(entry.geom)) entry.geom = flipped; + } } for (const entry of placed) { @@ -282,7 +322,11 @@ export function layout(model: SchematicModel): LayoutModel { .map((member): DropPoint | undefined => { const point = terminalPoints.get(`${member.component}.${member.terminal}`); if (!point) return undefined; - return { ...point, centerMain: centerByComponent.get(member.component) ?? point.main }; + return { + ...point, + component: member.component, + centerMain: centerByComponent.get(member.component) ?? point.main, + }; }) .filter((point): point is DropPoint => point !== undefined); if (points.length === 0) continue; @@ -317,6 +361,27 @@ export function layout(model: SchematicModel): LayoutModel { continue; } + // Adjacent facing terminals on one axis need only a straight connection. + const [left, right] = [...points].sort((a, b) => a.main - b.main); + if ( + points.length === 2 && + left && + right && + left.cross === right.cross && + left.main > left.centerMain && + right.main < right.centerMain && + !placed.some((entry) => entry.centerMain > left.main && entry.centerMain < right.main) + ) { + wires.push({ + net: net.name, + anonymous: net.anonymous, + style: "wire", + segments: [{ from: left, to: right }], + junctions: [], + }); + continue; + } + const avg = points.reduce((sum, point) => sum + point.cross, 0) / points.length; const side: "top" | "bottom" = avg < 0 ? "top" : "bottom"; const railCross = side === "top" ? topProvisional : bottomProvisional; @@ -326,12 +391,22 @@ export function layout(model: SchematicModel): LayoutModel { railCross, centerMain: point.centerMain, lane: 0, + escapeCross: + placedById.get(point.component)?.instance.symbol === "ground-reference" && + point.cross < 0 && + railCross > point.cross + ? point.cross - STUB + : undefined, })); pendingNets.push({ net, side, railCross, minMain: 0, maxMain: 0, drops }); allDrops.push(...drops); } assignDropLanes(allDrops); + // Leave the ground terminal outward before passing around its bars. + for (const drop of allDrops) { + if (drop.escapeCross !== undefined) drop.lane = Math.max(24, drop.lane); + } // With lanes fixed, record each rail's true main extent, then pack the rails // into shared tracks per side so unrelated nets stop stacking up the margins. @@ -356,6 +431,21 @@ export function layout(model: SchematicModel): LayoutModel { } for (const drop of drops) { const lm = laneMain(drop); + if (drop.escapeCross !== undefined) { + segments.push( + { + from: { main: drop.main, cross: drop.cross }, + to: { main: drop.main, cross: drop.escapeCross }, + }, + { + from: { main: drop.main, cross: drop.escapeCross }, + to: { main: lm, cross: drop.escapeCross }, + }, + { from: { main: lm, cross: drop.escapeCross }, to: { main: lm, cross: railCross } }, + ); + if (lm > minMain && lm < maxMain) junctions.push({ main: lm, cross: railCross }); + continue; + } // Step sideways into the assigned lane before running down to the rail. if (drop.lane !== 0) { segments.push({ diff --git a/packages/core/src/layout/geometry.ts b/packages/core/src/layout/geometry.ts index 580cbb6..cc92ed3 100644 --- a/packages/core/src/layout/geometry.ts +++ b/packages/core/src/layout/geometry.ts @@ -35,6 +35,11 @@ const TWO_TERM_CROSS = 22; const TRANSISTOR_MAIN = 54; const TRANSISTOR_CROSS = 44; +// Potentiometer: a two-terminal resistor body whose wiper taps the midpoint on a +// perpendicular stub. The wiper sits this far off the body centerline; the cross +// span is symmetric around the body so the box encloses the tap on either flow. +const POT_WIPER_REACH = 24; + // Single-terminal glyphs (ground, power flag, test point, antenna) hang off a // short stub; the terminal exits one end toward a rail. const SINGLE_TERM_MAIN = 36; @@ -76,6 +81,10 @@ const TWO_TERMINAL_SYMBOLS = new Set([ "inductor", "diode", "led", + "zener-diode", + "schottky-diode", + "photodiode", + "rheostat", "spst-switch", "push-button", "battery", @@ -93,13 +102,17 @@ const SINGLE_TERMINAL_SYMBOLS = new Set([ ]); /** - * Symbols the layout may mirror to face a wire. Only two-terminal parts qualify: + * Symbols the layout may mirror to face a wire. Two-terminal parts qualify: * their glyphs draw between the terminal points, so swapped terminals mirror the * drawing for free, and a reversed diode/LED/battery is legitimate schematic - * practice. Modules and ICs draw from `side` and never mirror; transistors draw + * practice. Potentiometers also draw from role positions, so the wiper can face + * its load while the interchangeable track ends swap. Modules and ICs draw from `side` and never mirror; transistors draw * from role positions and are excluded for the same reason. */ -export const MIRRORABLE_SYMBOLS: ReadonlySet = TWO_TERMINAL_SYMBOLS; +export const MIRRORABLE_SYMBOLS: ReadonlySet = new Set([ + ...TWO_TERMINAL_SYMBOLS, + "potentiometer", +]); export function isTwoTerminalSymbol(symbol: string): boolean { return TWO_TERMINAL_SYMBOLS.has(symbol); @@ -141,6 +154,24 @@ export function componentGeometry(instance: ComponentInstance): ComponentGeom { }; } + if (symbol === "potentiometer") { + // Body runs end-to-end like a resistor; the wiper taps the midpoint on a + // perpendicular stub. Resolve the wiper by role so the two track ends keep + // their declared order regardless of which terminal is the tap. + const wiper = roleTerminal(instance, "wiper") ?? instance.terminals[1] ?? "W"; + const ends = instance.terminals.filter((name) => name !== wiper); + const [a, b] = ends.length >= 2 ? ends : instance.terminals; + return { + mainSpan: TWO_TERM_MAIN, + crossSpan: 2 * POT_WIPER_REACH, + terminals: [ + { name: a ?? "1", main: 0, cross: 0 }, + { name: b ?? "2", main: TWO_TERM_MAIN, cross: 0 }, + { name: wiper, main: TWO_TERM_MAIN / 2, cross: -POT_WIPER_REACH }, + ], + }; + } + if (TWO_TERMINAL_SYMBOLS.has(symbol) && instance.terminals.length >= 2) { const [a, b] = instance.terminals; return { diff --git a/packages/core/src/library/standard-library.ts b/packages/core/src/library/standard-library.ts index d2d5c1d..a7d0223 100644 --- a/packages/core/src/library/standard-library.ts +++ b/packages/core/src/library/standard-library.ts @@ -48,6 +48,25 @@ const DEFINITIONS: readonly ComponentTypeDef[] = [ symbol: "inductor", designatorPrefixes: ["L"], }, + { + name: "Potentiometer", + // Three terminals: the two track ends (`1`, `2`) and the `W`iper tap. + terminals: ["1", "W", "2"], + properties: [{ name: "value", kind: "quantity", dimension: "resistance", recommended: true }], + defaultLabels: ["id", "value"], + symbol: "potentiometer", + roleMappings: [{ role: "wiper", terminal: "W" }], + designatorPrefixes: ["R", "RV", "VR"], + }, + { + name: "Rheostat", + // Two-terminal variable resistor: a resistor body with a wiper arrow. + terminals: ["1", "2"], + properties: [{ name: "value", kind: "quantity", dimension: "resistance", recommended: true }], + defaultLabels: ["id", "value"], + symbol: "rheostat", + designatorPrefixes: ["R", "RV", "VR", "RH"], + }, { name: "Diode", terminals: ["A", "C"], @@ -78,6 +97,42 @@ const DEFINITIONS: readonly ComponentTypeDef[] = [ ], designatorPrefixes: ["D", "LED"], }, + { + name: "ZenerDiode", + terminals: ["A", "C"], + properties: [{ name: "voltage", kind: "quantity", dimension: "voltage" }], + defaultLabels: ["id", "voltage"], + symbol: "zener-diode", + roleMappings: [ + { role: "anode", terminal: "A" }, + { role: "cathode", terminal: "C" }, + ], + designatorPrefixes: ["D", "ZD"], + }, + { + name: "SchottkyDiode", + terminals: ["A", "C"], + properties: [], + defaultLabels: ["id"], + symbol: "schottky-diode", + roleMappings: [ + { role: "anode", terminal: "A" }, + { role: "cathode", terminal: "C" }, + ], + designatorPrefixes: ["D", "SD"], + }, + { + name: "Photodiode", + terminals: ["A", "C"], + properties: [], + defaultLabels: ["id"], + symbol: "photodiode", + roleMappings: [ + { role: "anode", terminal: "A" }, + { role: "cathode", terminal: "C" }, + ], + designatorPrefixes: ["D", "PD"], + }, { name: "NPNTransistor", terminals: ["C", "B", "E"], diff --git a/packages/core/src/library/symbols.ts b/packages/core/src/library/symbols.ts index 80fd81d..3f51eb4 100644 --- a/packages/core/src/library/symbols.ts +++ b/packages/core/src/library/symbols.ts @@ -10,6 +10,11 @@ const SYMBOL_REQUIRED_ROLES: Record = { inductor: [], diode: ["anode", "cathode"], led: ["anode", "cathode"], + "zener-diode": ["anode", "cathode"], + "schottky-diode": ["anode", "cathode"], + photodiode: ["anode", "cathode"], + potentiometer: ["wiper"], + rheostat: [], "npn-transistor": ["collector", "base", "emitter"], "pnp-transistor": ["collector", "base", "emitter"], battery: ["positive", "negative"], diff --git a/packages/core/src/render/symbols.ts b/packages/core/src/render/symbols.ts index 48e1354..533532d 100644 --- a/packages/core/src/render/symbols.ts +++ b/packages/core/src/render/symbols.ts @@ -232,26 +232,32 @@ function drawInductor(pen: Pen, length: number): string { return pen.wire({ points }); } -function drawDiode(pen: Pen, length: number, component: LayoutComponent, led: boolean): string { +const DIODE_TIP = 7; // half-length of the body: flat anode at -tip, point at +tip +const DIODE_HEIGHT = 16; // height of the body and the cathode bar + +/** Diode leads plus the filled triangle body, shared by every diode variant. */ +function drawDiodeBody(pen: Pen, length: number, fill: string): string { const center = length / 2; - const tipDistance = 7; // half-length of the body: flat anode at -tip, point at +tip - const bodyHeight = 16; // height of the body and the cathode bar - const fill = led ? (ledColor(component) ?? "#9ca3af") : "#1f2937"; - const parts = [ - pen.lead({ from: { along: 0, across: 0 }, to: { along: center - tipDistance, across: 0 } }), // anode lead - pen.lead({ - from: { along: center + tipDistance, across: 0 }, - to: { along: length, across: 0 }, - }), // cathode lead + return group( + pen.lead({ from: { along: 0, across: 0 }, to: { along: center - DIODE_TIP, across: 0 } }), // anode lead + pen.lead({ from: { along: center + DIODE_TIP, across: 0 }, to: { along: length, across: 0 } }), // cathode lead pen.triangle({ fill, points: [ - { along: center - tipDistance, across: -bodyHeight / 2 }, - { along: center - tipDistance, across: bodyHeight / 2 }, - { along: center + tipDistance, across: 0 }, + { along: center - DIODE_TIP, across: -DIODE_HEIGHT / 2 }, + { along: center - DIODE_TIP, across: DIODE_HEIGHT / 2 }, + { along: center + DIODE_TIP, across: 0 }, ], }), - pen.bar({ at: center + tipDistance, height: bodyHeight }), // cathode bar + ); +} + +function drawDiode(pen: Pen, length: number, component: LayoutComponent, led: boolean): string { + const center = length / 2; + const fill = led ? (ledColor(component) ?? "#9ca3af") : "#1f2937"; + const parts = [ + drawDiodeBody(pen, length, fill), + pen.bar({ at: center + DIODE_TIP, height: DIODE_HEIGHT }), // straight cathode bar ]; if (led) { // Two parallel "emitted light" arrows pointing away from the diode. @@ -266,6 +272,70 @@ function drawDiode(pen: Pen, length: number, component: LayoutComponent, led: bo return group(...parts); } +function drawZenerDiode(pen: Pen, length: number): string { + const center = length / 2; + const bar = center + DIODE_TIP; + const h = DIODE_HEIGHT / 2; + // Canonical Zener cathode bar: a "Z" with the top end bent back toward the + // anode and the bottom end bent forward toward the cathode. (A unidirectional + // TVS shares this standard mark; the bidirectional TVS is the two-triangle form.) + return group( + drawDiodeBody(pen, length, "#1f2937"), + pen.wire({ + points: [ + { along: bar - 4, across: -h }, + { along: bar, across: -h }, + { along: bar, across: h }, + { along: bar + 4, across: h }, + ], + }), + ); +} + +function drawSchottkyDiode(pen: Pen, length: number): string { + const center = length / 2; + const bar = center + DIODE_TIP; + const h = DIODE_HEIGHT / 2; + // Cathode bar with squared "S" hooks: a forward tick on top, a back tick below. + return group( + drawDiodeBody(pen, length, "#1f2937"), + pen.wire({ + points: [ + { along: bar + 4, across: -h + 4 }, + { along: bar + 4, across: -h }, + { along: bar, across: -h }, + { along: bar, across: h }, + { along: bar - 4, across: h }, + { along: bar - 4, across: h - 4 }, + ], + }), + ); +} + +function drawPhotodiode(pen: Pen, length: number): string { + const center = length / 2; + // A plain diode plus two arrows pointing *into* the body (incident light), + // the inverse of the LED's outward-emitting arrows. + return group( + drawDiodeBody(pen, length, "#1f2937"), + pen.bar({ at: center + DIODE_TIP, height: DIODE_HEIGHT }), + pen.arrow({ from: { along: center + 4, across: -16 }, to: { along: center, across: -10 } }), + pen.arrow({ from: { along: center + 8, across: -14 }, to: { along: center + 4, across: -8 } }), + ); +} + +function drawRheostat(pen: Pen, length: number): string { + const center = length / 2; + // A resistor body with a diagonal wiper arrow drawn across it. + return group( + drawResistor(pen, length), + pen.arrow({ + from: { along: center - 12, across: 13 }, + to: { along: center + 12, across: -13 }, + }), + ); +} + function drawBattery(pen: Pen, length: number): string { const center = length / 2; const tallPlate = 22; // long cell plate height (the + terminal) @@ -692,6 +762,28 @@ function drawModule(component: LayoutComponent): string { return parts.join(""); } +function drawPotentiometer(component: LayoutComponent): string { + // The body is a resistor between the two track ends; the wiper taps the + // midpoint with an arrow. Resolve the wiper by role, then draw the body in the + // end-to-end frame and the arrow in a fresh frame from the wiper to the body. + const wiperName = component.roleMappings.find((mapping) => mapping.role === "wiper")?.terminal; + const wiper = + component.terminals.find((terminal) => terminal.name === wiperName) ?? component.terminals[1]; + const ends = component.terminals.filter((terminal) => terminal !== wiper); + const [a, b] = ends.length >= 2 ? ends : component.terminals; + if (!a || !b) return ""; + const { frame, length } = makeFrame(a.point, b.point); + const body = drawResistor(makePen(frame), length); + if (!wiper) return body; + const bodyCenter = frame({ along: length / 2, across: 0 }); + const { frame: wiperFrame, length: wiperLength } = makeFrame(wiper.point, bodyCenter); + const arrow = makePen(wiperFrame).arrow({ + from: { along: 0, across: 0 }, + to: { along: wiperLength, across: 0 }, + }); + return group(body, arrow); +} + function drawTwoTerminal(component: LayoutComponent): string | null { const [first, second] = component.terminals; if (!first || !second) return null; @@ -710,6 +802,14 @@ function drawTwoTerminal(component: LayoutComponent): string | null { return drawDiode(p, length, component, false); case "led": return drawDiode(p, length, component, true); + case "zener-diode": + return drawZenerDiode(p, length); + case "schottky-diode": + return drawSchottkyDiode(p, length); + case "photodiode": + return drawPhotodiode(p, length); + case "rheostat": + return drawRheostat(p, length); case "battery": return drawBattery(p, length); case "spst-switch": @@ -733,6 +833,28 @@ function drawLabels(component: LayoutComponent): string { const labels = component.labels.filter((label) => label !== ""); if (labels.length === 0) return ""; const parts: string[] = []; + if (component.symbol === "potentiometer") { + const wiperName = component.roleMappings.find((mapping) => mapping.role === "wiper")?.terminal; + const wiper = component.terminals.find((terminal) => terminal.name === wiperName); + if (wiper) { + // Keep text opposite the wiper, beside the track rather than in any of + // the three terminal paths. Use final coordinates so hints rotate it. + const dx = wiper.point.x - component.center.x; + const dy = wiper.point.y - component.center.y; + const vertical = Math.abs(dx) > Math.abs(dy); + const side = (vertical ? dx : dy) < 0 ? 1 : -1; + const x = component.center.x + (vertical ? side * 18 : 0); + const anchor = vertical ? (side > 0 ? "start" : "end") : "middle"; + let y = vertical + ? component.center.y + 4 - ((labels.length - 1) * 13) / 2 + : component.center.y + (side > 0 ? 22 : -16); + for (const label of labels) { + parts.push(text(label, { x, y }, anchor, "wire-label")); + y += vertical ? 13 : side * 13; + } + return parts.join(""); + } + } // A vertical two-terminal part has wires entering top and bottom, so labels // above would sit in the wire's path; put them beside the body instead. const [first, second] = component.terminals; @@ -742,7 +864,8 @@ function drawLabels(component: LayoutComponent): string { second && Math.abs(second.point.y - first.point.y) > Math.abs(second.point.x - first.point.x); if (vertical) { - const x = component.position.x + component.size.width + 6; + const optical = component.symbol === "photodiode" || component.symbol === "led"; + const x = component.position.x + component.size.width + (optical ? 18 : 6); let y = component.center.y + 4 - ((labels.length - 1) * 13) / 2; for (const label of labels) { parts.push( @@ -784,6 +907,9 @@ export function renderComponent(component: LayoutComponent): string { case "pnp-transistor": glyph = drawTransistor(component, true); break; + case "potentiometer": + glyph = drawPotentiometer(component); + break; case "ic": glyph = drawIc(component); break; diff --git a/packages/core/test/__snapshots__/parts-catalog.test.ts.snap b/packages/core/test/__snapshots__/parts-catalog.test.ts.snap index e2715ce..dbd6abf 100644 --- a/packages/core/test/__snapshots__/parts-catalog.test.ts.snap +++ b/packages/core/test/__snapshots__/parts-catalog.test.ts.snap @@ -15,9 +15,30 @@ exports[`parts catalog: snapshot > renders a mixed-parts schematic stably 1`] = .wire-net-label { fill: #2563eb; font: 600 10px var(--wire-font, system-ui, sans-serif); } .wire-annotation { fill: #6b7280; font: italic 10px var(--wire-font, system-ui, sans-serif); } - + VCC1GND2OUT3EN4NC5U1FB1D1LS1ANT1TP1F15V " `; + +exports[`parts catalog: variable resistors & diode variants snapshot > renders the new discrete parts stably 1`] = ` +" +Variable resistors and diode variants +A schematic with 5 components. + + + +RV110kRH14k7D1D2D3 + +" +`; diff --git a/packages/core/test/__snapshots__/render-features.test.ts.snap b/packages/core/test/__snapshots__/render-features.test.ts.snap index 9e0d101..69ebf19 100644 --- a/packages/core/test/__snapshots__/render-features.test.ts.snap +++ b/packages/core/test/__snapshots__/render-features.test.ts.snap @@ -35,7 +35,7 @@ exports[`layout/render features > produces stable SVG output (snapshot) 1`] = ` .wire-net-label { fill: #2563eb; font: 600 10px var(--wire-font, system-ui, sans-serif); } .wire-annotation { fill: #6b7280; font: italic 10px var(--wire-font, system-ui, sans-serif); } - + BT15VR1220ohmD1 Current limiting resistor " @@ -62,7 +62,7 @@ exports[`layout/render features > renders a component rotated by orientation (sn `; exports[`layout/render features > renders the complex symbol set (transistor, ground, header, switch, inductor, polarized cap) 1`] = ` -" +" Symbol coverage A schematic with 7 components. - - + + Q1R11kL110mH+C1100uFSW1VCCGNDJ1 " diff --git a/packages/core/test/ground-routing.test.ts b/packages/core/test/ground-routing.test.ts new file mode 100644 index 0000000..b3dfa21 --- /dev/null +++ b/packages/core/test/ground-routing.test.ts @@ -0,0 +1,27 @@ +import { compile, layout } from "@wire-lang/core"; +import { expect, it } from "vitest"; + +it("approaches a ground terminal from outside its glyph on a lower return rail", () => { + const result = layout( + compile(`schematic + component R1 Resistor value=1k + component G1 GroundReference + net GND: R1.2, G1.GND + render R1 orientation=vertical + `).model, + ); + const ground = result.components.find((c) => c.id === "G1")!; + const terminal = ground.terminals[0]!.point; + const connections = result.wires + .flatMap((wire) => wire.segments) + .filter( + ({ from, to }) => + (from.x === terminal.x && from.y === terminal.y) || + (to.x === terminal.x && to.y === terminal.y), + ); + expect(connections).toHaveLength(1); + const segment = connections[0]!; + const other = + segment.from.x === terminal.x && segment.from.y === terminal.y ? segment.to : segment.from; + expect(other.y).toBeLessThan(terminal.y); +}); diff --git a/packages/core/test/issue-67.test.ts b/packages/core/test/issue-67.test.ts new file mode 100644 index 0000000..80aaf0e --- /dev/null +++ b/packages/core/test/issue-67.test.ts @@ -0,0 +1,94 @@ +import { compile, layout, renderSvg } from "@wire-lang/core"; +import { describe, expect, it } from "vitest"; + +// Keep the original collision fixture independent of gallery edits. +const divider = `schematic + title "Potentiometer voltage divider" + description "A 10k potentiometer taps a fraction of the 5V rail; a rheostat trims the return leg." + + component PWR1 PowerFlag name=5V + component RV1 Potentiometer value=10k + component RH1 Rheostat value=4k7 + component G1 GroundReference + + net VCC: PWR1.1, RV1.1 + net OUT: RV1.W, RH1.1 + net GND: RV1.2, RH1.2, G1.GND +`; + +function coordinates(value: string): number[] { + return value.match(/-?\d+(?:\.\d+)?/g)!.map(Number); +} + +describe("issue #67: potentiometer contact and labels", () => { + for (const direction of ["left-to-right", "right-to-left", "top-to-bottom", "bottom-to-top"]) { + for (const orientation of ["horizontal", "vertical"]) { + const source = `${divider}\nrender direction=${direction}\nrender RV1 orientation=${orientation}\n`; + const name = `${direction}, ${orientation}`; + + it(`keeps labels clear of connections: ${name}`, () => { + const model = layout(compile(source).model); + const svg = renderSvg(source); + const component = svg.match( + /]*data-wire-id="RV1"[^>]*>(.*?)<\/g>/s, + )![1]!; + const labels = [ + ...component.matchAll( + /([^<]+)<\/text>/g, + ), + ]; + expect(labels).toHaveLength(2); + for (const [, x, y, anchor, text] of labels) { + const width = text!.length * 7; + const left = Number(x) - (anchor === "middle" ? width / 2 : anchor === "end" ? width : 0); + const box = { + left: left - 2, + right: left + width + 2, + top: Number(y) - 11, + bottom: Number(y) + 2, + }; + for (const wire of model.wires) { + for (const { from, to } of wire.segments) { + const intersects = + Math.max(from.x, to.x) > box.left && + Math.min(from.x, to.x) < box.right && + Math.max(from.y, to.y) > box.top && + Math.min(from.y, to.y) < box.bottom; + expect(intersects, `${text} intersects ${wire.net}`).toBe(false); + } + } + expect(box.left).toBeGreaterThanOrEqual(0); + expect(box.right).toBeLessThanOrEqual(model.size.width); + expect(box.top).toBeGreaterThanOrEqual(0); + expect(box.bottom).toBeLessThanOrEqual(model.size.height); + } + }); + + it(`puts the arrow tip on the resistor track: ${name}`, () => { + const svg = renderSvg(source); + const component = svg.match( + /]*data-wire-id="RV1"[^>]*>(.*?)<\/g>/s, + )![1]!; + const track = coordinates(component.match(/]* d="([^"]+)"/)![1]!); + const [x, y] = coordinates(component.match(/]* points="([^"]+)"/)![1]!); + let touches = false; + for (let i = 2; i < track.length; i += 2) { + const ax = track[i - 2]!; + const ay = track[i - 1]!; + const bx = track[i]!; + const by = track[i + 1]!; + const cross = (x! - ax) * (by - ay) - (y! - ay) * (bx - ax); + if ( + Math.abs(cross) < 0.01 && + x! >= Math.min(ax, bx) && + x! <= Math.max(ax, bx) && + y! >= Math.min(ay, by) && + y! <= Math.max(ay, by) + ) + touches = true; + } + expect(touches).toBe(true); + }); + } + } +}); diff --git a/packages/core/test/parts-catalog.test.ts b/packages/core/test/parts-catalog.test.ts index 4ae096e..05b4fcc 100644 --- a/packages/core/test/parts-catalog.test.ts +++ b/packages/core/test/parts-catalog.test.ts @@ -15,6 +15,10 @@ describe("parts catalog: new discrete components", () => { { type: "TVSDiode", id: "D1", symbol: "tvs-diode", terminals: ["A", "C"] }, { type: "Speaker", id: "LS1", symbol: "speaker", terminals: ["+", "-"] }, { type: "PTC", id: "F1", symbol: "ptc", terminals: ["1", "2"] }, + { type: "Rheostat", id: "RV1", symbol: "rheostat", terminals: ["1", "2"] }, + { type: "ZenerDiode", id: "D2", symbol: "zener-diode", terminals: ["A", "C"] }, + { type: "SchottkyDiode", id: "D3", symbol: "schottky-diode", terminals: ["A", "C"] }, + { type: "Photodiode", id: "D4", symbol: "photodiode", terminals: ["A", "C"] }, ]; for (const part of twoTerminal) { @@ -83,6 +87,70 @@ describe("parts catalog: new discrete components", () => { }); }); +describe("parts catalog: potentiometer", () => { + const DIVIDER = `schematic + component RV1 Potentiometer value=10k + component R1 Resistor value=1k + component R2 Resistor value=2k + component R3 Resistor value=3k + net TOP: RV1.1, R1.1 + net WIP: RV1.W, R2.1 + net BOT: RV1.2, R3.1 + net RET: R1.2, R2.2, R3.2 +`; + + it("resolves three terminals with a wiper role mapping", () => { + const { model, ok } = compile(DIVIDER); + expect(ok).toBe(true); + const rv = model.components.find((c) => c.id === "RV1")!; + expect(rv.symbol).toBe("potentiometer"); + expect(rv.terminals).toEqual(["1", "W", "2"]); + expect(rv.roleMappings).toContainEqual({ role: "wiper", terminal: "W" }); + }); + + it("accepts the `wiper` role as a terminal alias on a net", () => { + const source = `schematic + component RV1 Potentiometer value=10k + component R1 Resistor value=1k + component R2 Resistor value=2k + component R3 Resistor value=3k + net TOP: RV1.1, R1.1 + net WIP: RV1.wiper, R2.1 + net BOT: RV1.2, R3.1 + net RET: R1.2, R2.2, R3.2 +`; + expect(errorCodes(source)).toEqual([]); + const { model } = compile(source); + const wip = model.nets.find((n) => n.name === "WIP")!; + expect(wip.members).toContainEqual({ component: "RV1", terminal: "W" }); + }); + + it("renders the potentiometer symbol without error", () => { + expect(() => renderSvg(DIVIDER)).not.toThrow(); + expect(renderSvg(DIVIDER)).toContain('data-wire-symbol="potentiometer"'); + }); +}); + +describe("parts catalog: variable resistors & diode variants snapshot", () => { + it("renders the new discrete parts stably", () => { + const svg = renderSvg(`schematic + title "Variable resistors and diode variants" + component RV1 Potentiometer value=10k + component RH1 Rheostat value=4k7 + component D1 ZenerDiode + component D2 SchottkyDiode + component D3 Photodiode + net A: RV1.1, RH1.1, D3.C + net W: RV1.W, D1.A + net B: RV1.2, D1.C + net C: RH1.2, D2.A + net D: D2.C, D3.A +`); + expect(svg.startsWith(" { const IC = `schematic component U1 IC pins=[1:VCC@left, 2:GND@left, 3:OUT@right] diff --git a/packages/core/test/pr-67-examples.test.ts b/packages/core/test/pr-67-examples.test.ts new file mode 100644 index 0000000..cf2bc33 --- /dev/null +++ b/packages/core/test/pr-67-examples.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { compile, layout, renderSvg } from "@wire-lang/core"; +import { expect, it } from "vitest"; +import { collinearOverlaps, foreignTerminalHits } from "./helpers/geometry.js"; + +const source = (name: string) => + readFileSync(new URL(`../../../examples/${name}.wire`, import.meta.url), "utf8"); + +it("connects a current-limited Zener bias and reverse-biased photodiode to one ground", () => { + const result = compile(source("diode-variants")); + expect(result.diagnostics).toEqual([]); + const members = (net: string) => + result.model.nets + .find((n) => n.name === net)! + .members.map((m) => `${m.component}.${m.terminal}`) + .sort(); + expect(members("LIMITED")).toEqual(["D1.C", "R1.1"]); + expect(members("BIAS")).toEqual(["D2.C", "D3.C", "R1.2"]); + expect(members("SENSE")).toEqual(["D3.A", "R2.1"]); + expect(members("GND")).toEqual(["D2.A", "G1.GND", "R2.2"]); + const drawing = layout(result.model); + for (const id of ["D2", "D3"]) { + const diode = drawing.components.find((c) => c.id === id)!; + expect(diode.terminals.find((t) => t.name === "C")!.point.y).toBeLessThan( + diode.terminals.find((t) => t.name === "A")!.point.y, + ); + } + expect(drawing.wires.find((w) => w.net === "LIMITED")!.segments).toHaveLength(1); + expect(renderSvg(result.model)).toContain(">3.3V"); + expect(collinearOverlaps(drawing)).toEqual([]); + expect(foreignTerminalHits(drawing, result.model)).toEqual([]); +}); + +it("shows a right-facing divider wiper and its load with a visible OUT label", () => { + const result = compile(source("pot-divider")); + expect(result.diagnostics).toEqual([]); + const drawing = layout(result.model); + const pot = drawing.components.find((c) => c.id === "RV1")!; + expect(pot.terminals.find((t) => t.name === "W")!.point.x).toBeGreaterThan(pot.center.x); + expect(drawing.labels.some((label) => label.text === "OUT")).toBe(true); + expect(collinearOverlaps(drawing)).toEqual([]); + expect(foreignTerminalHits(drawing, result.model)).toEqual([]); +}); diff --git a/scripts/update-examples.mjs b/scripts/update-examples.mjs index cf37b77..d49e8a1 100644 --- a/scripts/update-examples.mjs +++ b/scripts/update-examples.mjs @@ -21,6 +21,8 @@ const EXAMPLES = [ ["examples/npn-led-driver.wire", "docs/assets/npn-led-driver"], ["examples/kitchen-sink.wire", "docs/assets/kitchen-sink"], ["examples/bus-rail.wire", "docs/assets/bus-rail"], + ["examples/pot-divider.wire", "docs/assets/pot-divider"], + ["examples/diode-variants.wire", "docs/assets/diode-variants"], ]; execFileSync("pnpm", ["--filter", "@wire-lang/core", "run", "build:js"], { diff --git a/skills/wire-lang/references/component-library.md b/skills/wire-lang/references/component-library.md index 955db9d..cefc7f1 100644 --- a/skills/wire-lang/references/component-library.md +++ b/skills/wire-lang/references/component-library.md @@ -9,8 +9,13 @@ Lang source. | `Capacitor` | `1`, `2` | recommended `capacitance` | `id`, `capacitance` | | `PolarizedCapacitor` | `+`, `-` | recommended `capacitance` | `id`, `capacitance` | | `Inductor` | `1`, `2` | recommended `inductance` | `id`, `inductance` | +| `Potentiometer` | `1`, `W`, `2` | recommended `value` | `id`, `value` | +| `Rheostat` | `1`, `2` | recommended `value` | `id`, `value` | | `Diode` | `A`, `C` | none | `id` | | `LED` | `A`, `C` | optional `color` | `id` | +| `ZenerDiode` | `A`, `C` | optional `voltage` | `id`, `voltage` | +| `SchottkyDiode` | `A`, `C` | none | `id` | +| `Photodiode` | `A`, `C` | none | `id` | | `NPNTransistor` | `C`, `B`, `E` | none | `id` | | `PNPTransistor` | `C`, `B`, `E` | none | `id` | | `Battery` | `+`, `-` | recommended `voltage` | `id`, `voltage` | @@ -30,10 +35,26 @@ Lang source. `PTC` is the resettable-fuse / polyfuse variant. `PowerFlag` draws its `name` (e.g. `5V`, `3V3`, `VBAT`) as a rail flag and is not a hidden global net. +`Potentiometer` is a three-terminal variable resistor: the two track ends `1` +and `2` are interchangeable and the `W` wiper taps the middle (connect it by name +or via the `wiper` role alias, e.g. `net OUT: RV1.W, ...`). `Rheostat` is the +two-terminal form. Potentiometer labels are placed opposite the wiper and follow +orientation hints. The arrow touches the resistor track. Avoid redundant +annotations beside these parts when the component and net names already explain +the circuit; annotations are not automatically routed around wires. + +`ZenerDiode`, `SchottkyDiode`, and `Photodiode` reuse the +`Diode` `A`/`C` terminals. + ## Property Examples ```wire component R1 Resistor value=10k +component RV1 Potentiometer value=10k +component RH1 Rheostat value=4k7 +component D2 ZenerDiode +component D3 SchottkyDiode +component D4 Photodiode component C1 Capacitor capacitance=100nF component BT1 Battery voltage=5V component D1 LED color=red @@ -62,3 +83,14 @@ MOSFETs, op-amps, relays, motors, displays, sensors, Arduino boards, ESP32 boards, and custom component libraries are outside the MVP standard library. Use a local `define component ... symbol module` block when a simple module placeholder is enough. + +## Practical diode and divider examples + +Use `ZenerDiode voltage=3.3V` to show its nominal breakdown voltage. A Zener +shunt bias needs current limiting ahead of its branch, not just a resistor in +another load branch. For a reverse-biased photodiode, connect the cathode toward +the positive bias and its anode toward the sensing resistor and ground. + +GroundReference glyphs do not merge nets. Put all intended common returns in +one `GND` net, even when using `render net GND style=label` to shorten the drawing. +A rheostat between a potentiometer wiper and ground is a load on the output.