From 639ace003009024d23dfa3ea9f82e3f77251cc42 Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Tue, 18 Aug 2026 12:18:54 -0600 Subject: [PATCH] Add transistors --- .../Services/BreadboardValidator.cs | 74 ++++++-- .../wwwroot/css/breadboard.css | 4 + .../js/breadboard/editor/component-art.js | 93 +++++++++++ .../wwwroot/js/breadboard/editor/palette.js | 3 +- .../wwwroot/js/breadboard/editor/status.js | 5 +- .../js/breadboard/editor/theme-colors.js | 3 + .../js/breadboard/engine/models/conduction.js | 49 ++++++ .../js/breadboard/engine/models/diode.js | 62 +++++++ .../js/breadboard/engine/models/index.js | 2 + .../js/breadboard/engine/models/switches.js | 56 ++----- .../js/breadboard/engine/models/transistor.js | 158 ++++++++++++++++++ .../breadboard/shared/component-registry.js | 88 +++++++++- 12 files changed, 540 insertions(+), 57 deletions(-) create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/conduction.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/diode.js create mode 100644 Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/transistor.js diff --git a/Media.JoshHeaps.Net/Services/BreadboardValidator.cs b/Media.JoshHeaps.Net/Services/BreadboardValidator.cs index df148ba..799753e 100644 --- a/Media.JoshHeaps.Net/Services/BreadboardValidator.cs +++ b/Media.JoshHeaps.Net/Services/BreadboardValidator.cs @@ -70,6 +70,7 @@ public class BreadboardValidator(ILogger logger) private const int ChipColumnSpan = 7; // 14-pin DIP private const int DipSwitchColumnSpan = 8; // 16-pin DIP private const int PushButtonColumnSpan = 3; + private const int TransistorColumnSpan = 3; // TO-92, three legs one column apart private const int DipSwitchPositions = 8; private static readonly Regex UidPattern = new($@"^[A-Za-z0-9_.:-]{{1,{MaxUidLength}}}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -110,7 +111,8 @@ public class BreadboardValidator(ILogger logger) private static readonly HashSet ComponentTypes = new(StringComparer.Ordinal) { - "led", "resistor", "pushButton", "dipSwitch8", "powerSupply5V", + "led", "resistor", "diode", "npn", "pnp", "nmos", "pmos", + "pushButton", "dipSwitch8", "powerSupply5V", "74HC00", "74HC02", "74HC04", "74HC08", "74HC32", "74HC86", "74HC30" }; @@ -289,7 +291,21 @@ public class BreadboardValidator(ILogger logger) switch (type) { case "led": - ValidateLed(component, path, boardUids, errors); + ValidateTwoHoleSpan(component, path, boardUids, errors); + ValidateLedProps(component, path, errors); + break; + + case "diode": + ValidateTwoHoleSpan(component, path, boardUids, errors); + ValidateEmptyProps(component, path, errors); + break; + + case "npn": + case "pnp": + case "nmos": + case "pmos": + ValidateInlinePackage(component, path, TransistorColumnSpan, boardUids, errors); + ValidateEmptyProps(component, path, errors); break; case "resistor": @@ -334,15 +350,15 @@ public class BreadboardValidator(ILogger logger) } /// - /// An LED spans two holes: the anchor is the anode and the cathode sits one hole away in - /// the orient direction. A leg in a power rail is legal — electrically useless if both - /// legs share a rail, but a real breadboard allows it — so only the footprint is checked. + /// A two-legged part spanning two holes: the anchor is the first terminal (an LED's anode, + /// a diode's anode) and the second sits one hole away in the orient direction. A leg in a + /// power rail is legal — electrically useless if both legs share a rail, but a real + /// breadboard allows it — so only the footprint is checked. /// - private static void ValidateLed(JsonElement component, string path, HashSet boardUids, ErrorList errors) + private static void ValidateTwoHoleSpan(JsonElement component, string path, HashSet boardUids, ErrorList errors) { var orient = ReadEnum(component, "orient", path, Orientations, errors, required: true); var anchor = ReadAnchor(component, path, boardUids, errors, required: true); - ValidateLedProps(component, path, errors); if (anchor is null || orient is null) { @@ -352,7 +368,7 @@ public class BreadboardValidator(ILogger logger) if (orient is "up" or "down") { // Vertical travel moves one row and may legitimately cross the centre channel. A - // rail has no rows, so the cathode of an up/down LED anchored there has nowhere to go. + // rail has no rows, so the far leg of an up/down part anchored there has nowhere to go. if (anchor.Kind != "main") { errors.Add($"{path}.orient", "not_valid_on_rail"); @@ -362,8 +378,8 @@ public class BreadboardValidator(ILogger logger) // Rows a..j read top to bottom, so "up" decreases the row index — the same sign // convention that maps "left"/"right" to -1/+1 on the column axis. Mirrors // shared/board-geometry.js (team amendment A12 pins it as contract). - var cathodeRow = (anchor.Row[0] - 'a') + (orient == "up" ? -1 : 1); - if (cathodeRow < 0 || cathodeRow >= MainRows.Count) + var farRow = (anchor.Row[0] - 'a') + (orient == "up" ? -1 : 1); + if (farRow < 0 || farRow >= MainRows.Count) { errors.Add($"{path}.anchor", "footprint_off_board"); } @@ -371,16 +387,50 @@ public class BreadboardValidator(ILogger logger) return; } - var cathode = anchor.Position + (orient == "left" ? -1 : 1); + var farPosition = anchor.Position + (orient == "left" ? -1 : 1); var min = anchor.Kind == "main" ? MainColumnMin : RailIndexMin; var max = anchor.Kind == "main" ? MainColumnMax : RailIndexMax; - if (cathode < min || cathode > max) + if (farPosition < min || farPosition > max) { errors.Add($"{path}.anchor", "footprint_off_board"); } } + /// + /// A three-legged inline package (TO-92). Its legs run along one row of the main grid, one + /// column apart, so only "left" and "right" leave each leg in a strip of its own — a + /// vertical placement would put two legs in the same five-hole strip, and a power rail is + /// one continuous strip, which is why an anchor there is rejected outright. + /// + private static void ValidateInlinePackage( + JsonElement component, + string path, + int columnSpan, + HashSet boardUids, + ErrorList errors) + { + var orient = ReadEnum(component, "orient", path, PackageOrientations, errors, required: true); + var anchor = ReadAnchor(component, path, boardUids, errors, required: true); + + if (anchor is null || orient is null) + { + return; + } + + if (anchor.Kind != "main") + { + errors.Add($"{path}.anchor.kind", "must_be_main"); + return; + } + + var lastColumn = anchor.Position + ((orient == "left" ? -1 : 1) * (columnSpan - 1)); + if (lastColumn < MainColumnMin || lastColumn > MainColumnMax) + { + errors.Add($"{path}.anchor.col", "package_off_board"); + } + } + /// /// Packages that straddle the centre gap. Pin 1 sits in row "e" or "f" on the main grid /// and the package runs across columns: row "e" reads left diff --git a/Media.JoshHeaps.Net/wwwroot/css/breadboard.css b/Media.JoshHeaps.Net/wwwroot/css/breadboard.css index 2fefa0c..8c7239d 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/breadboard.css +++ b/Media.JoshHeaps.Net/wwwroot/css/breadboard.css @@ -39,6 +39,9 @@ --bb-chip-pin: #c2c2c8; --bb-resistor-body: #d6c298; --bb-resistor-lead: #a9a9ad; + --bb-diode-body: #9aa7b4; + --bb-diode-band: #1c1c20; + --bb-transistor-body: #1f1f24; --bb-button-body: #34343a; --bb-button-cap: #c9553f; --bb-button-cap-down: #8d3a2b; @@ -76,6 +79,7 @@ --bb-wire-shadow: rgba(0, 0, 0, 0.22); --bb-chip-body: #2b2b30; + --bb-transistor-body: #2b2b30; --bb-supply-body: #26303a; } diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/component-art.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/component-art.js index 9c1e293..deba3e9 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/component-art.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/component-art.js @@ -167,6 +167,95 @@ function drawResistor(ctx, component, pins, colors, state, zoom) { ctx.restore(); } +function drawDiode(ctx, component, pins, colors) { + const [anode, cathode] = pins; + if (!anode || !cathode) return; + + drawLead(ctx, anode, cathode, colors); + + const angle = Math.atan2(cathode.y - anode.y, cathode.x - anode.x); + const length = Math.hypot(cathode.x - anode.x, cathode.y - anode.y); + const bodyLength = Math.max(PITCH * 0.5, Math.min(length * 0.6, PITCH * 1.8)); + const bodyHeight = PITCH * 0.34; + + ctx.save(); + ctx.translate((anode.x + cathode.x) / 2, (anode.y + cathode.y) / 2); + ctx.rotate(angle); + + ctx.fillStyle = colors.diodeBody; + roundRect(ctx, -bodyLength / 2, -bodyHeight / 2, bodyLength, bodyHeight, bodyHeight * 0.3); + ctx.fill(); + + // The band marks the cathode, which after the rotation is always the +x end. + ctx.fillStyle = colors.diodeBand; + ctx.fillRect(bodyLength * 0.24, -bodyHeight / 2, bodyLength * 0.16, bodyHeight); + + ctx.restore(); +} + +/** + * TO-92 package seen from above: a flat face with a domed back, sitting over its own + * three holes. Everything is drawn within half a pitch of the pin row so the body stays + * inside componentBounds, which is what hit-testing and dirty-rect culling use. + */ +function drawTransistor(ctx, component, pins, colors, zoom) { + const placed = pins.filter(p => p !== null); + if (placed.length < 3) return; + + const xs = placed.map(p => p.x); + const ys = placed.map(p => p.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + const w = Math.max(...xs) - x; + const cy = y + (Math.max(...ys) - y) / 2; + const pad = PITCH * 0.34; + const left = x - pad; + const right = x + w + pad; + const flatY = cy + PITCH * 0.42; + const backY = cy - PITCH * 0.52; + + ctx.strokeStyle = colors.chipPin; + ctx.lineWidth = PITCH * 0.12; + ctx.lineCap = 'butt'; + for (const pin of placed) { + ctx.beginPath(); + ctx.moveTo(pin.x, flatY - PITCH * 0.1); + ctx.lineTo(pin.x, flatY + PITCH * 0.16); + ctx.stroke(); + } + + ctx.fillStyle = colors.transistorBody; + ctx.beginPath(); + ctx.moveTo(left, flatY); + ctx.lineTo(right, flatY); + ctx.lineTo(right, backY + PITCH * 0.22); + ctx.quadraticCurveTo((left + right) / 2, backY - PITCH * 0.28, left, backY + PITCH * 0.22); + ctx.closePath(); + ctx.fill(); + + const def = getComponentDef(component.type); + ctx.fillStyle = colors.chipLabel; + ctx.textAlign = 'center'; + + if (zoom > 1.4) { + // Leg letters, taken from the registry's pin names so they can never disagree + // with the netlist. They follow the pins, so a rotated part reads correctly. + ctx.font = `${PITCH * 0.26}px system-ui, -apple-system, "Segoe UI", sans-serif`; + ctx.textBaseline = 'bottom'; + ctx.globalAlpha = 0.7; + for (let i = 0; i < 3; i++) { + if (pins[i]) ctx.fillText(def.pins[i].name[0].toUpperCase(), pins[i].x, flatY - PITCH * 0.08); + } + ctx.globalAlpha = 1; + } + + if (zoom > 0.9) { + ctx.font = `${PITCH * 0.3}px system-ui, -apple-system, "Segoe UI", sans-serif`; + ctx.textBaseline = 'middle'; + ctx.fillText(def.label, (left + right) / 2, backY + PITCH * 0.3); + } +} + function drawPushButton(ctx, component, pins, colors, state) { const placed = pins.filter(p => p !== null); if (placed.length < 4) return; @@ -387,6 +476,10 @@ export function drawComponent(ctx, component, boards, colors, state, zoom) { switch (component.type) { case 'led': drawLed(ctx, component, pins, colors, state); break; case 'resistor': drawResistor(ctx, component, pins, colors, state, zoom); break; + case 'diode': drawDiode(ctx, component, pins, colors); break; + case 'npn': case 'pnp': case 'nmos': case 'pmos': + drawTransistor(ctx, component, pins, colors, zoom); + break; case 'pushButton': drawPushButton(ctx, component, pins, colors, state); break; case 'dipSwitch8': drawDipSwitch(ctx, component, pins, colors, state, zoom); break; case 'powerSupply5V': drawPowerSupply(ctx, component, pins, colors, state, zoom, boards); break; diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js index 5665946..5191e68 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js @@ -7,9 +7,10 @@ import { el, button, createListenerBag } from './dom.js'; import { COMPONENT_TYPES, getComponentDef } from '../shared/component-registry.js'; import { WIRE_COLORS, DEFAULT_WIRE_COLOR } from '../shared/circuit-schema.js'; -const CATEGORY_ORDER = Object.freeze(['passive', 'output', 'input', 'power', 'chip']); +const CATEGORY_ORDER = Object.freeze(['passive', 'semiconductor', 'output', 'input', 'power', 'chip']); const CATEGORY_LABELS = Object.freeze(Object.assign(Object.create(null), { passive: 'Passive', + semiconductor: 'Semiconductor', output: 'Output', input: 'Input', power: 'Power', diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js index 2be70dc..27b8817 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js @@ -27,6 +27,8 @@ const WARNING_LABELS = Object.freeze(Object.assign(Object.create(null), { unknownComponent: 'Unrecognised component', unconnectedSupply: 'Supply is not connected to anything', selfShorted: 'Both ends are on the same net', + floatingControl: 'Nothing is connected to the base or gate', + unlimitedBaseCurrent: 'Transistor base has no series resistor', engineError: 'The simulation engine hit an internal error', warningsSuppressed: 'Some warnings were coalesced' })); @@ -39,7 +41,8 @@ const SEVERITY = Object.freeze(Object.assign(Object.create(null), { shortCircuit: 'error', contention: 'error', oscillation: 'warn', - ledOvercurrent: 'warn' + ledOvercurrent: 'warn', + unlimitedBaseCurrent: 'warn' })); function humanizeKind(kind) { diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js index fa4e547..7d61a1d 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js @@ -34,6 +34,9 @@ const TOKENS = Object.freeze({ chipPin: ['--bb-chip-pin', '#c8c8cc'], resistorBody: ['--bb-resistor-body', '#d8c49a'], resistorLead: ['--bb-resistor-lead', '#b0b0b0'], + diodeBody: ['--bb-diode-body', '#9aa7b4'], + diodeBand: ['--bb-diode-band', '#1c1c20'], + transistorBody: ['--bb-transistor-body', '#1f1f24'], buttonBody: ['--bb-button-body', '#3a3a3e'], buttonCap: ['--bb-button-cap', '#c9553f'], buttonCapDown: ['--bb-button-cap-down', '#8d3a2b'], diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/conduction.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/conduction.js new file mode 100644 index 0000000..f0ab622 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/conduction.js @@ -0,0 +1,49 @@ +/** + * Bidirectional conduction between two pins of one component. + * + * Shared by every element that opens and closes a channel: mechanical contacts + * (push button, DIP switch) and the transistors. Conduction is NOT a dynamic + * merge of the two nets — re-running union-find every time a contact moves + * would be slow, and it would invalidate the net index the UI is handed once at + * load. Each side simply drives the other with what it sees. + * + * Conduction preserves STRENGTH, unlike a resistor: a closed contact between + * the two power rails has to hand SUPPLY strength across so the short reads as + * a short circuit and not as garden-variety contention. The same is true of a + * saturated transistor, which is why it destroys itself in real life. + * + * The source net is always read EXCLUDING the element's own contribution to it, + * or a closed channel would read back the value it is itself asserting and + * latch onto it forever after the real source went away. + * + * Every caller passes a non-zero delay. It is a loop breaker rather than a + * physical figure: two elements wired in a ring would otherwise conduct round + * it at zero delay forever and trip the delta-cycle guard. + */ + +import { STRENGTH_HIGHZ, VALUE_LOW } from '../constants.js'; +import { driveStrength, driveMask, MASK_LOW, MASK_HIGH } from '../drive.js'; + +/** Passes `fromPin`'s net through to `toPin` at full strength, or opens the channel. */ +export function conduct(ctx, inst, fromPin, toPin, closed, delayNs) { + if (!closed) { + ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, delayNs); + return; + } + const drive = ctx.driveExcludingSelf(inst, fromPin); + const strength = driveStrength(drive); + const mask = driveMask(drive); + // A source that is itself unresolved (both polarities present) passes + // nothing: there is no single value to conduct. + if (strength === STRENGTH_HIGHZ || (mask !== MASK_LOW && mask !== MASK_HIGH)) { + ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, delayNs); + return; + } + ctx.drive(inst, toPin, strength, mask === MASK_HIGH ? 1 : 0, delayNs); +} + +/** Refreshes both directions of the bridge between `a` and `b`. */ +export function refreshBridge(ctx, inst, a, b, closed, delayNs) { + conduct(ctx, inst, a, b, closed, delayNs); + conduct(ctx, inst, b, a, closed, delayNs); +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/diode.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/diode.js new file mode 100644 index 0000000..292953a --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/diode.js @@ -0,0 +1,62 @@ +/** + * Diode. Pin 1 is the anode, pin 2 the banded cathode. + * + * A one-way conductor: forward biased it passes the anode's drive through to + * the cathode at full strength (conduction.js), reverse biased it passes + * nothing, and it NEVER drives its anode in either state. That asymmetry is the + * whole part — it is what makes diode-OR steering, polarity protection and a + * freewheel path across an inductive load behave differently from a wire. + * + * Forward bias is decided from the cathode read EXCLUDING this diode's own + * contribution, for the same reason the transistors do it: a diode charging an + * otherwise-floating node would otherwise see the node it had just pulled high, + * conclude it was no longer forward biased, release, and oscillate. + * + * The cathode is treated as blocking whenever it is ALREADY high — not only + * when it is driven high by something stronger. Two supplies steered into one + * node through a diode each is the ordinary case, and neither diode should + * report conducting into the other's output. + * + * LIMIT OF THIS MODEL. There is no forward voltage drop, because there is no + * voltage between 0 V and 5 V to drop it to (see resistor.js). A diode here is + * a switch that only closes one way; a chain of them does not stack up 0.7 V a + * time, and a diode cannot be used as a voltage reference. Reverse breakdown is + * not modelled either, so a Zener cannot be built from one. Forward voltage + * DOES matter for an LED, and lives in the LED's own model. + */ + +import { LEVEL_HIGH, LEVEL_WEAK_HIGH } from '../constants.js'; +import { defineModel } from './registry.js'; +import { conduct } from './conduction.js'; + +/** Loop breaker, as everywhere else conduction is instantaneous in reality. */ +const DIODE_DELAY_NS = 1; + +const PIN_ANODE = 0; +const PIN_CATHODE = 1; + +function isHigh(level) { + return level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH; +} + +function refreshBias(ctx, inst) { + // The anode needs no exclusion: a diode never drives its own anode, so its + // driver there is high-Z and contributes nothing to exclude. + const forward = isHigh(ctx.level(inst, PIN_ANODE)) && !isHigh(ctx.levelExcludingSelf(inst, PIN_CATHODE)); + conduct(ctx, inst, PIN_ANODE, PIN_CATHODE, forward, DIODE_DELAY_NS); +} + +defineModel({ + type: 'diode', + pinCount: 2, + delayNs: DIODE_DELAY_NS, + functionalPins: [[1, 2]], + + init(ctx, inst) { + refreshBias(ctx, inst); + }, + + evaluate(ctx, inst) { + refreshBias(ctx, inst); + }, +}); diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js index 00b1240..bbabf95 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js @@ -11,5 +11,7 @@ import './supply.js'; import './resistor.js'; import './switches.js'; import './led.js'; +import './diode.js'; +import './transistor.js'; export { registry, getModel, knownTypes, defineModel, WAKE_INIT, WAKE_PIN, WAKE_TIMER } from './registry.js'; diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js index 193c1c3..2dde723 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js @@ -1,50 +1,22 @@ /** * Mechanical contacts: push button and 8-position DIP switch. * - * A closed contact is modelled as a bidirectional conductor rather than a - * dynamic merge of the two nets. Re-running union-find every time a user - * presses a button would be both slow and a nightmare for the UI's net index, - * which is handed out once at load and must stay valid. - * - * Conduction preserves STRENGTH, unlike a resistor: a closed button between the - * two power rails has to hand SUPPLY strength across so the short reads as a - * short circuit and not as garden-variety contention. + * Both are pure conduction elements — see conduction.js for why a closed + * contact drives across rather than merging the two nets, and why it preserves + * drive strength while doing so. * * The 1 ns contact delay is a loop breaker, not a debounce model. Two contacts * wired in a ring would otherwise conduct round it at zero delay forever. */ -import { STRENGTH_HIGHZ, VALUE_LOW } from '../constants.js'; -import { driveStrength, driveMask, MASK_LOW, MASK_HIGH } from '../drive.js'; import { defineModel, WAKE_PIN } from './registry.js'; +import { refreshBridge } from './conduction.js'; const CONTACT_DELAY_NS = 1; -/** - * Passes `fromPin`'s net through to `toPin` at full strength, or opens the - * contact. Reads the source net excluding this element's own contribution, so - * the contact cannot latch onto the value it is itself asserting. - */ -function conduct(ctx, inst, fromPin, toPin, closed) { - if (!closed) { - ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, CONTACT_DELAY_NS); - return; - } - const drive = ctx.driveExcludingSelf(inst, fromPin); - const strength = driveStrength(drive); - const mask = driveMask(drive); - // A source that is itself unresolved (both polarities present) passes - // nothing: there is no single value to conduct. - if (strength === STRENGTH_HIGHZ || (mask !== MASK_LOW && mask !== MASK_HIGH)) { - ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, CONTACT_DELAY_NS); - return; - } - ctx.drive(inst, toPin, strength, mask === MASK_HIGH ? 1 : 0, CONTACT_DELAY_NS); -} - -function refreshContact(ctx, inst, a, b, closed) { - conduct(ctx, inst, a, b, closed); - conduct(ctx, inst, b, a, closed); +/** Closes or opens the contact bridging `a` and `b`. */ +function setContact(ctx, inst, a, b, closed) { + refreshBridge(ctx, inst, a, b, closed, CONTACT_DELAY_NS); } /* ------------------------------------------------------------------ * @@ -70,12 +42,12 @@ defineModel({ }, init(ctx, inst) { - refreshContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed); + setContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed); }, evaluate(ctx, inst, wake) { if (wake.reason === WAKE_PIN && wake.pin !== BUTTON_A && wake.pin !== BUTTON_B) return; - refreshContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed); + setContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed); }, /** `{ type:"input", uid, value }` — value is a boolean: pressed or released. */ @@ -83,7 +55,7 @@ defineModel({ const pressed = value === true || value?.pressed === true; if (inst.state.pressed === pressed) return; inst.state.pressed = pressed; - refreshContact(ctx, inst, BUTTON_A, BUTTON_B, pressed); + setContact(ctx, inst, BUTTON_A, BUTTON_B, pressed); }, }); @@ -116,7 +88,7 @@ defineModel({ init(ctx, inst) { for (let k = 1; k <= DIP_SWITCH_COUNT; k++) { const [a, b] = dipPins(k); - refreshContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); + setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); } }, @@ -129,7 +101,7 @@ defineModel({ if (wake.reason !== WAKE_PIN) { for (let k = 1; k <= DIP_SWITCH_COUNT; k++) { const [a, b] = dipPins(k); - refreshContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); + setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); } return; } @@ -138,7 +110,7 @@ defineModel({ if (!(pin >= 0) || pin > 15) return; const k = pin < DIP_SWITCH_COUNT ? pin + 1 : 16 - pin; const [a, b] = dipPins(k); - refreshContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); + setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1); }, /** @@ -152,6 +124,6 @@ defineModel({ if (inst.state.on[k - 1] === on) return; inst.state.on[k - 1] = on; const [a, b] = dipPins(k); - refreshContact(ctx, inst, a, b, on === 1); + setContact(ctx, inst, a, b, on === 1); }, }); diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/transistor.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/transistor.js new file mode 100644 index 0000000..fb873f5 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/transistor.js @@ -0,0 +1,158 @@ +/** + * Transistors: NPN and PNP bipolars, N- and P-channel MOSFETs. + * + * All four are the same device to this engine — a channel between pin 1 and + * pin 3 that the control pin on pin 2 opens and closes — so they share one + * definition factory and differ only in the polarity of the turn-on test and in + * which mistakes are worth warning about. Pin order is the physical TO-92 one, + * control terminal in the middle: emitter/base/collector, source/gate/drain. + * + * Turning on takes BOTH terminals into account, never the control pin alone. An + * NPN conducts when its base is above its emitter, so an NPN whose emitter is + * already sitting on the 5 V rail stays off no matter what its base does — the + * single most common reason a beginner's high-side switch does nothing. + * + * The channel terminal is read EXCLUDING this device's own contribution, and + * that is load-bearing rather than defensive. An emitter follower pulls its own + * emitter up to what it is switching; read plainly, the device would then see + * base and emitter at the same level, decide Vbe had collapsed, turn off, drop + * the emitter, turn on again, and oscillate forever at the switching delay. The + * exclusion asks the question that actually decides conduction: would current + * flow if this device were not already conducting? + * + * WHAT THIS MODEL IS NOT. There is no linear region and no gain: the device is + * saturated or cut off, nothing between. There is no Vbe and no Vce(sat), so a + * follower's output does not sit 0.7 V below its base — the engine has no + * voltage between 0 and 5 V to put it at (see resistor.js). And the control pin + * is a pure high-Z input drawing no base current, which is exactly why the + * unlimited-base warning below has to exist: the model cannot punish a missing + * base resistor by melting, so it says so instead. A MOSFET's intrinsic body + * diode is not modelled either, so an off device never conducts backwards. + * + * A conducting device passes drive strength through unchanged (conduction.js), + * so a transistor wired collector-to-rail and emitter-to-ground still reports + * the short circuit it would really be. + */ + +import { LEVEL_HIGH, LEVEL_WEAK_HIGH, LEVEL_LOW, LEVEL_WEAK_LOW, NO_NET, STRENGTH_SUPPLY } from '../constants.js'; +import { driveStrength } from '../drive.js'; +import { defineModel } from './registry.js'; +import { refreshBridge } from './conduction.js'; + +/** + * Switching delay, ns. Slower than a contact bridging two nets because a real + * transistor's storage and rise time genuinely dominate it, and comfortably + * non-zero so a discrete inverter ring oscillates at a rate rather than + * tripping the delta-cycle guard. + */ +const SWITCHING_DELAY_NS = 10; + +const PIN_CHANNEL_LOW = 0; // emitter / source +const PIN_CONTROL = 1; // base / gate +const PIN_CHANNEL_HIGH = 2; // collector / drain + +function isHigh(level) { + return level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH; +} + +function isLow(level) { + return level === LEVEL_LOW || level === LEVEL_WEAK_LOW; +} + +/** + * Whether the channel is open. A floating control pin reads as neither high nor + * low and therefore leaves the device off, which is the right answer for a + * bipolar (no base current, no conduction) and the safe one for a MOSFET, whose + * real gate would drift somewhere unpredictable — hence the load-time warning. + */ +function isConducting(ctx, inst) { + const control = ctx.level(inst, PIN_CONTROL); + const channel = ctx.levelExcludingSelf(inst, PIN_CHANNEL_LOW); + return inst.model.pChannel ? isLow(control) && isHigh(channel) : isHigh(control) && isLow(channel); +} + +function refreshChannel(ctx, inst) { + const conducting = isConducting(ctx, inst); + refreshBridge(ctx, inst, PIN_CHANNEL_LOW, PIN_CHANNEL_HIGH, conducting, SWITCHING_DELAY_NS); + return conducting; +} + +/** A control pin nothing else touches can never switch the device. */ +function warnIfControlFloating(ctx, inst) { + const net = ctx.netOf(inst, PIN_CONTROL); + if (net !== NO_NET && ctx.netListenerCount(net) > 1) return; + ctx.staticWarning( + 'floatingControl', + `${inst.type} ${inst.uid}: nothing is connected to its ${inst.model.controlName}, so it can never switch on`, + [inst.uid], + ); +} + +/** + * Base current is what destroys a bipolar driven straight from a logic output, + * and this engine draws none — so the mistake is reported the first time the + * device actually conducts with an unlimited base rather than at load, where a + * base deliberately tied to ground to hold the part off would trip it too. + * Latched per instance: a transistor switching at 1 MHz must not re-warn. + * + * Evidence of a base resistor is a resistor touching the base's net, which is + * the same dominant-resistance approximation the LED uses and inherits the same + * blind spot — a resistor on that net need not be in series with the base. The + * one place that approximation is not merely imprecise but WRONG is a base + * clipped straight onto a power rail: every part on the board shares its rails, + * so a rail nearly always carries some resistor, yet nothing is in series with + * a base sitting on it. Supply drive strength is exactly what identifies that + * case, so it is tested first and overrides the resistor evidence. + * + * The result errs one way only: it can stay quiet about a real mistake, and + * never invents one. + */ +function warnIfBaseUnlimited(ctx, inst) { + if (inst.state.warnedBaseDrive) return; + const net = ctx.netOf(inst, PIN_CONTROL); + if (net === NO_NET) return; + const onSupplyRail = driveStrength(ctx.driveExcludingSelf(inst, PIN_CONTROL)) === STRENGTH_SUPPLY; + if (!onSupplyRail && ctx.dominantSeriesOhms(net) > 0) return; + inst.state.warnedBaseDrive = true; + ctx.warn( + 'unlimitedBaseCurrent', + [inst.uid], + net, + `${inst.type} ${inst.uid} is switched on through a base with no series resistor; a real one would draw destructive base current`, + ); +} + +function defineTransistor(definition) { + defineModel({ + pinCount: 3, + delayNs: SWITCHING_DELAY_NS, + // The two channel terminals must reach different nets for the device to + // switch anything; the control pin may legitimately share a net with + // either (a gate tied to its source is simply held off). + functionalPins: [[1, 3]], + + createState() { + return { warnedBaseDrive: false }; + }, + + init(ctx, inst) { + warnIfControlFloating(ctx, inst); + refreshChannel(ctx, inst); + }, + + evaluate(ctx, inst) { + // Any pin can change the answer: the control pin decides drive, the + // channel-low pin decides whether there is a potential to drive it + // with, and the channel-high pin changes what gets passed across. + const conducting = refreshChannel(ctx, inst); + if (conducting && inst.model.bipolar) warnIfBaseUnlimited(ctx, inst); + }, + + ...definition, + }); +} + +defineTransistor({ type: 'npn', pChannel: false, bipolar: true, controlName: 'base' }); +defineTransistor({ type: 'pnp', pChannel: true, bipolar: true, controlName: 'base' }); +defineTransistor({ type: 'nmos', pChannel: false, bipolar: false, controlName: 'gate' }); +defineTransistor({ type: 'pmos', pChannel: true, bipolar: false, controlName: 'gate' }); diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js index b2cc569..058900d 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js @@ -116,6 +116,52 @@ function chipDef(type) { }; } +/** + * Orientations a three-legged inline package may carry. Its legs run along a row, so + * only the two horizontal directions leave each leg in a strip of its own: a column's + * rows a-e are one strip, which would short two legs of any vertical placement. + */ +export const INLINE_ORIENTATIONS = Object.freeze(['left', 'right']); + +/** Footprint for a part whose pins step away from the anchor in the orient direction. */ +function orientStepFootprint(pinCount) { + const pins = []; + for (let pin = 1; pin <= pinCount; pin++) { + pins.push(pin === 1 + ? Object.freeze({ pin, at: 'anchor' }) + : Object.freeze({ pin, at: 'orientStep', steps: pin - 1 })); + } + return Object.freeze({ kind: 'orientStep', pinCount, pins: Object.freeze(pins) }); +} + +/** + * The four transistors differ only in polarity and in what their legs are called; the + * package, the footprint and the placement rules are one part. Pin order is the + * physical TO-92 one, control terminal in the middle. + */ +function transistorDef(type, label, description, pinNames) { + return { + type, + label, + description, + category: 'semiconductor', + pins: namedPins(pinNames), + bodyColumns: 3, + straddlesGap: false, + anchorRows: null, + // A power rail is one continuous strip, so all three legs there would be common. + anchorKinds: Object.freeze(['main']), + anchorless: false, + orientable: true, + orientValues: INLINE_ORIENTATIONS, + defaultOrient: 'right', + dipStyle: false, + footprint: orientStepFootprint(3), + defaultProps: Object.freeze({}), + propSpecs: Object.freeze({}) + }; +} + const DEFS = Object.create(null); function define(def) { @@ -186,6 +232,45 @@ define({ }) }); +define({ + type: 'diode', + label: 'Diode', + description: 'Signal diode (1N4148). Anchor is the anode; the banded cathode sits one hole away. Passes current one way only.', + category: 'semiconductor', + pins: namedPins(['anode', 'cathode']), + bodyColumns: 1, + straddlesGap: false, + anchorRows: null, + anchorKinds: Object.freeze(['main', 'rail']), + anchorless: false, + orientable: true, + orientValues: Object.freeze(['up', 'down', 'left', 'right']), + // Same reasoning as the LED: 'right' is the only default that reaches a different + // strip from every main column and also works from a rail hole. + defaultOrient: 'right', + dipStyle: false, + footprint: orientStepFootprint(2), + defaultProps: Object.freeze({}), + propSpecs: Object.freeze({}) +}); + +const TRANSISTOR_DEFS = Object.freeze([ + transistorDef('npn', 'NPN', + 'NPN transistor (2N3904): emitter, base, collector. Conducts when the base is high and the emitter is the low side, so it switches a load to ground.', + ['emitter', 'base', 'collector']), + transistorDef('pnp', 'PNP', + 'PNP transistor (2N3906): emitter, base, collector. Conducts when the base is low and the emitter is the high side, so it switches a load to the supply.', + ['emitter', 'base', 'collector']), + transistorDef('nmos', 'N-MOSFET', + 'N-channel MOSFET (2N7000): source, gate, drain. Conducts when the gate is high and the source is the low side. The gate draws no current, so it needs a pull-down to stay off.', + ['source', 'gate', 'drain']), + transistorDef('pmos', 'P-MOSFET', + 'P-channel MOSFET (BS250): source, gate, drain. Conducts when the gate is low and the source is the high side. The gate draws no current, so it needs a pull-up to stay off.', + ['source', 'gate', 'drain']) +]); + +for (const def of TRANSISTOR_DEFS) define(def); + define({ type: 'pushButton', label: 'Push button', @@ -277,7 +362,8 @@ Object.freeze(DEFS); /** All registered component type names, in palette order. */ export const COMPONENT_TYPES = Object.freeze([ - 'led', 'resistor', 'pushButton', 'dipSwitch8', 'powerSupply5V', ...CHIP_TYPES + 'led', 'resistor', 'diode', ...TRANSISTOR_DEFS.map(d => d.type), + 'pushButton', 'dipSwitch8', 'powerSupply5V', ...CHIP_TYPES ]); /** Valid `orient` values for orientable components. */