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)

+
+## Potentiometer Voltage Divider
+
+Source: [examples/pot-divider.wire](../examples/pot-divider.wire)
+
+
+
+## Diode Variants
+
+Source: [examples/diode-variants.wire](../examples/diode-variants.wire)
+
+
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 @@
+
\ 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 @@
+
\ 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 |
+| ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+### Vertical potentiometer
+
+Same source on pre-fix PR `b4578b2` and the fixed renderer.
+
+| Before fix | After fix |
+| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+### Component catalog
+
+Same source on pre-fix PR `b4578b2` and the fixed renderer.
+
+| Before fix | After fix |
+| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+## 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 |
+| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+### 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 |
+| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+|  |  |
+
+### 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 |
+| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+### 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 |
+| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+### 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 |
+| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+|  |  |
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`] = `
+""
+`;
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`] = `
-"