, which the Razor page renders empty - all chrome is built here.
+//
+// Everything user-supplied (project name, server messages, engine detail strings)
+// reaches the DOM through textContent via the dom.js helpers. Nothing uses innerHTML.
+
+import { el, createListenerBag, debounce } from './dom.js';
+import { createPalette as createThemePalette } from './theme-colors.js';
+import { createViewport } from './viewport.js';
+import { createRenderer } from './renderer.js';
+import { createEditorState } from './editor-state.js';
+import { createTools } from './tools.js';
+import { createToolbar } from './toolbar.js';
+import { createPalette } from './palette.js';
+import { createProperties } from './properties.js';
+import { createStatus } from './status.js';
+import { createApi, ApiError } from './api.js';
+import { createSimClient } from './sim-client.js';
+import { describeServerErrors } from './server-errors.js';
+
+import {
+ normalizeCircuitWithReport,
+ createCircuit,
+ circuitBounds
+} from '../shared/circuit-schema.js';
+
+import { boardBounds } from '../shared/board-geometry.js';
+
+/** How long after the last edit the simulation is re-loaded. */
+const SIM_RELOAD_DEBOUNCE_MS = 350;
+
+/** How long after the last edit an autosave fires. */
+const AUTOSAVE_DEBOUNCE_MS = 4000;
+
+const VIEW_STORAGE_PREFIX = 'bb-view-';
+
+function readView(projectId) {
+ try {
+ const raw = localStorage.getItem(VIEW_STORAGE_PREFIX + projectId);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null; // private mode, quota, corrupt entry - the view is optional
+ }
+}
+
+function writeView(projectId, view) {
+ try {
+ localStorage.setItem(VIEW_STORAGE_PREFIX + projectId, JSON.stringify(view));
+ } catch {
+ // Losing the remembered viewport is not worth surfacing.
+ }
+}
+
+export function boot(root) {
+ const projectId = root.dataset.projectId;
+ const apiBase = root.dataset.apiBase || '/api/breadboard';
+
+ // Shell
+ const toolbarHost = el('div', { className: 'bb-toolbar-host' });
+ const canvasContainer = el('div', { className: 'bb-canvas-container' });
+ const paletteHost = el('div', { className: 'bb-palette-host' });
+ const propsHost = el('div', { className: 'bb-props-host' });
+ const statusHost = el('div', { className: 'bb-status-host', id: 'bb-warnings' });
+ const workspace = el('div', {
+ className: 'bb-workspace',
+ children: [paletteHost, canvasContainer, propsHost]
+ });
+ const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace] });
+ root.appendChild(shell);
+
+ // The status strip floats over the bottom of the canvas rather than sitting below
+ // it in the column. In the column, every message that appeared or timed out resized
+ // the canvas container, which reallocated all four backing stores and forced a full
+ // repaint - twice per message.
+ canvasContainer.appendChild(statusHost);
+
+ const status = createStatus(statusHost);
+
+ if (!projectId) {
+ status.error('This page did not receive a project id, so there is nothing to edit.');
+ return { destroy() { status.destroy(); root.removeChild(shell); } };
+ }
+
+ const bag = createListenerBag();
+ const api = createApi(apiBase);
+ const themePalette = createThemePalette(root);
+ const viewport = createViewport();
+ const renderer = createRenderer(canvasContainer, viewport, themePalette, {
+ onPaintError(layer, message) {
+ // A blank canvas with no explanation is the worst possible failure mode.
+ status.error(`The ${layer} layer failed to draw: ${message}`);
+ }
+ });
+ const state = createEditorState(createCircuit());
+
+ let destroyed = false;
+ let saving = false;
+ /** Pending properties-panel rebuild. See scheduleProperties. */
+ let propertiesFrame = null;
+ let simLoadedOnce = false;
+ /** Components named by a simulation warning, highlighted on the canvas. */
+ const warnedUids = new Set();
+
+ /**
+ * Record the components a warning blames, so the overlay can point at them.
+ *
+ * Single rule for BOTH the load-time warnings array and the streaming warning
+ * message: warningsSuppressed is a coalescing summary rather than a fault on any
+ * component, so it must never paint a highlight. Enforcing it in one place stops
+ * the two paths from disagreeing.
+ */
+ function addWarnedUids(warning) {
+ if (!warning || warning.kind === 'warningsSuppressed') return;
+ for (const uid of warning.uids || []) warnedUids.add(uid);
+ }
+
+ // --- Simulation ---
+
+ const sim = createSimClient({
+ loaded({ netCount, warnings, failed }) {
+ simLoadedOnce = true;
+ if (!failed) warnedUids.clear();
+ for (const warning of warnings) addWarnedUids(warning);
+ // A failed load still posts `loaded` so nothing waits forever. Keep the
+ // error that came with it instead of clearing the list and announcing
+ // success, and do not report the failure a second time.
+ if (!failed) status.clearWarnings();
+ for (const warning of warnings) status.addWarning(warning);
+ refreshSimState();
+ pushSimScene('dynamic', 'overlay');
+ if (!failed && netCount > 0) {
+ status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`);
+ }
+ },
+ frame() {
+ pushSimScene();
+ refreshSimState();
+ },
+ warning(warning) {
+ status.addWarning(warning);
+ addWarnedUids(warning);
+ // Repaint here rather than waiting for an unrelated message: shortCircuit
+ // and oscillation HALT the engine, so there may be no further frame at all -
+ // and those are exactly the warnings whose components most need pointing at.
+ pushSimScene('dynamic', 'overlay');
+ // A halt is worth an explicit message; the engine refuses to run into it.
+ if (warning.kind === 'shortCircuit') {
+ status.error('Simulation halted: the supply rails are shorted together.');
+ } else if (warning.kind === 'oscillation') {
+ status.warn('Simulation halted: the circuit oscillates without settling.');
+ }
+ refreshSimState();
+ },
+ unavailable(message) {
+ status.warn(`Simulation is unavailable: ${message}`);
+ refreshSimState();
+ }
+ });
+
+ /**
+ * Hand the current simulation state to the renderer.
+ *
+ * Defaults to the dynamic layer alone. The overlay only shows simulation state
+ * through `warnedUids`, which changes on a warning - not on every frame - so
+ * callers that touch warnedUids pass 'dynamic', 'overlay' explicitly.
+ */
+ function pushSimScene(...layers) {
+ renderer.setScene({
+ netLevels: sim.state.netLevels,
+ netOfStrip: sim.state.netOfStrip,
+ ledBrightness: sim.state.ledBrightness,
+ burned: sim.state.burned,
+ warnedUids,
+ simActive: sim.state.loaded
+ }, ...(layers.length > 0 ? layers : ['dynamic']));
+ }
+
+ /**
+ * Rebuild the properties panel at most once per frame. render() throws away and
+ * rebuilds the whole panel, and a component drag fires a circuit change on every
+ * pointermove, so calling it directly meant a full DOM teardown per mouse move.
+ */
+ function scheduleProperties() {
+ if (propertiesFrame !== null) return;
+ propertiesFrame = requestAnimationFrame(() => {
+ propertiesFrame = null;
+ if (!destroyed) properties.render(state);
+ });
+ }
+
+ function refreshSimState() {
+ toolbar.setSimState({
+ available: sim.available,
+ running: sim.state.running,
+ settled: sim.state.settled,
+ halted: sim.state.halted,
+ loaded: simLoadedOnce
+ });
+ }
+
+ const reloadSim = debounce(() => {
+ if (destroyed || !sim.available) return;
+ warnedUids.clear();
+ sim.load(state.circuit);
+ }, SIM_RELOAD_DEBOUNCE_MS);
+
+ // --- Persistence ---
+
+ function updateSaveState() {
+ if (saving) {
+ toolbar.setSaveState('saving', 'Saving…');
+ } else if (state.dirty) {
+ toolbar.setSaveState('dirty', 'Unsaved changes');
+ } else {
+ toolbar.setSaveState('clean', 'Saved');
+ }
+ }
+
+ async function save() {
+ if (destroyed || saving || !state.dirty) return;
+ saving = true;
+ updateSaveState();
+ try {
+ // The reporting form, so anything the document cannot carry is surfaced
+ // rather than silently dropped on the way out.
+ // Neutral lead-in: some problems are renames, which ARE saved, just under
+ // a different id. "Not saved" would be false for those.
+ const { problems } = normalizeCircuitWithReport(state.circuit);
+ for (const problem of problems) status.warn(`On save: ${problem.reason}`);
+
+ const sent = await api.saveCircuit(projectId, state.circuit);
+ state.markSaved(sent);
+ status.info('Saved.');
+ } catch (error) {
+ if (error instanceof ApiError) {
+ status.errors(describeServerErrors(error.messages));
+ } else {
+ status.error('Saving failed unexpectedly.');
+ }
+ } finally {
+ saving = false;
+ updateSaveState();
+ }
+ }
+
+ const autosave = debounce(() => {
+ if (state.dirty && !saving) save();
+ }, AUTOSAVE_DEBOUNCE_MS);
+
+ // --- Chrome ---
+
+ const toolbar = createToolbar(toolbarHost, {
+ projectName: root.dataset.projectName || 'Breadboard',
+ onSave: save,
+ onRun: () => { sim.run(); refreshSimState(); },
+ onPause: () => { sim.pause(); refreshSimState(); },
+ onStep: () => sim.step(1),
+ onReset: () => {
+ // Clear BEFORE issuing the command: sending it can itself fail (an
+ // unclonable payload throws in postMessage), and clearing afterwards would
+ // wipe the very error the reset produced.
+ warnedUids.clear();
+ status.clearWarnings();
+ sim.reset();
+ status.info('Simulation reset.');
+ pushSimScene('dynamic', 'overlay');
+ },
+ onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
+ onAddBoard: () => {
+ const result = state.addBoard();
+ if (!result.ok) status.warn(result.reason);
+ else status.info(`Added board ${result.board.uid}.`);
+ },
+ onZoom: (factor) => {
+ viewport.zoomAtCenter(renderer.width, renderer.height, factor);
+ afterViewportChange();
+ },
+ onZoomFit: () => fitAll()
+ });
+
+ const palette = createPalette(paletteHost, {
+ onTool: (tool) => {
+ tools.setTool(tool);
+ palette.setActiveTool(tool);
+ },
+ onWireColor: (color) => tools.setWireColor(color)
+ });
+
+ const properties = createProperties(propsHost, {
+ onChangeProps: (uid, changes) => {
+ const result = state.setComponentProps(uid, changes);
+ if (!result.ok) status.warn(result.reason);
+ },
+ onToggleSwitch: (uid, switchNumber) => {
+ const on = state.toggleSwitch(uid, switchNumber);
+ if (on !== null) sim.setSwitch(uid, switchNumber, on);
+ },
+ onRotate: (uid) => {
+ const result = state.rotateComponent(uid);
+ if (!result.ok) status.warn(result.reason);
+ },
+ onDelete: (uid) => {
+ state.select(uid);
+ const removed = state.deleteSelected();
+ if (removed > 0) status.info('Deleted.');
+ },
+ onDeleteSelection: () => {
+ const removed = state.deleteSelected();
+ if (removed > 0) status.info(`Deleted ${removed} items.`);
+ },
+ onFocusBoard: (uid) => {
+ const board = state.circuit.boards.find(b => b.uid === uid);
+ if (!board) return;
+ viewport.fit(boardBounds(board), renderer.width, renderer.height);
+ afterViewportChange();
+ },
+ onRemoveBoard: (uid) => {
+ const result = state.removeBoard(uid);
+ if (!result.ok) status.warn(result.reason);
+ else status.info(`Removed board ${uid}${result.removed > 0 ? ` and ${result.removed} item(s) on it` : ''}.`);
+ }
+ });
+
+ const tools = createTools({
+ canvas: renderer.canvas,
+ viewport,
+ state,
+ renderer,
+ sim,
+ status,
+ initialWireColor: palette.wireColor,
+ setTool: (tool) => {
+ tools.setTool(tool);
+ palette.setActiveTool(tool);
+ },
+ onSceneChange: () => {
+ toolbar.setZoom(viewport.zoom);
+ persistView();
+ },
+ onSelectionChange: () => scheduleProperties()
+ });
+
+ palette.setActiveTool({ kind: 'select', type: null });
+
+ // --- Wiring ---
+
+ const persistView = debounce(() => writeView(projectId, viewport.toJSON()), 400);
+
+ function afterViewportChange() {
+ renderer.viewportChanged();
+ toolbar.setZoom(viewport.zoom);
+ persistView();
+ }
+
+ function fitAll() {
+ viewport.fit(circuitBounds(state.circuit), renderer.width, renderer.height);
+ afterViewportChange();
+ }
+
+ function onContainerResize() {
+ tools.invalidateRect();
+ renderer.resize();
+ }
+
+ bag.on(window, 'resize', onContainerResize);
+ const resizeObserver = typeof ResizeObserver === 'function'
+ ? new ResizeObserver(onContainerResize)
+ : null;
+ if (resizeObserver) resizeObserver.observe(canvasContainer);
+
+ bag.on(window, 'keydown', (event) => {
+ if ((event.ctrlKey || event.metaKey) && (event.key === 's' || event.key === 'S')) {
+ event.preventDefault();
+ save();
+ }
+ });
+
+ bag.on(window, 'beforeunload', (event) => {
+ if (!state.dirty) return;
+ event.preventDefault();
+ event.returnValue = '';
+ });
+
+ state.subscribe((change) => {
+ if (change.kind === 'circuit') {
+ tools.refresh('board', 'static', 'dynamic', 'overlay');
+ scheduleProperties();
+ reloadSim();
+ autosave();
+ } else if (change.kind === 'runtime') {
+ tools.refresh('dynamic', 'overlay');
+ }
+ updateSaveState();
+ });
+
+ // --- Load ---
+
+ async function load() {
+ try {
+ const project = await api.getProject(projectId);
+ if (destroyed) return;
+
+ toolbar.setProjectName(project.name);
+ if (project.name) document.title = `${project.name} — Breadboard`;
+
+ const { circuit, problems } = normalizeCircuitWithReport(project.circuit);
+ state.replace(circuit);
+
+ // Nothing the document could not carry is allowed to vanish quietly.
+ for (const problem of problems) status.warn(problem.reason);
+ if (problems.length > 0) {
+ status.warn(`${problems.length} item${problems.length === 1 ? '' : 's'} in the saved circuit could not be loaded exactly as stored.`);
+ }
+
+ renderer.resize();
+ const savedView = readView(projectId);
+ if (!viewport.restore(savedView)) fitAll();
+ else afterViewportChange();
+
+ tools.refresh('board', 'static', 'dynamic', 'overlay');
+ scheduleProperties();
+ updateSaveState();
+
+ if (sim.start()) sim.load(circuit);
+ sim.setSpeed(toolbar.initialSpeed);
+ refreshSimState();
+ } catch (error) {
+ if (error instanceof ApiError) status.errors(describeServerErrors(error.messages));
+ else status.error('The project could not be loaded.');
+ toolbar.setSaveState('error', 'Not loaded');
+ }
+ }
+
+ load();
+
+ return {
+ destroy() {
+ destroyed = true;
+ reloadSim.cancel();
+ autosave.cancel();
+ persistView.cancel();
+ if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame);
+ bag.removeAll();
+ if (resizeObserver) resizeObserver.disconnect();
+ tools.destroy();
+ properties.destroy();
+ palette.destroy();
+ toolbar.destroy();
+ renderer.destroy();
+ themePalette.destroy();
+ sim.destroy();
+ status.destroy();
+ if (shell.parentNode) shell.parentNode.removeChild(shell);
+ }
+ };
+}
+
+// Auto-boot. Module scripts are deferred, so the DOM is normally parsed by now, but
+// guard anyway rather than assuming either way.
+function start() {
+ const root = document.getElementById('breadboard-editor');
+ if (root && !root.dataset.booted) {
+ root.dataset.booted = 'true';
+ boot(root);
+ }
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start, { once: true });
+} else {
+ start();
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js
new file mode 100644
index 0000000..5191e68
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js
@@ -0,0 +1,127 @@
+// Component palette and tool selection.
+//
+// Driven entirely by the shared component registry, so a new component type appears
+// here without touching this file.
+
+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', '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',
+ chip: 'Logic'
+}));
+
+export function createPalette(root, handlers) {
+ const bag = createListenerBag();
+ const toolButtons = new Map();
+
+ function toolButton(key, label, title) {
+ const node = button(label, { className: 'bb-tool', title });
+ node.dataset.tool = key;
+ toolButtons.set(key, node);
+ return node;
+ }
+
+ const selectButton = toolButton('select', 'Select', 'Select, move and rotate parts (Esc)');
+ const wireButton = toolButton('wire', 'Wire', 'Drag from hole to hole to run a wire');
+
+ const colorSwatches = el('div', { className: 'bb-swatches' });
+ let activeColor = DEFAULT_WIRE_COLOR;
+ const swatchNodes = new Map();
+ for (const color of WIRE_COLORS) {
+ const swatch = button('', {
+ className: 'bb-swatch',
+ title: `Wire colour ${color}`,
+ attrs: { 'aria-label': `Wire colour ${color}` }
+ });
+ swatch.style.background = color;
+ swatchNodes.set(color, swatch);
+ bag.on(swatch, 'click', () => {
+ setColor(color);
+ handlers.onWireColor(color);
+ });
+ colorSwatches.appendChild(swatch);
+ }
+
+ function setColor(color) {
+ activeColor = color;
+ for (const [value, node] of swatchNodes) node.classList.toggle('is-active', value === color);
+ }
+ setColor(DEFAULT_WIRE_COLOR);
+
+ const sections = [
+ el('div', {
+ className: 'bb-palette-section',
+ children: [
+ el('h2', { className: 'bb-palette-heading', text: 'Tools' }),
+ el('div', { className: 'bb-palette-grid', children: [selectButton, wireButton] }),
+ colorSwatches
+ ]
+ })
+ ];
+
+ // Component buttons, grouped by registry category.
+ const byCategory = new Map();
+ for (const type of COMPONENT_TYPES) {
+ const def = getComponentDef(type);
+ if (!byCategory.has(def.category)) byCategory.set(def.category, []);
+ byCategory.get(def.category).push(def);
+ }
+
+ for (const category of CATEGORY_ORDER) {
+ const defs = byCategory.get(category);
+ if (!defs || defs.length === 0) continue;
+ const grid = el('div', { className: 'bb-palette-grid' });
+ for (const def of defs) {
+ const node = toolButton(`place:${def.type}`, def.label, def.description || def.label);
+ node.classList.add('bb-tool-component');
+ grid.appendChild(node);
+ }
+ sections.push(el('div', {
+ className: 'bb-palette-section',
+ children: [
+ el('h2', { className: 'bb-palette-heading', text: CATEGORY_LABELS[category] || category }),
+ grid
+ ]
+ }));
+ }
+
+ const hint = el('p', {
+ className: 'bb-palette-hint',
+ text: 'Space or middle-drag pans. Scroll to zoom. R rotates, Delete removes.'
+ });
+ sections.push(hint);
+
+ const panel = el('aside', { className: 'bb-palette', children: sections });
+ root.appendChild(panel);
+
+ for (const [key, node] of toolButtons) {
+ bag.on(node, 'click', () => {
+ if (key === 'select') handlers.onTool({ kind: 'select', type: null });
+ else if (key === 'wire') handlers.onTool({ kind: 'wire', type: null });
+ else handlers.onTool({ kind: 'place', type: key.slice('place:'.length) });
+ });
+ }
+
+ return {
+ get wireColor() { return activeColor; },
+
+ setActiveTool(tool) {
+ const key = tool.kind === 'place' ? `place:${tool.type}` : tool.kind;
+ for (const [name, node] of toolButtons) node.classList.toggle('is-active', name === key);
+ },
+
+ setWireColor: setColor,
+
+ destroy() {
+ bag.removeAll();
+ if (panel.parentNode) panel.parentNode.removeChild(panel);
+ }
+ };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/properties.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/properties.js
new file mode 100644
index 0000000..6a15719
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/properties.js
@@ -0,0 +1,248 @@
+// Properties side panel: edits the selected component, and manages boards.
+//
+// Controls are generated from the registry's propSpecs, so a new scalar property on a
+// component type gets an editor here for free.
+
+import { el, button, setText, clear, createListenerBag, formatOhms } from './dom.js';
+import { getComponentDef, LED_COLORS } from '../shared/component-registry.js';
+import { componentSelfShorts, componentPinsWithNames } from '../shared/component-pins.js';
+
+function field(labelText, control) {
+ return el('label', {
+ className: 'bb-field',
+ children: [el('span', { className: 'bb-field-label', text: labelText }), control]
+ });
+}
+
+export function createProperties(root, handlers) {
+ // Panel-lifetime listeners.
+ const bag = createListenerBag();
+ // Listeners for the controls rebuilt on every render. Cleared each time, otherwise
+ // the bag would keep every detached button and its closure alive for the session.
+ const renderBag = createListenerBag();
+ const body = el('div', { className: 'bb-props-body' });
+ const boardsBody = el('div', { className: 'bb-boards-body' });
+
+ const panel = el('aside', {
+ className: 'bb-props',
+ children: [
+ el('h2', { className: 'bb-props-heading', text: 'Properties' }),
+ body,
+ el('h2', { className: 'bb-props-heading', text: 'Boards' }),
+ boardsBody
+ ]
+ });
+ root.appendChild(panel);
+
+ function buildEnumControl(component, key, spec) {
+ const select = el('select', { className: 'bb-input' });
+ // LED colours get their swatch shown alongside the name.
+ const options = key === 'color'
+ ? LED_COLORS.map(c => ({ value: c.value, label: c.label }))
+ : spec.values.map(v => ({ value: v, label: v }));
+ for (const option of options) {
+ const node = el('option', { text: option.label, attrs: { value: option.value } });
+ if (component.props[key] === option.value) node.selected = true;
+ select.appendChild(node);
+ }
+ renderBag.on(select, 'change', () => handlers.onChangeProps(component.uid, { [key]: select.value }));
+ return select;
+ }
+
+ function buildNumberControl(component, key, spec) {
+ const input = el('input', {
+ className: 'bb-input',
+ attrs: {
+ type: 'number', min: String(spec.min), max: String(spec.max),
+ step: '1', value: String(component.props[key])
+ }
+ });
+ const commit = () => {
+ const value = Number(input.value);
+ if (!Number.isFinite(value)) {
+ input.value = String(component.props[key]);
+ return;
+ }
+ const clamped = Math.min(spec.max, Math.max(spec.min, value));
+ input.value = String(clamped);
+ handlers.onChangeProps(component.uid, { [key]: clamped });
+ };
+ renderBag.on(input, 'change', commit);
+ renderBag.on(input, 'keydown', (event) => {
+ if (event.key === 'Enter') { event.preventDefault(); commit(); }
+ });
+
+ const wrapper = el('div', { className: 'bb-field-stack', children: [input] });
+
+ if (Array.isArray(spec.presets)) {
+ const presets = el('div', { className: 'bb-presets' });
+ for (const preset of spec.presets) {
+ const node = button(formatOhms(preset), { className: 'bb-chip-btn' });
+ renderBag.on(node, 'click', () => {
+ input.value = String(preset);
+ handlers.onChangeProps(component.uid, { [key]: preset });
+ });
+ presets.appendChild(node);
+ }
+ wrapper.appendChild(presets);
+ }
+ return wrapper;
+ }
+
+ function buildBoolArrayControl(component, key, spec) {
+ const row = el('div', { className: 'bb-switch-row' });
+ const values = Array.isArray(component.props[key]) ? component.props[key] : [];
+ for (let i = 0; i < spec.length; i++) {
+ const node = button(String(i + 1), {
+ className: `bb-switch-toggle${values[i] ? ' is-on' : ''}`,
+ title: `Switch ${i + 1}: ${values[i] ? 'on' : 'off'}`
+ });
+ renderBag.on(node, 'click', () => handlers.onToggleSwitch(component.uid, i + 1));
+ row.appendChild(node);
+ }
+ return row;
+ }
+
+ function renderComponent(component) {
+ const def = getComponentDef(component.type);
+ if (def === null) return;
+
+ body.appendChild(el('div', {
+ className: 'bb-props-title',
+ children: [
+ el('strong', { text: def.label }),
+ el('span', { className: 'bb-props-uid', text: component.uid })
+ ]
+ }));
+
+ if (def.description) {
+ body.appendChild(el('p', { className: 'bb-props-description', text: def.description }));
+ }
+
+ for (const [key, spec] of Object.entries(def.propSpecs)) {
+ let control = null;
+ if (spec.kind === 'enum') control = buildEnumControl(component, key, spec);
+ else if (spec.kind === 'number') control = buildNumberControl(component, key, spec);
+ else if (spec.kind === 'boolArray') control = buildBoolArrayControl(component, key, spec);
+ if (control !== null) {
+ body.appendChild(field(spec.unit ? `${spec.label} (${spec.unit})` : spec.label, control));
+ }
+ }
+
+ // Placement summary - where the part actually sits.
+ const pins = componentPinsWithNames(component);
+ const placed = pins.filter(p => p.hole !== null);
+ if (placed.length > 0) {
+ const first = placed[0].hole;
+ const where = first.kind === 'main'
+ ? `board ${first.board}, column ${first.col} row ${first.row}`
+ : `board ${first.board}, ${first.rail} rail`;
+ body.appendChild(el('p', { className: 'bb-props-meta', text: `${pins.length} pins at ${where}` }));
+ }
+
+ const shorts = componentSelfShorts(component);
+ if (shorts.length > 0) {
+ body.appendChild(el('p', {
+ className: 'bb-props-warning',
+ text: 'Both ends of this part sit in the same connected strip, so it will have no effect.'
+ }));
+ }
+
+ const actions = el('div', { className: 'bb-props-actions' });
+ if (def.orientable) {
+ const rotate = button('Rotate (R)', { className: 'bb-btn' });
+ renderBag.on(rotate, 'click', () => handlers.onRotate(component.uid));
+ actions.appendChild(rotate);
+ }
+ const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
+ renderBag.on(remove, 'click', () => handlers.onDelete(component.uid));
+ actions.appendChild(remove);
+ body.appendChild(actions);
+ }
+
+ function renderWire(wire) {
+ body.appendChild(el('div', {
+ className: 'bb-props-title',
+ children: [el('strong', { text: 'Wire' }), el('span', { className: 'bb-props-uid', text: wire.uid })]
+ }));
+ const describe = (hole) => hole.kind === 'main'
+ ? `${hole.board} · ${hole.col}${hole.row}`
+ : `${hole.board} · ${hole.rail} ${hole.index}`;
+ body.appendChild(el('p', {
+ className: 'bb-props-meta',
+ text: `${describe(wire.from)} → ${describe(wire.to)}`
+ }));
+
+ const actions = el('div', { className: 'bb-props-actions' });
+ const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
+ renderBag.on(remove, 'click', () => handlers.onDelete(wire.uid));
+ actions.appendChild(remove);
+ body.appendChild(actions);
+ }
+
+ return {
+ /** Re-render for the current selection. */
+ render(state) {
+ renderBag.removeAll();
+ clear(body);
+ const selection = [...state.selection];
+
+ if (selection.length === 0) {
+ body.appendChild(el('p', {
+ className: 'bb-props-empty',
+ text: 'Select a part or wire to edit it.'
+ }));
+ } else if (selection.length > 1) {
+ body.appendChild(el('p', {
+ className: 'bb-props-empty',
+ text: `${selection.length} items selected.`
+ }));
+ const actions = el('div', { className: 'bb-props-actions' });
+ const remove = button(`Delete ${selection.length} items`, { className: 'bb-btn bb-btn-danger' });
+ renderBag.on(remove, 'click', () => handlers.onDeleteSelection());
+ actions.appendChild(remove);
+ body.appendChild(actions);
+ } else {
+ const uid = selection[0];
+ const component = state.circuit.components.find(c => c.uid === uid);
+ if (component) renderComponent(component);
+ else {
+ const wire = state.circuit.wires.find(w => w.uid === uid);
+ if (wire) renderWire(wire);
+ }
+ }
+
+ this.renderBoards(state);
+ },
+
+ /** Rebuilds the board list. Its listeners belong to renderBag - render() clears
+ * it first, so calling this directly is only valid from render(). */
+ renderBoards(state) {
+ clear(boardsBody);
+ for (const board of state.circuit.boards) {
+ const onBoard = state.circuit.components.filter(c =>
+ (c.anchor && c.anchor.board === board.uid) || (c.props && c.props.board === board.uid)).length;
+ const row = el('div', { className: 'bb-board-row' });
+ row.appendChild(el('span', { className: 'bb-board-name', text: board.uid }));
+ row.appendChild(el('span', { className: 'bb-board-count', text: `${onBoard} part${onBoard === 1 ? '' : 's'}` }));
+
+ const focus = button('Show', { className: 'bb-btn bb-btn-small' });
+ renderBag.on(focus, 'click', () => handlers.onFocusBoard(board.uid));
+ row.appendChild(focus);
+
+ if (state.circuit.boards.length > 1) {
+ const remove = button('Remove', { className: 'bb-btn bb-btn-small bb-btn-danger' });
+ renderBag.on(remove, 'click', () => handlers.onRemoveBoard(board.uid));
+ row.appendChild(remove);
+ }
+ boardsBody.appendChild(row);
+ }
+ },
+
+ destroy() {
+ renderBag.removeAll();
+ bag.removeAll();
+ if (panel.parentNode) panel.parentNode.removeChild(panel);
+ }
+ };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js
new file mode 100644
index 0000000..49bd807
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js
@@ -0,0 +1,428 @@
+// Layered canvas renderer.
+//
+// ============================ COORDINATE / SCALE POLICY ============================
+// devicePixelRatio lives OUTSIDE the world transform, and this is the only file that
+// knows about it. Each canvas backing store is sized cssPx * dpr; before drawing we
+// set the BASE transform to (dpr, 0, 0, dpr, 0, 0), then apply the pan/zoom world
+// transform on top of it. Consequences, relied on everywhere else:
+// - viewport.js, board-geometry.js and component-art.js never see device pixels.
+// - a "line width of 2" is 2 CSS pixels at zoom 1, on every display.
+// - hit-testing uses viewport.screenToWorld on CSS-pixel mouse coordinates, with no
+// dpr correction anywhere.
+//
+// ================================ LAYERS & DIRTYING ================================
+// Four stacked canvases, each redrawn only when something it depends on changes.
+//
+// board static board artwork, blitted from a cached bitmap (board-art.js).
+// Dirty on: board add/move/remove, theme, viewport.
+// static components whose appearance CANNOT change during simulation - chips,
+// resistors, supplies. These carry the expensive text.
+// Dirty on: circuit edit, theme, viewport.
+// dynamic wires (with net-level halos) and the components that DO change - LEDs,
+// buttons, DIP switches.
+// Dirty on: circuit edit, theme, viewport, and every simulation frame.
+// overlay hover highlight, selection, drag ghost, in-progress wire.
+// Dirty on: pointer/selection changes only.
+//
+// Each component is drawn on exactly one layer, chosen by whether simulation can alter
+// it, so a 60 fps frame never re-rasterizes chip labels and a mousemove that changes
+// nothing draws nothing.
+
+import {
+ PITCH,
+ BOARD_WIDTH,
+ BOARD_HEIGHT,
+ holeWorldPos,
+ boardBounds,
+ stripKey
+} from '../shared/board-geometry.js';
+
+import { createBoardArtCache } from './board-art.js';
+import { drawComponent, drawWire, componentBounds } from './component-art.js';
+import { rectsIntersect } from './viewport.js';
+
+/** Components whose drawn appearance depends on simulation state. */
+const VOLATILE_TYPES = new Set(['led', 'pushButton', 'dipSwitch8']);
+
+const LAYER_NAMES = Object.freeze(['board', 'static', 'dynamic', 'overlay']);
+
+export function createRenderer(container, viewport, palette, options = {}) {
+ const canvases = {};
+ const contexts = {};
+ const boardArt = createBoardArtCache();
+
+ for (const name of LAYER_NAMES) {
+ const canvas = document.createElement('canvas');
+ canvas.className = `bb-layer bb-layer-${name}`;
+ // Only the topmost layer takes pointer events; the rest are pure paint.
+ canvas.style.pointerEvents = name === 'overlay' ? 'auto' : 'none';
+ container.appendChild(canvas);
+ canvases[name] = canvas;
+ contexts[name] = canvas.getContext('2d');
+ }
+ canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard
+
+ // Latest MEASURED size, in CSS pixels. Read straight after resize() so callers
+ // that fit the view can trust it.
+ let cssWidth = 0;
+ let cssHeight = 0;
+ let dpr = 1;
+ // Size the backing stores currently hold. Reallocating them costs tens of MB of
+ // churn, so it happens once per frame at most, inside paint().
+ let appliedWidth = 0;
+ let appliedHeight = 0;
+ let appliedDpr = 0;
+ const dirty = { board: true, static: true, dynamic: true, overlay: true };
+ let frameHandle = null;
+
+ // Latest scene to draw. Replaced wholesale by the editor on each change.
+ let scene = {
+ circuit: null,
+ boards: new Map(),
+ selection: new Set(),
+ hoverHole: null,
+ hoverComponent: null,
+ pendingWire: null, // { from: holeRef, toPoint: {x,y}, color }
+ ghost: null, // { component, valid }
+ ledBrightness: new Map(),
+ burned: new Set(),
+ pressed: new Set(),
+ warnedUids: new Set(), // components a simulation warning named
+ netLevels: null, // Uint8Array
+ netOfStrip: null, // Map
+ simActive: false
+ };
+
+ /** Read the container size. Cheap - one layout read, no canvas work. */
+ function measureContainer() {
+ const rect = container.getBoundingClientRect();
+ const nextDpr = window.devicePixelRatio || 1;
+ const width = Math.max(1, Math.round(rect.width));
+ const height = Math.max(1, Math.round(rect.height));
+ if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
+ cssWidth = width;
+ cssHeight = height;
+ dpr = nextDpr;
+ return true;
+ }
+
+ /** Resize the backing stores to the measured size. Wipes them, so all layers dirty. */
+ function applyCanvasSize() {
+ if (cssWidth === appliedWidth && cssHeight === appliedHeight && dpr === appliedDpr) return;
+ appliedWidth = cssWidth;
+ appliedHeight = cssHeight;
+ appliedDpr = dpr;
+ for (const name of LAYER_NAMES) {
+ const canvas = canvases[name];
+ canvas.width = Math.round(cssWidth * dpr);
+ canvas.height = Math.round(cssHeight * dpr);
+ canvas.style.width = `${cssWidth}px`;
+ canvas.style.height = `${cssHeight}px`;
+ }
+ for (const name of LAYER_NAMES) dirty[name] = true;
+ }
+
+ /** Apply the base dpr transform, then the world transform. */
+ function beginWorld(ctx) {
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
+ ctx.save();
+ ctx.translate(viewport.offsetX, viewport.offsetY);
+ ctx.scale(viewport.zoom, viewport.zoom);
+ }
+
+ function endWorld(ctx) {
+ ctx.restore();
+ }
+
+ function visibleRect() {
+ const rect = viewport.visibleWorldRect(cssWidth, cssHeight);
+ // A little margin so components straddling the edge are not clipped mid-body.
+ const margin = PITCH * 4;
+ return { x: rect.x - margin, y: rect.y - margin, w: rect.w + margin * 2, h: rect.h + margin * 2 };
+ }
+
+ function visibleBoards(view) {
+ if (!scene.circuit) return [];
+ return scene.circuit.boards.filter(board => rectsIntersect(boardBounds(board), view));
+ }
+
+ // --- Layer painters ---
+
+ function drawBoardLayer() {
+ const ctx = contexts.board;
+ beginWorld(ctx);
+ if (scene.circuit) {
+ const art = boardArt.get(viewport.zoom, palette.colors, palette.version);
+ const view = visibleRect();
+ for (const board of visibleBoards(view)) {
+ ctx.drawImage(art.surface, board.x, board.y, BOARD_WIDTH, BOARD_HEIGHT);
+ }
+ }
+ endWorld(ctx);
+ }
+
+ /** Net level code for a hole, or -1 when the simulation has nothing to say. */
+ function levelAtHole(hole) {
+ if (!scene.netLevels || !scene.netOfStrip || !hole) return -1;
+ const netId = scene.netOfStrip.get(stripKey(hole));
+ if (netId === undefined || netId < 0 || netId >= scene.netLevels.length) return -1;
+ return scene.netLevels[netId];
+ }
+
+ function drawStaticLayer() {
+ const ctx = contexts.static;
+ beginWorld(ctx);
+ if (scene.circuit) {
+ const view = visibleRect();
+ for (const component of scene.circuit.components) {
+ if (VOLATILE_TYPES.has(component.type)) continue;
+ const bounds = componentBounds(component, scene.boards);
+ if (bounds === null || !rectsIntersect(bounds, view)) continue;
+ drawComponent(ctx, component, scene.boards, palette.colors, {}, viewport.zoom);
+ }
+ }
+ endWorld(ctx);
+ }
+
+ function drawDynamicLayer() {
+ const ctx = contexts.dynamic;
+ beginWorld(ctx);
+ if (scene.circuit) {
+ const view = visibleRect();
+
+ for (const wire of scene.circuit.wires) {
+ const from = holeWorldPos(wire.from, scene.boards);
+ const to = holeWorldPos(wire.to, scene.boards);
+ if (!from || !to) continue;
+ const bounds = {
+ x: Math.min(from.x, to.x) - PITCH,
+ y: Math.min(from.y, to.y) - PITCH,
+ w: Math.abs(to.x - from.x) + PITCH * 2,
+ h: Math.abs(to.y - from.y) + PITCH * 2
+ };
+ if (!rectsIntersect(bounds, view)) continue;
+
+ let levelColor = null;
+ if (scene.simActive) {
+ const level = levelAtHole(wire.from);
+ if (level >= 0) levelColor = palette.levelColor(level);
+ }
+ drawWire(ctx, from, to, wire.color, {
+ levelColor,
+ shadow: palette.colors.wireShadow
+ });
+ }
+
+ for (const component of scene.circuit.components) {
+ if (!VOLATILE_TYPES.has(component.type)) continue;
+ const bounds = componentBounds(component, scene.boards);
+ if (bounds === null || !rectsIntersect(bounds, view)) continue;
+ drawComponent(ctx, component, scene.boards, palette.colors, {
+ brightness: scene.ledBrightness.get(component.uid) || 0,
+ burned: scene.burned.has(component.uid),
+ pressed: scene.pressed.has(component.uid)
+ }, viewport.zoom);
+ }
+ }
+ endWorld(ctx);
+ }
+
+ function drawOverlayLayer() {
+ const ctx = contexts.overlay;
+ beginWorld(ctx);
+ const colors = palette.colors;
+
+ // Selection rings around every selected component's pins.
+ for (const uid of scene.selection) {
+ const component = scene.circuit && scene.circuit.components.find(c => c.uid === uid);
+ if (component) {
+ const bounds = componentBounds(component, scene.boards);
+ if (bounds) {
+ ctx.strokeStyle = colors.selection;
+ ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.07);
+ ctx.setLineDash([PITCH * 0.3, PITCH * 0.2]);
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
+ ctx.setLineDash([]);
+ }
+ continue;
+ }
+ const wire = scene.circuit && scene.circuit.wires.find(w => w.uid === uid);
+ if (wire) {
+ const from = holeWorldPos(wire.from, scene.boards);
+ const to = holeWorldPos(wire.to, scene.boards);
+ if (from && to) {
+ ctx.strokeStyle = colors.selection;
+ ctx.lineWidth = PITCH * 0.34;
+ ctx.globalAlpha = 0.45;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(from.x, from.y);
+ ctx.lineTo(to.x, to.y);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ }
+ }
+ }
+
+ // Components a warning named. Worth drawing rather than only listing: the
+ // whole difficulty with a self-shorted or burned-out part is that it looks
+ // correctly placed, so a text row alone leaves the user hunting for it.
+ if (scene.warnedUids && scene.warnedUids.size > 0 && scene.circuit) {
+ ctx.strokeStyle = colors.invalid;
+ ctx.lineWidth = Math.max(2 / viewport.zoom, PITCH * 0.09);
+ ctx.setLineDash([PITCH * 0.22, PITCH * 0.18]);
+ for (const uid of scene.warnedUids) {
+ const component = scene.circuit.components.find(c => c.uid === uid);
+ if (!component) continue;
+ const bounds = componentBounds(component, scene.boards);
+ if (bounds === null) continue;
+ const pad = PITCH * 0.18;
+ ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
+ }
+ ctx.setLineDash([]);
+ }
+
+ // Ghost of the component about to be placed.
+ if (scene.ghost && scene.ghost.component) {
+ ctx.globalAlpha = 0.55;
+ drawComponent(ctx, scene.ghost.component, scene.boards, colors, {}, viewport.zoom);
+ ctx.globalAlpha = 1;
+ if (!scene.ghost.valid) {
+ const bounds = componentBounds(scene.ghost.component, scene.boards);
+ if (bounds) {
+ ctx.strokeStyle = colors.invalid;
+ ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
+ ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
+ }
+ }
+ }
+
+ // Wire being dragged out.
+ if (scene.pendingWire) {
+ const from = holeWorldPos(scene.pendingWire.from, scene.boards);
+ if (from && scene.pendingWire.toPoint) {
+ ctx.strokeStyle = scene.pendingWire.color;
+ ctx.lineWidth = PITCH * 0.18;
+ ctx.globalAlpha = 0.85;
+ ctx.setLineDash([PITCH * 0.4, PITCH * 0.25]);
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(from.x, from.y);
+ ctx.lineTo(scene.pendingWire.toPoint.x, scene.pendingWire.toPoint.y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+ }
+ }
+
+ // Hovered hole, plus a soft wash over every other hole on the same strip so the
+ // electrical grouping is visible while wiring.
+ if (scene.hoverHole) {
+ const point = holeWorldPos(scene.hoverHole, scene.boards);
+ if (point) {
+ if (scene.hoverStripPoints) {
+ ctx.fillStyle = colors.hover;
+ ctx.globalAlpha = 0.18;
+ for (const p of scene.hoverStripPoints) {
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, PITCH * 0.3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ }
+ ctx.strokeStyle = colors.hover;
+ ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, PITCH * 0.34, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ }
+
+ endWorld(ctx);
+ }
+
+ const PAINTERS = { board: drawBoardLayer, static: drawStaticLayer, dynamic: drawDynamicLayer, overlay: drawOverlayLayer };
+
+ // Layers whose painter has already thrown, so the failure is reported once rather
+ // than on every frame.
+ const reportedFailures = new Set();
+
+ function paint() {
+ frameHandle = null;
+ applyCanvasSize();
+ for (const name of LAYER_NAMES) {
+ if (!dirty[name]) continue;
+ try {
+ PAINTERS[name]();
+ // Cleared only on success. Marking clean before painting would leave a
+ // layer that threw permanently stale, so it could never recover even
+ // once the cause was gone.
+ dirty[name] = false;
+ } catch (error) {
+ if (!reportedFailures.has(name)) {
+ reportedFailures.add(name);
+ const message = error && error.message ? error.message : String(error);
+ if (typeof options.onPaintError === 'function') {
+ options.onPaintError(name, message);
+ }
+ }
+ // Stop rather than cascade: the layers below would paint over a gap.
+ break;
+ }
+ }
+ }
+
+ function schedule() {
+ if (frameHandle === null) frameHandle = requestAnimationFrame(paint);
+ }
+
+ function invalidate(...names) {
+ for (const name of names) dirty[name] = true;
+ schedule();
+ }
+
+ function invalidateAll() {
+ invalidate(...LAYER_NAMES);
+ }
+
+ const unsubscribeTheme = palette.subscribe(() => {
+ boardArt.invalidate();
+ invalidateAll();
+ });
+
+ return {
+ get canvas() { return canvases.overlay; },
+ get width() { return cssWidth; },
+ get height() { return cssHeight; },
+
+ /** Replace the scene and mark the given layers dirty. */
+ setScene(next, ...invalidated) {
+ scene = Object.assign(scene, next);
+ invalidate(...(invalidated.length > 0 ? invalidated : LAYER_NAMES));
+ },
+
+ resize() {
+ if (measureContainer()) invalidateAll();
+ },
+
+ invalidate,
+ invalidateAll,
+
+ /** Board artwork must be re-rasterized when the zoom bucket may have changed. */
+ viewportChanged() {
+ invalidate('board', 'static', 'dynamic', 'overlay');
+ },
+
+ destroy() {
+ if (frameHandle !== null) cancelAnimationFrame(frameHandle);
+ frameHandle = null;
+ unsubscribeTheme();
+ boardArt.invalidate();
+ for (const name of LAYER_NAMES) {
+ const canvas = canvases[name];
+ if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
+ }
+ }
+ };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/server-errors.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/server-errors.js
new file mode 100644
index 0000000..92c0a4b
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/server-errors.js
@@ -0,0 +1,74 @@
+// Friendly wording for the server validator's machine-readable error tokens.
+//
+// The validator emits `path:reason` tokens (e.g. "components[3].props.color:unsupported")
+// deliberately, so they stay machine-readable. Turning them into sentences belongs
+// here, in the UI layer, and MUST fall back to the raw token for anything unrecognised
+// - a new validator reason has to remain visible, not vanish.
+
+const REASONS = Object.freeze(Object.assign(Object.create(null), {
+ malformed_json: 'could not be read',
+ exceeds_max_size: 'is larger than the 2 MB limit',
+ unsupported: 'has a value this build does not support',
+ unknown_property: 'has a property this build does not recognise',
+ not_an_object: 'is not shaped like a circuit element',
+ not_an_array: 'should be a list',
+ not_a_boolean: 'should be true or false',
+ wrong_length: 'has the wrong number of entries',
+ out_of_range: 'is outside the allowed range',
+ unknown_board: 'refers to a board that does not exist',
+ duplicate: 'is used more than once',
+ not_applicable: 'is not allowed on this component',
+ contradicts_anchor_row: 'does not match which side of the centre channel the part sits on',
+ footprint_off_board: 'would place part of the component off the edge of the board',
+ package_off_board: 'is too close to the edge for the package to fit',
+ not_valid_on_rail: 'is not a valid direction for a part in a power rail',
+ rail_pair_already_supplied: 'already has a 5V supply on that rail pair',
+ unsupported_version: 'was saved by a different version of the editor',
+ truncated: 'and more problems were found than can be listed'
+}));
+
+const PATHS = Object.freeze(Object.assign(Object.create(null), {
+ circuit: 'The circuit',
+ version: 'The circuit version',
+ boards: 'The boards list',
+ components: 'The components list',
+ wires: 'The wires list'
+}));
+
+function describePath(path) {
+ if (PATHS[path]) return PATHS[path];
+
+ // components[3].props.color -> "Component 4's colour"
+ const match = /^(components|wires|boards)\[(\d+)\](?:\.(.+))?$/.exec(path);
+ if (match) {
+ const noun = { components: 'Component', wires: 'Wire', boards: 'Board' }[match[1]];
+ const ordinal = Number(match[2]) + 1;
+ const field = match[3] ? ` (${match[3].replace(/\./g, ' ')})` : '';
+ return `${noun} ${ordinal}${field}`;
+ }
+ return path;
+}
+
+/**
+ * Turn one validator token into a sentence, or return it unchanged when it is not in
+ * the expected shape.
+ * @param {string} token
+ * @returns {string}
+ */
+export function describeServerError(token) {
+ if (typeof token !== 'string') return String(token);
+ const split = token.lastIndexOf(':');
+ if (split <= 0) return token;
+
+ const path = token.slice(0, split);
+ const reason = token.slice(split + 1);
+ const wording = REASONS[reason];
+ // Unrecognised reason: show the raw token so nothing is silently swallowed.
+ if (!wording) return token;
+ return `${describePath(path)} ${wording}.`;
+}
+
+/** Map a list of tokens, preserving order. */
+export function describeServerErrors(tokens) {
+ return tokens.map(describeServerError);
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/sim-client.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/sim-client.js
new file mode 100644
index 0000000..c2b11c2
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/sim-client.js
@@ -0,0 +1,307 @@
+// Client for the simulation worker.
+//
+// Speaks the engine protocol exactly. This module owns ALL knowledge of the worker
+// message shapes; the rest of the editor sees plain callbacks and a small state
+// object, so a protocol change lands in one file.
+//
+// Behaviours of the engine that the UI must not misread, per the engine pair:
+// - The worker AUTO-PAUSES when the circuit settles. Frame silence is normal for a
+// combinational circuit, not a hang. `settled` says so explicitly.
+// - `input` works while paused; the worker settles the consequences and posts a
+// frame immediately, so a button press updates the view without pressing run.
+// - `run` can REFUSE: on a rail-to-rail short it posts a frame with halted:true and
+// does not start. The UI must reflect that the run did not take.
+// - A burned LED reports 0 mA, which is also what an off LED reports. Burnout is
+// known ONLY from the one-shot `ledBurnout` warning, so it is latched here and
+// cleared on load/reset.
+
+const WORKER_URL = '/js/breadboard/engine/worker.js';
+
+/** Current at which an LED is drawn at full brightness (also the overcurrent point). */
+const FULL_BRIGHTNESS_MA = 20;
+
+export function createSimClient(handlers = {}) {
+ let worker = null;
+ let available = false;
+ let loadError = null;
+
+ const state = {
+ running: false,
+ settled: false,
+ halted: false,
+ loaded: false,
+ netCount: 0,
+ simTimeNs: 0,
+ netOfStrip: new Map(),
+ pinNets: new Map(), // uid -> [netId per pin], -1 when unconnected
+ ledOrder: [],
+ netLevels: null,
+ ledCurrentMa: new Map(), // uid -> mA
+ ledBrightness: new Map(), // uid -> 0..1
+ burned: new Set(),
+ // Set by a worker `error`, cleared only when WE send a new load/reset. A failed
+ // load posts BOTH `error` and `loaded`, so this survives the `loaded` that
+ // follows and stops the failure being reported twice or wiped from the list.
+ engineError: null
+ };
+
+ function emit(name, ...args) {
+ const handler = handlers[name];
+ if (typeof handler === 'function') handler(...args);
+ }
+
+ function resetDerived() {
+ state.netOfStrip = new Map();
+ state.pinNets = new Map();
+ state.ledOrder = [];
+ state.netLevels = null;
+ state.ledCurrentMa = new Map();
+ state.ledBrightness = new Map();
+ state.burned = new Set();
+ state.running = false;
+ state.settled = false;
+ state.halted = false;
+ state.simTimeNs = 0;
+ }
+
+ function handleLoaded(message) {
+ const carriedError = state.engineError;
+ resetDerived();
+ state.engineError = carriedError;
+ state.loaded = true;
+
+ // The engine reports a failed load two ways: a preceding `error` message, and a
+ // `loadFailed` entry in these warnings with nets:0. Either means this is not a
+ // valid empty circuit, so check both rather than trusting nets===0.
+ const warnings = Array.isArray(message.warnings) ? message.warnings : [];
+ const loadFailed = warnings.some(w => w && w.kind === 'loadFailed');
+ state.netCount = typeof message.nets === 'number' ? message.nets : 0;
+
+ const index = message.netIndex || {};
+ if (index.strips && typeof index.strips === 'object') {
+ state.netOfStrip = new Map(Object.entries(index.strips));
+ }
+ if (index.components && typeof index.components === 'object') {
+ for (const [uid, nets] of Object.entries(index.components)) {
+ if (Array.isArray(nets)) state.pinNets.set(uid, nets);
+ }
+ }
+ state.ledOrder = Array.isArray(index.ledOrder) ? index.ledOrder.slice() : [];
+
+ if (loadFailed) state.halted = true;
+
+ emit('loaded', {
+ netCount: state.netCount,
+ warnings,
+ // The engine posts `loaded` even when the load failed, so that anything
+ // awaiting it is released. Tell the UI not to treat this as a clean start.
+ failed: carriedError !== null || loadFailed
+ });
+ }
+
+ function handleFrame(message) {
+ state.netLevels = message.netLevels instanceof Uint8Array ? message.netLevels : null;
+ state.simTimeNs = typeof message.simTimeNs === 'number' ? message.simTimeNs : state.simTimeNs;
+ // Additive fields beyond the spec; absent on an older engine, so default safely.
+ if (typeof message.running === 'boolean') state.running = message.running;
+ if (typeof message.settled === 'boolean') state.settled = message.settled;
+ if (typeof message.halted === 'boolean') state.halted = message.halted;
+ if (state.halted) state.running = false;
+
+ const currents = message.ledStates;
+ if (currents && currents.length >= 0) {
+ for (let i = 0; i < state.ledOrder.length && i < currents.length; i++) {
+ const uid = state.ledOrder[i];
+ const ma = currents[i];
+ state.ledCurrentMa.set(uid, ma);
+ // A burned LED also reports 0 mA, so the latched flag - not the
+ // current - decides whether it is drawn as dead.
+ state.ledBrightness.set(uid, state.burned.has(uid)
+ ? 0
+ : Math.max(0, Math.min(1, ma / FULL_BRIGHTNESS_MA)));
+ }
+ }
+ emit('frame', state);
+ }
+
+ function handleWarning(message) {
+ if (message.kind === 'ledBurnout' && Array.isArray(message.uids)) {
+ for (const uid of message.uids) {
+ state.burned.add(uid);
+ state.ledBrightness.set(uid, 0);
+ }
+ }
+ // Unknown kinds are passed through untouched - the status surface renders them
+ // generically so a new engine warning needs no change here.
+ emit('warning', {
+ kind: typeof message.kind === 'string' ? message.kind : 'unknown',
+ uids: Array.isArray(message.uids) ? message.uids : [],
+ netId: message.netId,
+ detail: typeof message.detail === 'string' ? message.detail : ''
+ });
+ }
+
+ /**
+ * The engine threw internally (amendment A13). The worker is still alive but its
+ * state is not to be trusted, so stop the transport and tell the user - never
+ * leave the UI waiting on frames that will not come.
+ */
+ function handleError(message) {
+ state.running = false;
+ state.halted = true;
+ const context = typeof message.context === 'string' && message.context.length > 0
+ ? message.context : 'simulation';
+ const detail = typeof message.message === 'string' ? message.message : '';
+ state.engineError = { context, detail };
+ emit('warning', {
+ kind: 'engineError',
+ uids: Array.isArray(message.uids) ? message.uids : [],
+ netId: message.netId,
+ detail: detail ? `${context}: ${detail}` : context
+ });
+ emit('frame', state);
+ }
+
+ /**
+ * The Worker failed to start or threw at the top level. Separate from the engine's
+ * own `error` message, which is a structured report from a worker that is running.
+ */
+ function onWorkerError(event) {
+ loadError = event.message || 'The simulation engine failed to start.';
+ available = false;
+ state.running = false;
+ emit('unavailable', loadError);
+ }
+
+ function onMessage(event) {
+ const message = event.data;
+ if (!message || typeof message.type !== 'string') return;
+ switch (message.type) {
+ case 'loaded': handleLoaded(message); break;
+ case 'frame': handleFrame(message); break;
+ case 'warning': handleWarning(message); break;
+ case 'error': handleError(message); break;
+ default:
+ // Forward-compatible, but NEVER silent about a failure: an unrecognised
+ // message that carries error-shaped fields is surfaced rather than
+ // dropped, because a swallowed error looks exactly like a hung worker.
+ if (/error|fail/i.test(message.type)
+ || typeof message.message === 'string'
+ || typeof message.context === 'string') {
+ handleError(message);
+ }
+ break;
+ }
+ }
+
+ function post(message) {
+ if (worker === null) return;
+ try {
+ worker.postMessage(message);
+ } catch (error) {
+ // Structured clone failed, so the message NEVER REACHED the worker: no
+ // loaded, no frame, and no engine-side `error` either, because the engine
+ // was never told. Without this the careful error plumbing is bypassed and
+ // the UI waits forever. Route it into the same failure path.
+ handleError({
+ context: message && message.type ? message.type : 'postMessage',
+ message: error && error.message ? error.message : String(error)
+ });
+ }
+ }
+
+ return {
+ state,
+ get available() { return available; },
+ get loadError() { return loadError; },
+
+ /**
+ * Start the worker. Failure is reported rather than thrown, so the editor
+ * still works without simulation.
+ * @returns {boolean} whether the worker started
+ */
+ start() {
+ if (worker !== null) return true;
+ try {
+ worker = new Worker(WORKER_URL, { type: 'module' });
+ } catch (error) {
+ loadError = error && error.message ? error.message : String(error);
+ available = false;
+ emit('unavailable', loadError);
+ return false;
+ }
+ worker.addEventListener('message', onMessage);
+ worker.addEventListener('error', onWorkerError);
+ available = true;
+ return true;
+ },
+
+ /** Send a circuit for net extraction. Clears burnout and run state. */
+ load(circuit) {
+ resetDerived();
+ state.engineError = null;
+ state.loaded = false;
+ post({ type: 'load', circuit });
+ },
+
+ run() {
+ state.running = true; // optimistic; a halted frame corrects it
+ post({ type: 'run' });
+ },
+
+ pause() {
+ state.running = false;
+ post({ type: 'pause' });
+ },
+
+ step(count = 1) {
+ post({ type: 'step', count });
+ },
+
+ setSpeed(eventsPerSecond) {
+ post({ type: 'setSpeed', eventsPerSecond });
+ },
+
+ /** Button press/release. */
+ setButton(uid, pressed) {
+ post({ type: 'input', uid, value: pressed === true });
+ },
+
+ /** DIP toggle. `pin` is the SWITCH number 1..8, not the package pin. */
+ setSwitch(uid, pin, on) {
+ post({ type: 'input', uid, value: { pin, on: on === true } });
+ },
+
+ /** Re-load the last circuit, clearing LED burnout. */
+ reset() {
+ state.burned.clear();
+ state.ledBrightness.clear();
+ state.engineError = null;
+ state.halted = false;
+ post({ type: 'reset' });
+ },
+
+ /** Net level code for a strip key, or -1 when unknown. */
+ levelForStrip(stripKey) {
+ if (!state.netLevels) return -1;
+ const netId = state.netOfStrip.get(stripKey);
+ if (netId === undefined || netId < 0 || netId >= state.netLevels.length) return -1;
+ return state.netLevels[netId];
+ },
+
+ destroy() {
+ if (worker !== null) {
+ // Removed by name rather than relying on terminate() to take the
+ // listeners with it, so teardown does not depend on that detail.
+ worker.removeEventListener('message', onMessage);
+ worker.removeEventListener('error', onWorkerError);
+ worker.terminate();
+ worker = null;
+ }
+ available = false;
+ resetDerived();
+ }
+ };
+}
+
+export { FULL_BRIGHTNESS_MA };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js
new file mode 100644
index 0000000..27b8817
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js
@@ -0,0 +1,166 @@
+// Status strip: transient messages and simulation warnings.
+//
+// Non-intrusive by design - nothing here blocks the canvas or steals focus. Every
+// string that reaches the DOM goes through textContent, because these carry server
+// error text and engine detail strings.
+
+import { el, button, setText, clear, createListenerBag } from './dom.js';
+
+/** How long an informational message stays before fading. Warnings persist. */
+const INFO_TIMEOUT_MS = 3200;
+
+/**
+ * Friendly text for the warning kinds the engine documents. Unknown kinds are rendered
+ * generically rather than dropped, so a new engine warning needs no change here
+ * (amendment A8).
+ */
+const WARNING_LABELS = Object.freeze(Object.assign(Object.create(null), {
+ contention: 'Contention — two outputs are driving the same net',
+ ledOvercurrent: 'LED over 20 mA',
+ ledBurnout: 'LED burned out',
+ shortCircuit: 'Short circuit across the supply',
+ oscillation: 'Oscillation — the circuit never settles',
+ floatingInput: 'Floating input',
+ unpoweredChip: 'Chip has no power',
+ invalidHole: 'Component is not on a valid hole',
+ duplicateUid: 'Duplicate component id',
+ 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'
+}));
+
+const SEVERITY = Object.freeze(Object.assign(Object.create(null), {
+ // A coalescing summary, not a circuit fault - it must not read as an error.
+ warningsSuppressed: 'info',
+ engineError: 'error',
+ ledBurnout: 'error',
+ shortCircuit: 'error',
+ contention: 'error',
+ oscillation: 'warn',
+ ledOvercurrent: 'warn',
+ unlimitedBaseCurrent: 'warn'
+}));
+
+function humanizeKind(kind) {
+ // "someUnknownKind" -> "Some unknown kind"
+ const spaced = String(kind).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
+export function createStatus(root) {
+ const bag = createListenerBag();
+
+ const messageList = el('div', { className: 'bb-status-messages' });
+ const warningList = el('div', { className: 'bb-status-warnings' });
+ const warningHeader = el('div', { className: 'bb-status-warnings-header' });
+ const warningTitle = el('span', { className: 'bb-status-warnings-title' });
+ const clearButton = button('Clear', { className: 'bb-btn bb-btn-ghost bb-btn-small' });
+
+ warningHeader.appendChild(warningTitle);
+ warningHeader.appendChild(clearButton);
+
+ const panel = el('div', {
+ className: 'bb-status',
+ attrs: { role: 'status', 'aria-live': 'polite' },
+ children: [messageList, warningList]
+ });
+ root.appendChild(panel);
+
+ /** kind -> { count, detail, uids, node } so repeats collapse instead of flooding. */
+ const warnings = new Map();
+ const timers = new Set();
+
+ function renderWarningHeader() {
+ if (warnings.size === 0) {
+ if (warningHeader.parentNode) warningList.removeChild(warningHeader);
+ warningList.classList.remove('is-visible');
+ return;
+ }
+ if (!warningHeader.parentNode) warningList.insertBefore(warningHeader, warningList.firstChild);
+ warningList.classList.add('is-visible');
+ setText(warningTitle, `${warnings.size} issue${warnings.size === 1 ? '' : 's'}`);
+ }
+
+ function message(text, kind) {
+ if (!text) return;
+ const node = el('div', { className: `bb-message bb-message-${kind}`, text });
+ messageList.appendChild(node);
+ // Newest first, and never let the list grow without bound.
+ while (messageList.childElementCount > 4) messageList.removeChild(messageList.firstChild);
+
+ if (kind === 'info') {
+ const timer = setTimeout(() => {
+ timers.delete(timer);
+ if (node.parentNode) node.parentNode.removeChild(node);
+ }, INFO_TIMEOUT_MS);
+ timers.add(timer);
+ }
+ }
+
+ bag.on(clearButton, 'click', () => {
+ warnings.clear();
+ clear(warningList);
+ renderWarningHeader();
+ });
+
+ return {
+ info(text) { message(text, 'info'); },
+ warn(text) { message(text, 'warn'); },
+ error(text) { message(text, 'error'); },
+
+ /** Show a list of messages, e.g. a server validation failure. */
+ errors(list) {
+ for (const text of list) message(text, 'error');
+ },
+
+ /**
+ * Record a simulation warning. Unknown kinds render generically - never
+ * dropped, never a crash.
+ */
+ addWarning(warning) {
+ const kind = warning.kind || 'unknown';
+ const existing = warnings.get(kind);
+ if (existing) {
+ existing.count++;
+ setText(existing.countNode, `x${existing.count}`);
+ if (warning.detail) setText(existing.detailNode, warning.detail);
+ return;
+ }
+
+ const label = WARNING_LABELS[kind] || humanizeKind(kind);
+ const severity = SEVERITY[kind] || 'warn';
+ const titleNode = el('span', { className: 'bb-warning-title', text: label });
+ const countNode = el('span', { className: 'bb-warning-count', text: '' });
+ const detailNode = el('span', { className: 'bb-warning-detail', text: warning.detail || '' });
+ const uidsText = Array.isArray(warning.uids) && warning.uids.length > 0
+ ? warning.uids.join(', ') : '';
+ const uidNode = el('span', { className: 'bb-warning-uids', text: uidsText });
+
+ const node = el('div', {
+ className: `bb-warning bb-warning-${severity}`,
+ children: [titleNode, countNode, detailNode, uidNode]
+ });
+ warningList.appendChild(node);
+ warnings.set(kind, { count: 1, node, countNode, detailNode });
+ renderWarningHeader();
+ },
+
+ /** Drop all simulation warnings, e.g. on a fresh load. */
+ clearWarnings() {
+ warnings.clear();
+ clear(warningList);
+ renderWarningHeader();
+ },
+
+ destroy() {
+ for (const timer of timers) clearTimeout(timer);
+ timers.clear();
+ bag.removeAll();
+ if (panel.parentNode) panel.parentNode.removeChild(panel);
+ }
+ };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js
new file mode 100644
index 0000000..7d61a1d
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js
@@ -0,0 +1,141 @@
+// Canvas palette, sourced from CSS.
+//
+// Canvas cannot inherit CSS, so every colour the renderer draws is read out of a CSS
+// custom property defined in breadboard.css. There are NO hardcoded colours in the
+// renderer - change the look by editing the stylesheet, and both themes follow.
+//
+// site.css puts the dark palette on bare :root and overrides it under
+// [data-theme="light"], so this reads whatever is currently in effect rather than
+// assuming either. The site's theme customizer can also set custom properties inline
+// on , which is why the observer watches `style` as well as `data-theme`.
+
+/** Every custom property the renderer needs, with a fallback if the sheet is missing. */
+const TOKENS = Object.freeze({
+ boardFace: ['--bb-board-face', '#e8e6df'],
+ boardEdge: ['--bb-board-edge', '#c9c5b8'],
+ boardBevel: ['--bb-board-bevel', '#f5f3ee'],
+ channel: ['--bb-channel', '#d8d5cb'],
+ channelEdge: ['--bb-channel-edge', '#bdb9ac'],
+ hole: ['--bb-hole', '#3a3a3a'],
+ holeRim: ['--bb-hole-rim', '#b9b5a8'],
+ silk: ['--bb-silk', '#8a8578'],
+ silkStrong: ['--bb-silk-strong', '#5e5a50'],
+ railPlus: ['--bb-rail-plus', '#d24b4b'],
+ railMinus: ['--bb-rail-minus', '#4b6fd2'],
+ canvasBg: ['--bb-canvas-bg', '#1b1d21'],
+ grid: ['--bb-grid', '#2a2d33'],
+ hover: ['--bb-hover', '#3fb950'],
+ selection: ['--bb-selection', '#58a6ff'],
+ ghost: ['--bb-ghost', '#58a6ff'],
+ invalid: ['--bb-invalid', '#f85149'],
+ wireShadow: ['--bb-wire-shadow', 'rgba(0,0,0,0.35)'],
+ chipBody: ['--bb-chip-body', '#2b2b2f'],
+ chipLabel: ['--bb-chip-label', '#d8d8d8'],
+ 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'],
+ dipBody: ['--bb-dip-body', '#2f4a8c'],
+ dipSwitchOn: ['--bb-dip-switch-on', '#f2f2f2'],
+ dipSwitchOff: ['--bb-dip-switch-off', '#8b8b90'],
+ supplyBody: ['--bb-supply-body', '#26303a'],
+ supplyText: ['--bb-supply-text', '#e6edf3'],
+ burned: ['--bb-burned', '#4a4a4a'],
+ levelLow: ['--bb-level-low', '#3b6ea5'],
+ levelHigh: ['--bb-level-high', '#e0483d'],
+ levelHiZ: ['--bb-level-hiz', '#7d7d85'],
+ levelWeakLow: ['--bb-level-weak-low', '#4f7fa8'],
+ levelWeakHigh: ['--bb-level-weak-high', '#d98a4a'],
+ levelContention: ['--bb-level-contention', '#ffcc00']
+});
+
+/**
+ * Net level codes from the engine's `frame` message, mapped to palette keys.
+ * Index is the Uint8Array value: 0=low 1=high 2=highZ 3=weakLow 4=weakHigh 5=contention
+ */
+export const LEVEL_KEYS = Object.freeze([
+ 'levelLow', 'levelHigh', 'levelHiZ', 'levelWeakLow', 'levelWeakHigh', 'levelContention'
+]);
+
+/** Human-readable names for the same codes, for the status bar. */
+export const LEVEL_NAMES = Object.freeze([
+ 'low', 'high', 'high-Z', 'weak low', 'weak high', 'contention'
+]);
+
+/**
+ * Reads the palette from CSS and notifies subscribers when the theme changes.
+ * `version` increments on every change so cached bitmaps know to re-rasterize.
+ */
+export function createPalette(rootElement) {
+ const probe = rootElement || document.documentElement;
+ let colors = read();
+ let version = 0;
+ const subscribers = new Set();
+
+ function read() {
+ const computed = getComputedStyle(probe);
+ const next = {};
+ for (const [key, [prop, fallback]] of Object.entries(TOKENS)) {
+ const value = computed.getPropertyValue(prop).trim();
+ next[key] = value.length > 0 ? value : fallback;
+ }
+ return next;
+ }
+
+ function refresh() {
+ const next = read();
+ const changed = Object.keys(next).some(k => next[k] !== colors[k]);
+ if (!changed) return false;
+ colors = next;
+ version++;
+ for (const fn of subscribers) fn(colors, version);
+ return true;
+ }
+
+ // carries both the data-theme attribute and any inline custom-property
+ // overrides written by the site's theme customizer.
+ const observer = new MutationObserver(refresh);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme', 'style', 'class']
+ });
+
+ return {
+ /** Current colours. Treat as immutable; re-read via .colors after a change. */
+ get colors() { return colors; },
+ /** Bumped whenever the palette changes - use as a cache key. */
+ get version() { return version; },
+ /** Colour for a net level code, falling back to high-Z for unknown codes. */
+ levelColor(code) {
+ const key = LEVEL_KEYS[code];
+ return colors[key === undefined ? 'levelHiZ' : key];
+ },
+ subscribe(fn) {
+ subscribers.add(fn);
+ return () => subscribers.delete(fn);
+ },
+ /** Force a re-read; returns whether anything changed. */
+ refresh,
+ destroy() {
+ observer.disconnect();
+ subscribers.clear();
+ }
+ };
+}
+
+/**
+ * Blend a CSS colour toward transparency for glow effects. Only handles the alpha
+ * channel, so it works with any colour syntax the browser accepts by delegating to
+ * globalAlpha at draw time instead of parsing.
+ */
+export function withAlpha(ctx, alpha, draw) {
+ const previous = ctx.globalAlpha;
+ ctx.globalAlpha = previous * alpha;
+ draw();
+ ctx.globalAlpha = previous;
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js
new file mode 100644
index 0000000..c538eb0
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js
@@ -0,0 +1,131 @@
+// Top toolbar: project identity, save state, simulation transport, zoom.
+
+import { el, button, setText, createListenerBag } from './dom.js';
+
+/** Simulation speed presets, in engine events per second. */
+const SPEED_STEPS = Object.freeze([1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]);
+const DEFAULT_SPEED_INDEX = 5;
+
+function formatSpeed(eventsPerSecond) {
+ if (eventsPerSecond >= 1000000) return `${eventsPerSecond / 1000000} M events/s`;
+ if (eventsPerSecond >= 1000) return `${eventsPerSecond / 1000} k events/s`;
+ return `${eventsPerSecond} events/s`;
+}
+
+export function createToolbar(root, handlers) {
+ const bag = createListenerBag();
+
+ const title = el('h1', { className: 'bb-title', text: handlers.projectName || 'Breadboard' });
+ const saveState = el('span', { className: 'bb-save-state', text: 'Loading…' });
+ const saveButton = button('Save', { className: 'bb-btn bb-btn-primary', attrs: { 'aria-keyshortcuts': 'Control+S' } });
+
+ const runButton = button('Run', { className: 'bb-btn bb-btn-run', title: 'Start the simulation' });
+ const pauseButton = button('Pause', { className: 'bb-btn', title: 'Pause the simulation' });
+ const stepButton = button('Step', { className: 'bb-btn', title: 'Advance one event' });
+ const resetButton = button('Reset', { className: 'bb-btn', title: 'Reload the circuit and clear burned-out parts' });
+ const simState = el('span', { className: 'bb-sim-state', text: 'idle' });
+
+ const speedInput = el('input', {
+ className: 'bb-speed',
+ attrs: {
+ type: 'range', min: '0', max: String(SPEED_STEPS.length - 1),
+ step: '1', value: String(DEFAULT_SPEED_INDEX),
+ 'aria-label': 'Simulation speed'
+ }
+ });
+ const speedLabel = el('span', { className: 'bb-speed-label', text: formatSpeed(SPEED_STEPS[DEFAULT_SPEED_INDEX]) });
+
+ const zoomOut = button('−', { className: 'bb-btn bb-btn-icon', title: 'Zoom out' });
+ const zoomIn = button('+', { className: 'bb-btn bb-btn-icon', title: 'Zoom in' });
+ const zoomFit = button('Fit', { className: 'bb-btn', title: 'Fit all boards in view' });
+ const zoomLabel = el('span', { className: 'bb-zoom-label', text: '100%' });
+
+ const addBoardButton = button('Add board', { className: 'bb-btn' });
+
+ // Last state written to the DOM. setSimState is called on every simulation frame,
+ // and writing unchanged text/disabled flags still invalidates layout on a
+ // wrapping toolbar, so identical calls stop here.
+ let lastSimSignature = null;
+
+ const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
+
+ const bar = el('div', {
+ className: 'bb-toolbar',
+ children: [
+ group('bb-group-project', [title, saveState, saveButton]),
+ group('bb-group-sim', [runButton, pauseButton, stepButton, resetButton, simState]),
+ group('bb-group-speed', [speedLabel, speedInput]),
+ group('bb-group-view', [addBoardButton, zoomOut, zoomLabel, zoomIn, zoomFit])
+ ]
+ });
+ root.appendChild(bar);
+
+ bag.on(saveButton, 'click', () => handlers.onSave());
+ bag.on(runButton, 'click', () => handlers.onRun());
+ bag.on(pauseButton, 'click', () => handlers.onPause());
+ bag.on(stepButton, 'click', () => handlers.onStep());
+ bag.on(resetButton, 'click', () => handlers.onReset());
+ bag.on(addBoardButton, 'click', () => handlers.onAddBoard());
+ bag.on(zoomIn, 'click', () => handlers.onZoom(1.25));
+ bag.on(zoomOut, 'click', () => handlers.onZoom(1 / 1.25));
+ bag.on(zoomFit, 'click', () => handlers.onZoomFit());
+ bag.on(speedInput, 'input', () => {
+ const speed = SPEED_STEPS[Number(speedInput.value)] || SPEED_STEPS[DEFAULT_SPEED_INDEX];
+ setText(speedLabel, formatSpeed(speed));
+ handlers.onSpeed(speed);
+ });
+
+ return {
+ get initialSpeed() { return SPEED_STEPS[DEFAULT_SPEED_INDEX]; },
+
+ setProjectName(name) {
+ setText(title, name || 'Breadboard');
+ },
+
+ /**
+ * Reflect save state.
+ * @param {'clean'|'dirty'|'saving'|'error'} kind
+ */
+ setSaveState(kind, text) {
+ saveState.className = `bb-save-state bb-save-${kind}`;
+ setText(saveState, text);
+ saveButton.disabled = kind === 'saving' || kind === 'clean';
+ },
+
+ /**
+ * Reflect simulation state. `halted` means the engine refused to run - a
+ * rail-to-rail short - so the run button must visibly not take.
+ */
+ setSimState({ available, running, settled, halted, loaded }) {
+ const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
+ if (signature === lastSimSignature) return;
+ lastSimSignature = signature;
+
+ runButton.disabled = !available || running || halted;
+ pauseButton.disabled = !available || !running;
+ stepButton.disabled = !available || running;
+ resetButton.disabled = !available;
+ speedInput.disabled = !available;
+
+ let label = 'idle';
+ if (!available) label = 'engine unavailable';
+ else if (halted) label = 'halted — fix the fault, then reset';
+ else if (running) label = 'running';
+ else if (settled) label = 'settled';
+ else if (loaded) label = 'paused';
+ simState.className = `bb-sim-state${halted ? ' bb-sim-halted' : ''}${running ? ' bb-sim-running' : ''}`;
+ setText(simState, label);
+ },
+
+ setZoom(zoom) {
+ setText(zoomLabel, `${Math.round(zoom * 100)}%`);
+ },
+
+ destroy() {
+ bag.removeAll();
+ if (bar.parentNode) bar.parentNode.removeChild(bar);
+ }
+ };
+}
+
+export { SPEED_STEPS };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js
new file mode 100644
index 0000000..34cfdeb
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js
@@ -0,0 +1,536 @@
+// Pointer and keyboard interaction.
+//
+// All mouse coordinates here are SCREEN space (CSS pixels relative to the canvas), and
+// are converted to world space through the viewport before touching any geometry.
+// devicePixelRatio never appears - see the policy note in renderer.js.
+//
+// Modes:
+// select click to select, drag a component to move it, drag empty space to pan
+// wire drag from hole to hole
+// place: a ghost follows the cursor; click a hole to commit
+// Space or the middle button pans in any mode.
+
+import {
+ holeAtWorldPoint,
+ holeWorldPos,
+ holesInStrip,
+ sameHole,
+ UPPER_ROWS,
+ BOARD_WIDTH,
+ BOARD_HEIGHT,
+ PITCH
+} from '../shared/board-geometry.js';
+
+import { getComponentDef } from '../shared/component-registry.js';
+import { componentPinHoles } from '../shared/component-pins.js';
+import { componentBounds } from './component-art.js';
+import { createListenerBag } from './dom.js';
+
+/** Pointer travel (screen px) before a press becomes a drag rather than a click. */
+const DRAG_THRESHOLD = 4;
+
+// How close the cursor must be to a hole, in WORLD units, to snap onto it.
+//
+// board-geometry's HOLE_HIT_RADIUS is half a pitch, which leaves the corners of every
+// cell dead - roughly a fifth of the board selects nothing. That is a UI feel decision
+// rather than a geometry fact, so the editor picks its own, more generous radius here.
+// At 0.7 the snap regions of neighbouring holes overlap slightly and nearest-hole wins,
+// so there is effectively no dead space and no risk of snapping to a distant hole.
+const HOLE_SNAP_RADIUS = PITCH * 0.7;
+
+// WheelEvent.deltaY is only in pixels when deltaMode is 0. Firefox reports deltaMode 1
+// (LINES, ~3 per notch) where Chrome reports 0 (PIXELS, ~100 per notch) - a ~32x
+// difference that makes an un-normalized zoom unusable outside Chromium. Normalizing at
+// the event boundary keeps viewport.js in pixels and unaware of DOM event quirks, the
+// same way it is kept unaware of devicePixelRatio.
+const WHEEL_LINE_PX = 16;
+const WHEEL_PAGE_FALLBACK_PX = 400;
+
+/**
+ * The row a DIP-style package should anchor to for a hole the user clicked: the near
+ * side of the centre channel. Uses the shared row grouping rather than comparing row
+ * letters, so it cannot drift if the row alphabet ever changes.
+ */
+function channelSideRow(hole) {
+ return UPPER_ROWS.indexOf(hole.row) !== -1 ? 'e' : 'f';
+}
+
+function pointInRect(x, y, rect) {
+ return rect !== null && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
+}
+
+export function createTools(options) {
+ const { canvas, viewport, state, renderer, sim, status, onSceneChange, onSelectionChange } = options;
+ const bag = createListenerBag();
+
+ let tool = { kind: 'select', type: null };
+ let wireColor = options.initialWireColor;
+ let spaceHeld = false;
+
+ // Transient interaction state
+ let gesture = null; // { kind, ... }
+ let hoverHole = null;
+ let hoverComponentUid = null;
+
+ // Cached canvas rect. pointermove is bound to `window` and needs the rect twice per
+ // event, and a high-poll mouse fires far more often than once per frame - reading it
+ // live forces a layout every time. Resize and scroll invalidate it explicitly; the
+ // TTL is the backstop for a layout shift that moves the canvas without either, so
+ // the rect is never more than RECT_TTL_MS stale.
+ let canvasRect = null;
+ let canvasRectAt = 0;
+ const RECT_TTL_MS = 250;
+
+ function bounds() {
+ const now = performance.now();
+ if (canvasRect === null || now - canvasRectAt > RECT_TTL_MS) {
+ canvasRect = canvas.getBoundingClientRect();
+ canvasRectAt = now;
+ }
+ return canvasRect;
+ }
+
+ function screenPoint(event) {
+ const rect = bounds();
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
+ }
+
+ /** Whether a pointer event happened over the canvas itself. */
+ function isOverCanvas(event) {
+ const rect = bounds();
+ return event.clientX >= rect.left && event.clientX <= rect.right
+ && event.clientY >= rect.top && event.clientY <= rect.bottom;
+ }
+
+ function worldPoint(event) {
+ const point = screenPoint(event);
+ return viewport.screenToWorld(point.x, point.y);
+ }
+
+ function holeAt(world) {
+ // Radius is in world units, so snapping feels the same at every zoom.
+ return holeAtWorldPoint(world.x, world.y, state.circuit.boards, HOLE_SNAP_RADIUS);
+ }
+
+ function componentAt(world) {
+ // Only boards under the cursor can hold the component under the cursor, so a
+ // cheap board test skips resolving pin geometry for everything elsewhere.
+ const nearbyBoards = new Set(
+ state.circuit.boards
+ .filter(b => world.x >= b.x - PITCH && world.x <= b.x + BOARD_WIDTH + PITCH
+ && world.y >= b.y - PITCH && world.y <= b.y + BOARD_HEIGHT + PITCH)
+ .map(b => b.uid)
+ );
+ if (nearbyBoards.size === 0) return null;
+
+ // Topmost first, so later components win where they overlap.
+ const components = state.circuit.components;
+ for (let i = components.length - 1; i >= 0; i--) {
+ const component = components[i];
+ const boardUid = component.anchor ? component.anchor.board
+ : (component.props ? component.props.board : null);
+ if (boardUid !== null && boardUid !== undefined && !nearbyBoards.has(boardUid)) continue;
+ if (pointInRect(world.x, world.y, componentBounds(component, state.boards))) {
+ return component;
+ }
+ }
+ return null;
+ }
+
+ /** Which DIP switch lever (1..8) is under a world point, or 0. */
+ function switchAt(component, world) {
+ if (component.type !== 'dipSwitch8') return 0;
+ const pins = componentPinHoles(component);
+ let best = 0;
+ let bestDistance = PITCH * 0.6;
+ for (let k = 0; k < 8; k++) {
+ const hole = pins[k] && pins[k].hole;
+ if (!hole) continue;
+ const point = holeWorldPos(hole, state.boards);
+ if (!point) continue;
+ const distance = Math.abs(point.x - world.x);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = k + 1;
+ }
+ }
+ return best;
+ }
+
+ function ghostFor(hole) {
+ if (tool.kind !== 'place' || hole === null) return null;
+ const def = getComponentDef(tool.type);
+ if (def === null) return null;
+
+ let anchor = hole;
+ // DIP-style packages must straddle the channel, so snap onto the nearest of
+ // rows e/f rather than refusing every other row.
+ if (def.dipStyle && hole.kind === 'main') {
+ anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
+ }
+ const component = state.buildComponent(tool.type, anchor, ghostExtraProps(hole));
+ if (component === null) return null;
+ const pins = componentPinHoles(component);
+ const valid = pins.length > 0 && pins.every(p => p.hole !== null);
+ return { component, valid, anchor };
+ }
+
+ function ghostExtraProps(hole) {
+ if (tool.type === 'powerSupply5V' && hole) {
+ return { board: hole.board, side: hole.kind === 'rail' ? (hole.rail.startsWith('top') ? 'top' : 'bottom') : 'top' };
+ }
+ if (tool.type === 'resistor' && hole) {
+ // Second terminal defaults four columns along, which the user then edits.
+ const to = Object.assign({}, hole);
+ if (to.kind === 'main') to.col = Math.min(63, to.col + 4);
+ else to.index = Math.min(50, to.index + 4);
+ return { to };
+ }
+ return undefined;
+ }
+
+ function publishScene(...layers) {
+ const ghost = tool.kind === 'place' ? ghostFor(hoverHole) : null;
+ renderer.setScene({
+ circuit: state.circuit,
+ boards: state.boards,
+ selection: state.selection,
+ hoverHole,
+ hoverStripPoints: hoverHole
+ ? holesInStrip(hoverHole).map(h => holeWorldPos(h, state.boards)).filter(Boolean)
+ : null,
+ ghost,
+ pendingWire: gesture && gesture.kind === 'wire'
+ ? { from: gesture.from, toPoint: gesture.toPoint, color: wireColor }
+ : null,
+ pressed: state.pressed
+ }, ...(layers.length > 0 ? layers : ['overlay']));
+ if (onSceneChange) onSceneChange();
+ }
+
+ // --- Gesture handlers ---
+
+ function beginPan(event) {
+ const point = screenPoint(event);
+ gesture = { kind: 'pan', lastX: point.x, lastY: point.y };
+ canvas.style.cursor = 'grabbing';
+ }
+
+ function tryPlace(hole) {
+ const ghost = ghostFor(hole);
+ if (ghost === null) return;
+ if (!ghost.valid) {
+ status.warn(`A ${getComponentDef(tool.type).label} does not fit there.`);
+ return;
+ }
+ const result = state.addComponent(tool.type, ghost.component.anchor, ghostExtraProps(hole));
+ if (!result.ok) {
+ status.warn(result.reason);
+ return;
+ }
+ for (const warning of result.warnings || []) status.warn(warning);
+ status.info(`Placed ${getComponentDef(tool.type).label}.`);
+ }
+
+ function onPointerDown(event) {
+ if (event.button !== 0 && event.button !== 1) return;
+ canvas.focus();
+ const world = worldPoint(event);
+ const screen = screenPoint(event);
+
+ if (event.button === 1 || spaceHeld) {
+ beginPan(event);
+ event.preventDefault();
+ return;
+ }
+
+ const hole = holeAt(world);
+
+ if (tool.kind === 'place') {
+ tryPlace(hole);
+ return;
+ }
+
+ if (tool.kind === 'wire') {
+ if (hole === null) {
+ beginPan(event);
+ return;
+ }
+ gesture = { kind: 'wire', from: hole, toPoint: world };
+ publishScene();
+ return;
+ }
+
+ // --- select mode ---
+ const component = componentAt(world);
+
+ // Interactive parts respond to a plain click, so the user can drive the
+ // simulation without switching tools.
+ if (component) {
+ if (component.type === 'pushButton') {
+ gesture = { kind: 'button', uid: component.uid };
+ state.setPressed(component.uid, true);
+ sim.setButton(component.uid, true);
+ publishScene('dynamic', 'overlay');
+ return;
+ }
+ if (component.type === 'dipSwitch8') {
+ const switchNumber = switchAt(component, world);
+ if (switchNumber > 0) {
+ const on = state.toggleSwitch(component.uid, switchNumber);
+ if (on !== null) {
+ sim.setSwitch(component.uid, switchNumber, on);
+ status.info(`Switch ${switchNumber} ${on ? 'on' : 'off'}.`);
+ }
+ return;
+ }
+ }
+ state.select(component.uid, event.shiftKey);
+ if (onSelectionChange) onSelectionChange();
+ const def = getComponentDef(component.type);
+ gesture = {
+ kind: 'maybeMove',
+ uid: component.uid,
+ movable: !def.anchorless,
+ startX: screen.x,
+ startY: screen.y
+ };
+ return;
+ }
+
+ const wire = hole ? state.wireAtHole(hole) : null;
+ if (wire) {
+ state.select(wire.uid, event.shiftKey);
+ if (onSelectionChange) onSelectionChange();
+ publishScene();
+ return;
+ }
+
+ if (!event.shiftKey) {
+ state.clearSelection();
+ if (onSelectionChange) onSelectionChange();
+ }
+ beginPan(event);
+ }
+
+ function onPointerMove(event) {
+ const world = worldPoint(event);
+ const screen = screenPoint(event);
+
+ if (gesture && gesture.kind === 'pan') {
+ viewport.panBy(screen.x - gesture.lastX, screen.y - gesture.lastY);
+ gesture.lastX = screen.x;
+ gesture.lastY = screen.y;
+ renderer.viewportChanged();
+ return;
+ }
+
+ if (gesture && gesture.kind === 'wire') {
+ gesture.toPoint = world;
+ const hole = holeAt(world);
+ // Snap the preview onto a hole when one is near.
+ if (hole !== null) {
+ const snapped = holeWorldPos(hole, state.boards);
+ if (snapped) gesture.toPoint = snapped;
+ }
+ hoverHole = hole;
+ publishScene();
+ return;
+ }
+
+ if (gesture && gesture.kind === 'maybeMove') {
+ const travelled = Math.hypot(screen.x - gesture.startX, screen.y - gesture.startY);
+ if (travelled > DRAG_THRESHOLD && gesture.movable) {
+ gesture = { kind: 'move', uid: gesture.uid };
+ } else {
+ return;
+ }
+ }
+
+ if (gesture && gesture.kind === 'move') {
+ const hole = holeAt(world);
+ if (hole !== null) {
+ const component = state.circuit.components.find(c => c.uid === gesture.uid);
+ if (component) {
+ const def = getComponentDef(component.type);
+ let anchor = hole;
+ if (def.dipStyle && hole.kind === 'main') {
+ anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
+ }
+ if (!sameHole(component.anchor, anchor)) state.moveComponent(gesture.uid, anchor);
+ }
+ }
+ return;
+ }
+
+ if (gesture && gesture.kind === 'button') return;
+
+ // Idle hover. pointermove is bound to `window` so a drag that leaves the canvas
+ // keeps tracking - but that also means this fires for every mouse move anywhere
+ // on the page. Hit-testing is O(components), so bail out before doing any of it
+ // when the pointer is not actually over the canvas.
+ if (!isOverCanvas(event)) {
+ if (hoverHole !== null || hoverComponentUid !== null) {
+ hoverHole = null;
+ hoverComponentUid = null;
+ publishScene();
+ }
+ return;
+ }
+
+ const hole = holeAt(world);
+ const component = componentAt(world);
+ const componentUid = component ? component.uid : null;
+ const holeChanged = (hole === null) !== (hoverHole === null)
+ || (hole !== null && hoverHole !== null && !sameHole(hole, hoverHole));
+ if (!holeChanged && componentUid === hoverComponentUid) return;
+
+ hoverHole = hole;
+ hoverComponentUid = componentUid;
+ canvas.style.cursor = cursorFor(hole, component);
+ publishScene();
+ }
+
+ function cursorFor(hole, component) {
+ if (spaceHeld) return 'grab';
+ if (tool.kind === 'place') return 'copy';
+ if (tool.kind === 'wire') return hole ? 'crosshair' : 'default';
+ if (component) {
+ if (component.type === 'pushButton' || component.type === 'dipSwitch8') return 'pointer';
+ return 'move';
+ }
+ return 'default';
+ }
+
+ function onPointerUp(event) {
+ if (!gesture) return;
+
+ if (gesture.kind === 'wire') {
+ const hole = holeAt(worldPoint(event));
+ if (hole !== null && !sameHole(hole, gesture.from)) {
+ const result = state.addWire(gesture.from, hole, wireColor);
+ if (!result.ok) status.warn(result.reason);
+ }
+ } else if (gesture.kind === 'button') {
+ state.setPressed(gesture.uid, false);
+ sim.setButton(gesture.uid, false);
+ }
+
+ const wasPan = gesture.kind === 'pan';
+ gesture = null;
+ canvas.style.cursor = wasPan ? 'default' : canvas.style.cursor;
+ publishScene('dynamic', 'overlay');
+ }
+
+ function onPointerLeave() {
+ if (hoverHole === null && hoverComponentUid === null) return;
+ hoverHole = null;
+ hoverComponentUid = null;
+ publishScene();
+ }
+
+ /** Wheel delta in pixels, whatever unit the browser reported it in. */
+ function wheelDeltaPixels(event) {
+ if (event.deltaMode === 1) return event.deltaY * WHEEL_LINE_PX;
+ if (event.deltaMode === 2) {
+ return event.deltaY * (canvas.clientHeight || WHEEL_PAGE_FALLBACK_PX);
+ }
+ return event.deltaY;
+ }
+
+ function onWheel(event) {
+ event.preventDefault();
+ const point = screenPoint(event);
+ viewport.zoomByWheel(point.x, point.y, wheelDeltaPixels(event));
+ renderer.viewportChanged();
+ if (onSceneChange) onSceneChange();
+ }
+
+ function onKeyDown(event) {
+ if (event.key === ' ' && !spaceHeld) {
+ spaceHeld = true;
+ canvas.style.cursor = 'grab';
+ event.preventDefault();
+ return;
+ }
+ if (event.key === 'Escape') {
+ if (gesture && gesture.kind === 'wire') {
+ gesture = null;
+ status.info('Wire cancelled.');
+ } else if (tool.kind !== 'select') {
+ options.setTool({ kind: 'select', type: null });
+ } else {
+ state.clearSelection();
+ if (onSelectionChange) onSelectionChange();
+ }
+ publishScene();
+ return;
+ }
+ if (event.key === 'Delete' || event.key === 'Backspace') {
+ const removed = state.deleteSelected();
+ if (removed > 0) {
+ status.info(`Deleted ${removed} item${removed === 1 ? '' : 's'}.`);
+ if (onSelectionChange) onSelectionChange();
+ }
+ event.preventDefault();
+ return;
+ }
+ if (event.key === 'r' || event.key === 'R') {
+ for (const uid of [...state.selection]) {
+ const result = state.rotateComponent(uid);
+ if (!result.ok && result.reason) status.warn(result.reason);
+ }
+ return;
+ }
+ if (event.key === '+' || event.key === '=') {
+ viewport.zoomAtCenter(renderer.width, renderer.height, 1.2);
+ renderer.viewportChanged();
+ } else if (event.key === '-' || event.key === '_') {
+ viewport.zoomAtCenter(renderer.width, renderer.height, 1 / 1.2);
+ renderer.viewportChanged();
+ }
+ }
+
+ function onKeyUp(event) {
+ if (event.key === ' ') {
+ spaceHeld = false;
+ canvas.style.cursor = 'default';
+ }
+ }
+
+ const invalidateRect = () => { canvasRect = null; };
+
+ bag.on(window, 'resize', invalidateRect);
+ // Capture, so an ancestor scrolling the canvas out from under us also counts.
+ bag.on(window, 'scroll', invalidateRect, true);
+ bag.on(canvas, 'pointerdown', onPointerDown);
+ bag.on(window, 'pointermove', onPointerMove);
+ bag.on(window, 'pointerup', onPointerUp);
+ bag.on(canvas, 'pointerleave', onPointerLeave);
+ bag.on(canvas, 'wheel', onWheel, { passive: false });
+ bag.on(canvas, 'keydown', onKeyDown);
+ bag.on(canvas, 'keyup', onKeyUp);
+ bag.on(canvas, 'contextmenu', e => e.preventDefault());
+
+ return {
+ get tool() { return tool; },
+ setTool(next) {
+ tool = next;
+ gesture = null;
+ canvas.style.cursor = cursorFor(hoverHole, null);
+ publishScene();
+ },
+ get wireColor() { return wireColor; },
+ setWireColor(color) {
+ wireColor = color;
+ for (const uid of [...state.selection]) state.setWireColor(uid, color);
+ publishScene('dynamic', 'overlay');
+ },
+ refresh: publishScene,
+ /** Drop the cached canvas rect. Call whenever the canvas may have moved. */
+ invalidateRect,
+ destroy() {
+ bag.removeAll();
+ }
+ };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/viewport.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/viewport.js
new file mode 100644
index 0000000..c253637
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/viewport.js
@@ -0,0 +1,136 @@
+// Pan / zoom state and the conversions between coordinate spaces.
+//
+// THREE SPACES, and every function here is named for the one it works in:
+// screen space - CSS pixels relative to the canvas element's top-left. This is what
+// mouse events give us (after subtracting the bounding rect).
+// world space - the shared coordinate system all boards live in. board.x/board.y
+// and board-geometry's world positions are in this space.
+// board space - a single board's own frame. board-geometry owns it; nothing here
+// touches it.
+//
+// The mapping is: screen = world * zoom + offset
+// world = (screen - offset) / zoom
+//
+// devicePixelRatio is deliberately NOT part of this. It is a property of the canvas
+// backing store, applied by the renderer as the base transform before the world
+// transform, so every number in this file is in CSS pixels. See renderer.js.
+
+/** Zoom limits. Below MIN a board is a few pixels tall; above MAX holes are huge. */
+export const MIN_ZOOM = 0.15;
+export const MAX_ZOOM = 6;
+
+const ZOOM_STEP = 1.0015; // per unit of wheel deltaY
+
+export function createViewport() {
+ let zoom = 1;
+ let offsetX = 0;
+ let offsetY = 0;
+
+ function clampZoom(value) {
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
+ }
+
+ return {
+ get zoom() { return zoom; },
+ get offsetX() { return offsetX; },
+ get offsetY() { return offsetY; },
+
+ /** Screen point (CSS px, canvas-relative) -> world point. */
+ screenToWorld(screenX, screenY) {
+ return { x: (screenX - offsetX) / zoom, y: (screenY - offsetY) / zoom };
+ },
+
+ /** World point -> screen point (CSS px, canvas-relative). */
+ worldToScreen(worldX, worldY) {
+ return { x: worldX * zoom + offsetX, y: worldY * zoom + offsetY };
+ },
+
+ /** Convert a screen-space distance to world units. */
+ screenToWorldDistance(distance) {
+ return distance / zoom;
+ },
+
+ /** Move the view by a screen-space delta (a drag). */
+ panBy(screenDX, screenDY) {
+ offsetX += screenDX;
+ offsetY += screenDY;
+ },
+
+ /**
+ * Zoom about a fixed screen point, so the world point under the cursor stays
+ * put. This is the whole trick to a wheel zoom that feels right.
+ */
+ zoomAt(screenX, screenY, factor) {
+ const next = clampZoom(zoom * factor);
+ if (next === zoom) return;
+ const worldX = (screenX - offsetX) / zoom;
+ const worldY = (screenY - offsetY) / zoom;
+ zoom = next;
+ offsetX = screenX - worldX * zoom;
+ offsetY = screenY - worldY * zoom;
+ },
+
+ /** Wheel handler helper: converts a wheel delta into a zoom factor. */
+ zoomByWheel(screenX, screenY, deltaY) {
+ this.zoomAt(screenX, screenY, Math.pow(ZOOM_STEP, -deltaY));
+ },
+
+ /** Zoom about the centre of a viewport of the given CSS-pixel size. */
+ zoomAtCenter(width, height, factor) {
+ this.zoomAt(width / 2, height / 2, factor);
+ },
+
+ /** Set zoom directly, keeping the viewport centre fixed. */
+ setZoom(value, width, height) {
+ const next = clampZoom(value);
+ this.zoomAt(width / 2, height / 2, next / zoom);
+ },
+
+ /**
+ * Frame a world-space rect in a viewport of the given CSS-pixel size.
+ * @param {{x:number,y:number,w:number,h:number}} bounds
+ */
+ fit(bounds, width, height, padding = 40) {
+ if (width <= 0 || height <= 0 || bounds.w <= 0 || bounds.h <= 0) return;
+ const scale = Math.min(
+ (width - padding * 2) / bounds.w,
+ (height - padding * 2) / bounds.h
+ );
+ zoom = clampZoom(scale);
+ offsetX = width / 2 - (bounds.x + bounds.w / 2) * zoom;
+ offsetY = height / 2 - (bounds.y + bounds.h / 2) * zoom;
+ },
+
+ /** World-space rect currently visible in a viewport of the given size. */
+ visibleWorldRect(width, height) {
+ const topLeft = this.screenToWorld(0, 0);
+ const bottomRight = this.screenToWorld(width, height);
+ return {
+ x: topLeft.x,
+ y: topLeft.y,
+ w: bottomRight.x - topLeft.x,
+ h: bottomRight.y - topLeft.y
+ };
+ },
+
+ /** Serializable state, for persisting the view between sessions. */
+ toJSON() {
+ return { zoom, offsetX, offsetY };
+ },
+
+ /** Restore from toJSON(). Ignores anything malformed. */
+ restore(state) {
+ if (!state || typeof state !== 'object') return false;
+ if (![state.zoom, state.offsetX, state.offsetY].every(Number.isFinite)) return false;
+ zoom = clampZoom(state.zoom);
+ offsetX = state.offsetX;
+ offsetY = state.offsetY;
+ return true;
+ }
+ };
+}
+
+/** True when two world-space rects overlap - used to cull off-screen boards. */
+export function rectsIntersect(a, b) {
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/constants.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/constants.js
new file mode 100644
index 0000000..df3dc9e
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/constants.js
@@ -0,0 +1,96 @@
+/**
+ * Shared constants for the breadboard simulation engine.
+ *
+ * IMMUTABILITY BOUNDARY (read this before editing anything in engine/):
+ * - constants.js, drive.js, union-find.js, net-builder.js and every model's
+ * pin/description table are PURE: no mutation, no I/O, safe to call from
+ * anywhere and freely testable.
+ * - net-state.js, event-queue.js and simulation.js own the HOT PATH. They use
+ * typed arrays and controlled in-place mutation on purpose. Nothing outside
+ * those three files may mutate their buffers; they expose accessor methods.
+ */
+
+/** Wire-format net level codes. These exact numbers go to the UI in `frame.netLevels`. */
+export const LEVEL_LOW = 0;
+export const LEVEL_HIGH = 1;
+export const LEVEL_HIGHZ = 2;
+export const LEVEL_WEAK_LOW = 3;
+export const LEVEL_WEAK_HIGH = 4;
+export const LEVEL_CONTENTION = 5;
+
+/** Drive strengths, ordered. A higher strength always wins over a lower one. */
+export const STRENGTH_HIGHZ = 0;
+export const STRENGTH_WEAK = 1;
+export const STRENGTH_STRONG = 2;
+export const STRENGTH_SUPPLY = 3;
+
+export const VALUE_LOW = 0;
+export const VALUE_HIGH = 1;
+
+/**
+ * What a chip input samples when its net is neither a clean low nor a clean
+ * high. A floating 74HC input is physically indeterminate, so the engine
+ * refuses to guess: it propagates UNKNOWN through three-valued logic and a
+ * gate whose output cannot be determined releases its driver to high-Z.
+ */
+export const INPUT_LOW = 0;
+export const INPUT_HIGH = 1;
+export const INPUT_UNKNOWN = 2;
+
+/** Maps a net level code to what a chip input pin reads off it. */
+export const LEVEL_TO_INPUT = new Uint8Array([
+ INPUT_LOW, // LEVEL_LOW
+ INPUT_HIGH, // LEVEL_HIGH
+ INPUT_UNKNOWN, // LEVEL_HIGHZ -- floating, indeterminate
+ INPUT_LOW, // LEVEL_WEAK_LOW -- a pull-down still reads as a low
+ INPUT_HIGH, // LEVEL_WEAK_HIGH -- a pull-up still reads as a high
+ INPUT_UNKNOWN, // LEVEL_CONTENTION
+]);
+
+export const SUPPLY_VOLTAGE = 5.0;
+
+/** Nominal voltage a net sits at, by level code. NaN means "indeterminate". */
+export const LEVEL_VOLTAGE = new Float64Array([
+ 0.0, // LEVEL_LOW
+ SUPPLY_VOLTAGE, // LEVEL_HIGH
+ NaN, // LEVEL_HIGHZ
+ 0.0, // LEVEL_WEAK_LOW
+ SUPPLY_VOLTAGE, // LEVEL_WEAK_HIGH
+ NaN, // LEVEL_CONTENTION
+]);
+
+/** Sentinel for "this pin is not connected to any net". */
+export const NO_NET = -1;
+
+/** Default gate propagation delay, ns. Per-type values live in each model. */
+export const DEFAULT_DELAY_NS = 10;
+
+/**
+ * Guards against zero-delay feedback loops (e.g. an inverter wired to itself
+ * through a component with no delay). Exceeding this at a single sim instant
+ * raises a warning and pauses instead of hanging the worker.
+ */
+export const MAX_EVENTS_PER_INSTANT = 100000;
+
+/** LED current thresholds, amps. */
+export const LED_WARN_CURRENT = 0.020;
+export const LED_BURNOUT_CURRENT = 0.050;
+
+/**
+ * Resistance an LED presents when nothing limits it. Real LEDs have a few ohms
+ * of bulk resistance; the point of the small number is that a resistor-less LED
+ * across 5 V computes a burnout-level current, which is what really happens.
+ */
+export const LED_INTRINSIC_OHMS = 10;
+
+/** Forward voltage by LED colour. */
+export const LED_FORWARD_VOLTAGE = Object.freeze({
+ red: 1.8,
+ yellow: 2.1,
+ orange: 2.0,
+ green: 2.1,
+ blue: 3.0,
+ white: 3.2,
+});
+
+export const DEFAULT_LED_FORWARD_VOLTAGE = 2.0;
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/drive.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/drive.js
new file mode 100644
index 0000000..a90026e
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/drive.js
@@ -0,0 +1,128 @@
+/**
+ * Multi-strength signal resolution — the reference (pure) implementation.
+ *
+ * A net's state is the fold of every driver attached to it. For that fold to be
+ * well defined the combine operator MUST be commutative and associative, so the
+ * answer cannot depend on driver order. It is NOT possible to get that by
+ * folding over the six wire-format level codes directly:
+ *
+ * (weakLow . weakHigh) . strongHigh vs weakLow . (weakHigh . strongHigh)
+ *
+ * If "weakLow . weakHigh" collapsed to `contention` and contention were
+ * absorbing, the left side would be `contention` while the right side is
+ * `strongHigh` — and the right side is the physically correct answer, because a
+ * strong driver really does overpower a pull-up/pull-down pair. Contention is
+ * therefore NOT absorbing across strengths.
+ *
+ * The fix is to fold in a richer domain and project to a level code only at the
+ * end. A drive is a pair (strength, valueMask) where valueMask is a bitset over
+ * {0,1}. Combining takes the stronger drive, or unions the value masks when the
+ * strengths tie. That is a lexicographic semilattice: commutative, associative,
+ * idempotent, with high-Z as the identity element.
+ *
+ * A drive is packed into one small integer so the whole fold is branch-light and
+ * allocation-free: packed = strength * 4 + valueMask.
+ */
+
+import {
+ LEVEL_LOW,
+ LEVEL_HIGH,
+ LEVEL_HIGHZ,
+ LEVEL_WEAK_LOW,
+ LEVEL_WEAK_HIGH,
+ LEVEL_CONTENTION,
+ STRENGTH_HIGHZ,
+ STRENGTH_WEAK,
+ STRENGTH_STRONG,
+ STRENGTH_SUPPLY,
+ VALUE_LOW,
+ VALUE_HIGH,
+} from './constants.js';
+
+export const MASK_NONE = 0;
+export const MASK_LOW = 1;
+export const MASK_HIGH = 2;
+export const MASK_BOTH = 3;
+
+/** The identity element of the fold: drives nothing. */
+export const DRIVE_HIGHZ = STRENGTH_HIGHZ * 4 + MASK_NONE;
+
+/** Packs a (strength, value) pair into a drive. */
+export function makeDrive(strength, value) {
+ if (strength === STRENGTH_HIGHZ) return DRIVE_HIGHZ;
+ return strength * 4 + (value === VALUE_HIGH ? MASK_HIGH : MASK_LOW);
+}
+
+export function driveStrength(drive) {
+ return drive >> 2;
+}
+
+export function driveMask(drive) {
+ return drive & 3;
+}
+
+/**
+ * The monoid operator. Commutative, associative, idempotent; DRIVE_HIGHZ is the
+ * identity. Pure.
+ */
+export function combineDrive(a, b) {
+ const sa = a >> 2;
+ const sb = b >> 2;
+ if (sa > sb) return a;
+ if (sb > sa) return b;
+ return sa * 4 + ((a & 3) | (b & 3));
+}
+
+/** Folds a list of packed drives. Pure; order-independent by construction. */
+export function foldDrives(drives) {
+ let acc = DRIVE_HIGHZ;
+ for (let i = 0; i < drives.length; i++) acc = combineDrive(acc, drives[i]);
+ return acc;
+}
+
+/**
+ * Projects a folded drive onto the six wire-format level codes.
+ *
+ * Decisions the spec left open, pinned here:
+ * - strong beats an opposing weak outright, and raises no warning: that is
+ * just a pull-up being overdriven, the single most common breadboard idiom.
+ * - a pull-up fighting a pull-down (weakLow + weakHigh, no stronger driver)
+ * yields LEVEL_CONTENTION as a level, but raises NO contention warning —
+ * the spec scopes that warning to conflicting *strong* drivers, and a
+ * resistor divider is not a fault. See `driveFault` below.
+ * - high-Z is the identity: high-Z combined with anything is that thing.
+ */
+export function driveToLevel(drive) {
+ const strength = drive >> 2;
+ const mask = drive & 3;
+ if (strength === STRENGTH_HIGHZ || mask === MASK_NONE) return LEVEL_HIGHZ;
+ if (mask === MASK_BOTH) return LEVEL_CONTENTION;
+ if (strength === STRENGTH_WEAK) return mask === MASK_HIGH ? LEVEL_WEAK_HIGH : LEVEL_WEAK_LOW;
+ return mask === MASK_HIGH ? LEVEL_HIGH : LEVEL_LOW;
+}
+
+export const FAULT_NONE = 0;
+export const FAULT_CONTENTION = 1;
+export const FAULT_SHORT_CIRCUIT = 2;
+
+/**
+ * Classifies a folded drive as a fault, given whether any strong driver of the
+ * losing polarity is also present.
+ *
+ * - two supply drivers of opposite polarity on one net is rail+ tied to rail-:
+ * a short circuit, which pauses the sim.
+ * - two strong drivers of opposite polarity is ordinary output contention.
+ * - a chip output fighting a supply rail is also contention: supply wins the
+ * level, but the chip is still sinking or sourcing into a rail.
+ */
+export function driveFault(drive, opposingStrongPresent) {
+ const strength = drive >> 2;
+ const mask = drive & 3;
+ if (mask === MASK_BOTH) {
+ return strength === STRENGTH_SUPPLY ? FAULT_SHORT_CIRCUIT : strength === STRENGTH_WEAK ? FAULT_NONE : FAULT_CONTENTION;
+ }
+ if (strength === STRENGTH_SUPPLY && opposingStrongPresent) return FAULT_CONTENTION;
+ return FAULT_NONE;
+}
+
+export { STRENGTH_HIGHZ, STRENGTH_WEAK, STRENGTH_STRONG, STRENGTH_SUPPLY, VALUE_LOW, VALUE_HIGH };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/event-queue.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/event-queue.js
new file mode 100644
index 0000000..384f2a6
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/event-queue.js
@@ -0,0 +1,141 @@
+/**
+ * Binary min-heap event queue, struct-of-arrays over typed arrays.
+ *
+ * MUTABLE HOT PATH. No object is allocated per event: an event is six parallel
+ * slots, and `pop()` writes the popped event into scalar fields on the queue
+ * rather than returning a record.
+ *
+ * Ordering key is the pair (timeNs, seq). A binary heap is not stable, so
+ * equal-time events would otherwise pop in an order that shifts when unrelated
+ * events are inserted; `seq` is a monotonically increasing insertion counter
+ * that makes ties resolve in insertion order. Simulation output is therefore
+ * reproducible regardless of the order components appear in the document.
+ *
+ * `seq` is a float64 counter, not int32: at a few million events per second a
+ * 32-bit counter wraps in under half an hour of wall time and the ordering
+ * silently inverts. Float64 counts exactly to 2^53.
+ */
+
+/** Schedule a driver change: target = driverId, arg = packed drive code. */
+export const EVENT_DRIVE = 0;
+/** Wake a device with no input change: target = device index, arg = timer id. */
+export const EVENT_TIMER = 1;
+
+export class EventQueue {
+ constructor(capacity = 1024) {
+ this.length = 0;
+ this.seqCounter = 0;
+ this.time = new Float64Array(capacity);
+ this.seq = new Float64Array(capacity);
+ this.kind = new Uint8Array(capacity);
+ this.target = new Int32Array(capacity);
+ this.arg = new Int32Array(capacity);
+ this.gen = new Int32Array(capacity);
+
+ // Fields written by pop(). Read them immediately; the next pop overwrites.
+ this.outTime = 0;
+ this.outKind = 0;
+ this.outTarget = 0;
+ this.outArg = 0;
+ this.outGen = 0;
+ }
+
+ get size() {
+ return this.length;
+ }
+
+ push(timeNs, kind, target, arg, gen) {
+ if (this.length === this.time.length) this.#grow();
+ let i = this.length++;
+ const seq = this.seqCounter++;
+ const time = this.time;
+ const seqs = this.seq;
+
+ // Sift up, moving the hole rather than swapping pairs.
+ while (i > 0) {
+ const parent = (i - 1) >> 1;
+ const pt = time[parent];
+ if (pt < timeNs || (pt === timeNs && seqs[parent] < seq)) break;
+ this.#copy(parent, i);
+ i = parent;
+ }
+ time[i] = timeNs;
+ seqs[i] = seq;
+ this.kind[i] = kind;
+ this.target[i] = target;
+ this.arg[i] = arg;
+ this.gen[i] = gen;
+ }
+
+ /** Time of the earliest event, or Infinity when empty. */
+ peekTime() {
+ return this.length === 0 ? Infinity : this.time[0];
+ }
+
+ /** Pops the earliest event into the out* fields. Returns false when empty. */
+ pop() {
+ if (this.length === 0) return false;
+ this.outTime = this.time[0];
+ this.outKind = this.kind[0];
+ this.outTarget = this.target[0];
+ this.outArg = this.arg[0];
+ this.outGen = this.gen[0];
+
+ const last = --this.length;
+ if (last === 0) return true;
+ const time = this.time;
+ const seqs = this.seq;
+ const lastTime = time[last];
+ const lastSeq = seqs[last];
+
+ let i = 0;
+ for (;;) {
+ const left = i * 2 + 1;
+ if (left >= last) break;
+ const right = left + 1;
+ let child = left;
+ if (right < last) {
+ const lt = time[left];
+ const rt = time[right];
+ if (rt < lt || (rt === lt && seqs[right] < seqs[left])) child = right;
+ }
+ const ct = time[child];
+ if (lastTime < ct || (lastTime === ct && lastSeq < seqs[child])) break;
+ this.#copy(child, i);
+ i = child;
+ }
+ this.#copy(last, i);
+ return true;
+ }
+
+ #copy(from, to) {
+ this.time[to] = this.time[from];
+ this.seq[to] = this.seq[from];
+ this.kind[to] = this.kind[from];
+ this.target[to] = this.target[from];
+ this.arg[to] = this.arg[from];
+ this.gen[to] = this.gen[from];
+ }
+
+ #grow() {
+ const capacity = this.time.length * 2;
+ const time = new Float64Array(capacity);
+ time.set(this.time);
+ const seq = new Float64Array(capacity);
+ seq.set(this.seq);
+ const kind = new Uint8Array(capacity);
+ kind.set(this.kind);
+ const target = new Int32Array(capacity);
+ target.set(this.target);
+ const arg = new Int32Array(capacity);
+ arg.set(this.arg);
+ const gen = new Int32Array(capacity);
+ gen.set(this.gen);
+ this.time = time;
+ this.seq = seq;
+ this.kind = kind;
+ this.target = target;
+ this.arg = arg;
+ this.gen = gen;
+ }
+}
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
new file mode 100644
index 0000000..bbabf95
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js
@@ -0,0 +1,17 @@
+/**
+ * Registers every model shipped in milestone 1.
+ *
+ * Importing this module is the only thing needed to populate the registry;
+ * each model file self-registers via defineModel(). Adding a counter, register,
+ * EEPROM or NE555 later means adding a file and one import line here.
+ */
+
+import './logic-ic.js';
+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/led.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/led.js
new file mode 100644
index 0000000..0ff8243
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/led.js
@@ -0,0 +1,130 @@
+/**
+ * LED. Pin 1 is the anode, pin 2 the cathode.
+ *
+ * An LED is a load, not a driver: it presents high-Z on both pins and never
+ * asserts a level. What it does is compute forward current so the UI can show
+ * brightness, and so overcurrent and burnout can be reported.
+ *
+ * I = (Vanode - Vcathode - Vf) / Rseries
+ *
+ * Rseries is the dominant current-limiting resistor on each side plus the LED's
+ * own bulk resistance. "Dominant" means the smallest resistor touching that
+ * net, which is the right answer for the normal one-resistor-per-side case and
+ * a sane approximation otherwise — a full nodal solve is out of scope for a
+ * logic simulator.
+ *
+ * The bulk resistance is what makes a resistor-less LED behave the way it does
+ * in real life: 5 V straight across a red LED computes ~320 mA and burns it
+ * out, which is exactly the mistake a beginner makes on a real breadboard and
+ * exactly what this simulator exists to show them.
+ *
+ * Burnout latches. A burned LED conducts nothing and stays burned until the
+ * circuit is reloaded or explicitly reset — the spec calls for a persistent
+ * flag, not a transient warning.
+ */
+
+import {
+ LEVEL_VOLTAGE,
+ LED_FORWARD_VOLTAGE,
+ DEFAULT_LED_FORWARD_VOLTAGE,
+ LED_INTRINSIC_OHMS,
+ LED_WARN_CURRENT,
+ LED_BURNOUT_CURRENT,
+} from '../constants.js';
+import { defineModel } from './registry.js';
+
+const PIN_ANODE = 0;
+const PIN_CATHODE = 1;
+
+function forwardVoltageOf(component) {
+ const colour = String(component?.props?.color ?? 'red').toLowerCase();
+ return LED_FORWARD_VOLTAGE[colour] ?? DEFAULT_LED_FORWARD_VOLTAGE;
+}
+
+/**
+ * Forward current in amps, or 0 when the LED is off, reverse-biased, burned, or
+ * sitting on a net whose level is indeterminate. Pure — exported so the current
+ * maths can be tested without standing up a whole simulation.
+ */
+export function ledCurrent(anodeLevel, cathodeLevel, forwardVoltage, seriesOhms) {
+ const vAnode = LEVEL_VOLTAGE[anodeLevel];
+ const vCathode = LEVEL_VOLTAGE[cathodeLevel];
+ if (Number.isNaN(vAnode) || Number.isNaN(vCathode)) return 0;
+ const across = vAnode - vCathode - forwardVoltage;
+ if (across <= 0) return 0;
+ return across / seriesOhms;
+}
+
+defineModel({
+ type: 'led',
+ pinCount: 2,
+ delayNs: 0,
+ functionalPins: [[1, 2]],
+
+ createState(component) {
+ return {
+ forwardVoltage: forwardVoltageOf(component),
+ seriesOhms: LED_INTRINSIC_OHMS,
+ current: 0,
+ burned: false,
+ warnedOvercurrent: false,
+ };
+ },
+
+ init(ctx, inst) {
+ const anodeSide = ctx.dominantSeriesOhms(inst.pins[PIN_ANODE]);
+ const cathodeSide = ctx.dominantSeriesOhms(inst.pins[PIN_CATHODE]);
+ inst.state.seriesOhms = anodeSide + cathodeSide + LED_INTRINSIC_OHMS;
+ this.evaluate(ctx, inst, { reason: 0, pin: -1 });
+ },
+
+ evaluate(ctx, inst) {
+ const state = inst.state;
+ if (state.burned) {
+ ctx.setLedCurrent(inst, 0);
+ return;
+ }
+ const current = ledCurrent(
+ ctx.level(inst, PIN_ANODE),
+ ctx.level(inst, PIN_CATHODE),
+ state.forwardVoltage,
+ state.seriesOhms,
+ );
+ if (current === state.current) return;
+ state.current = current;
+
+ if (current > LED_BURNOUT_CURRENT) {
+ state.burned = true;
+ state.current = 0;
+ ctx.setLedCurrent(inst, 0);
+ ctx.warn(
+ 'ledBurnout',
+ [inst.uid],
+ inst.pins[PIN_ANODE],
+ `LED ${inst.uid} drew ${(current * 1000).toFixed(0)} mA through ${state.seriesOhms.toFixed(0)} ohms and burned out`,
+ );
+ return;
+ }
+
+ ctx.setLedCurrent(inst, current);
+
+ if (current > LED_WARN_CURRENT && !state.warnedOvercurrent) {
+ // Latched for the lifetime of this load, NOT reset when the current
+ // falls back. An earlier version cleared the latch on every falling
+ // edge, which sounds like "one warning per excursion" but means a
+ // blinking LED re-warns on every single cycle — hundreds of
+ // thousands of messages a second on a running oscillator. Being
+ // over-current once is the fact worth reporting; the user does not
+ // need telling again on the next blink.
+ state.warnedOvercurrent = true;
+ ctx.warn(
+ 'ledOvercurrent',
+ [inst.uid],
+ inst.pins[PIN_ANODE],
+ `LED ${inst.uid} is drawing ${(current * 1000).toFixed(1)} mA (over ${LED_WARN_CURRENT * 1000} mA)`,
+ );
+ }
+ },
+});
+
+export const LED_TYPE = 'led';
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic-ic.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic-ic.js
new file mode 100644
index 0000000..9da0f71
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic-ic.js
@@ -0,0 +1,186 @@
+/**
+ * 74HC combinational logic ICs, 14-pin DIP.
+ *
+ * PINOUTS ARE THE WHOLE POINT OF THIS FILE — a wrong one is invisible in a
+ * passing test suite and poisons every circuit built on it. Pin numbers below
+ * are 1-based, exactly as printed on a datasheet, and are converted to 0-based
+ * indexes at registration. Two that catch people out:
+ *
+ * - 74HC02 is NOT the '00 layout. The NOR gate's OUTPUT comes first:
+ * pin 1 is 1Y, not 1A. Cloning the '00 table here yields a chip that looks
+ * right and behaves wrong.
+ * - 74HC30's eight inputs are NOT pins 1-8. Pins 9, 10 and 13 are no-connects
+ * and inputs G and H live on 11 and 12.
+ *
+ * '00, '08, '32 and '86 really do share one layout
+ * (1A 1B 1Y 2A 2B 2Y GND 3Y 3A 3B 4Y 4A 4B VCC), so that table is written once.
+ *
+ * Propagation delays are typical tPD at 5 V, 25 C from the NXP/TI 74HC data
+ * sheets, rounded to whole ns.
+ */
+
+import {
+ INPUT_UNKNOWN,
+ LEVEL_TO_INPUT,
+ LEVEL_HIGH,
+ LEVEL_WEAK_HIGH,
+ LEVEL_LOW,
+ LEVEL_WEAK_LOW,
+ STRENGTH_STRONG,
+ STRENGTH_HIGHZ,
+ VALUE_HIGH,
+ VALUE_LOW,
+} from '../constants.js';
+import { defineModel, WAKE_PIN } from './registry.js';
+import { applyOp, OP_AND, OP_NAND, OP_OR, OP_NOR, OP_XOR, OP_NOT } from './logic.js';
+
+/** Quad 2-input gate layout shared by 74HC00, '08, '32 and '86. */
+const QUAD_2IN_PINS = [
+ { out: 3, ins: [1, 2] },
+ { out: 6, ins: [4, 5] },
+ { out: 8, ins: [9, 10] },
+ { out: 11, ins: [12, 13] },
+];
+
+/** 74HC02 quad 2-input NOR — outputs first. */
+const QUAD_NOR_PINS = [
+ { out: 1, ins: [2, 3] },
+ { out: 4, ins: [5, 6] },
+ { out: 10, ins: [8, 9] },
+ { out: 13, ins: [11, 12] },
+];
+
+/** 74HC04 hex inverter. */
+const HEX_INV_PINS = [
+ { out: 2, ins: [1] },
+ { out: 4, ins: [3] },
+ { out: 6, ins: [5] },
+ { out: 8, ins: [9] },
+ { out: 10, ins: [11] },
+ { out: 12, ins: [13] },
+];
+
+/** 74HC30 single 8-input NAND. Pins 9, 10 and 13 are no-connects. */
+const NAND8_PINS = [{ out: 8, ins: [1, 2, 3, 4, 5, 6, 11, 12] }];
+
+const IC_TABLE = [
+ { type: '74HC00', op: OP_NAND, gates: QUAD_2IN_PINS, delayNs: 9 },
+ { type: '74HC02', op: OP_NOR, gates: QUAD_NOR_PINS, delayNs: 9 },
+ { type: '74HC04', op: OP_NOT, gates: HEX_INV_PINS, delayNs: 8 },
+ { type: '74HC08', op: OP_AND, gates: QUAD_2IN_PINS, delayNs: 9 },
+ { type: '74HC32', op: OP_OR, gates: QUAD_2IN_PINS, delayNs: 9 },
+ { type: '74HC86', op: OP_XOR, gates: QUAD_2IN_PINS, delayNs: 12 },
+ { type: '74HC30', op: OP_NAND, gates: NAND8_PINS, delayNs: 12 },
+];
+
+const VCC_PIN = 14;
+const GND_PIN = 7;
+const PIN_COUNT = 14;
+const NO_GATE = 255;
+
+/** Scratch buffer for gate inputs. Single-threaded worker, so one is enough. */
+const inputScratch = new Uint8Array(8);
+
+function isPowered(ctx, inst) {
+ const vcc = ctx.level(inst, inst.model.vccIndex);
+ const gnd = ctx.level(inst, inst.model.gndIndex);
+ return (vcc === LEVEL_HIGH || vcc === LEVEL_WEAK_HIGH) && (gnd === LEVEL_LOW || gnd === LEVEL_WEAK_LOW);
+}
+
+function evaluateGate(ctx, inst, gateIndex, powered) {
+ const gate = inst.model.gateList[gateIndex];
+ if (!powered) {
+ // An unpowered chip drives nothing. Its outputs are high-Z, not low.
+ ctx.drive(inst, gate.out, STRENGTH_HIGHZ, VALUE_LOW, inst.delayNs);
+ return;
+ }
+ const ins = gate.ins;
+ for (let i = 0; i < ins.length; i++) inputScratch[i] = LEVEL_TO_INPUT[ctx.level(inst, ins[i])];
+ const result = applyOp(inst.model.op, inputScratch, ins.length);
+ if (result === INPUT_UNKNOWN) {
+ // Indeterminate output: release the pin rather than invent a level, so
+ // the unknown keeps propagating instead of being laundered into a 0.
+ ctx.drive(inst, gate.out, STRENGTH_HIGHZ, VALUE_LOW, inst.delayNs);
+ return;
+ }
+ ctx.drive(inst, gate.out, STRENGTH_STRONG, result === 1 ? VALUE_HIGH : VALUE_LOW, inst.delayNs);
+}
+
+function evaluateAll(ctx, inst) {
+ const powered = isPowered(ctx, inst);
+ const gates = inst.model.gateList;
+ for (let g = 0; g < gates.length; g++) evaluateGate(ctx, inst, g, powered);
+}
+
+for (const entry of IC_TABLE) {
+ const gateList = entry.gates.map((gate) => ({
+ out: gate.out - 1,
+ ins: gate.ins.map((p) => p - 1),
+ }));
+
+ // pin -> the one gate it feeds, for O(1) wake dispatch.
+ const gateOfPin = new Uint8Array(PIN_COUNT).fill(NO_GATE);
+ for (let g = 0; g < gateList.length; g++) {
+ for (const pin of gateList[g].ins) gateOfPin[pin] = g;
+ }
+
+ defineModel({
+ type: entry.type,
+ pinCount: PIN_COUNT,
+ delayNs: entry.delayNs,
+ vcc: VCC_PIN,
+ gnd: GND_PIN,
+ vccIndex: VCC_PIN - 1,
+ gndIndex: GND_PIN - 1,
+ op: entry.op,
+ gateList,
+ gateOfPin,
+ outputPins: gateList.map((g) => g.out),
+ inputPins: gateList.flatMap((g) => g.ins),
+
+ init(ctx, inst) {
+ if (!isPowered(ctx, inst)) {
+ ctx.staticWarning(
+ 'unpoweredChip',
+ `${inst.type} ${inst.uid}: pin ${VCC_PIN} (VCC) / pin ${GND_PIN} (GND) are not tied to 5V and ground`,
+ [inst.uid],
+ );
+ }
+ // An input is floating when nothing else in the circuit shares its
+ // net: no wire, no other pin, so nothing can ever drive it. That is
+ // different from an input that simply has not been driven yet at
+ // load time, which is normal and must not warn.
+ for (const gate of inst.model.gateList) {
+ for (const pin of gate.ins) {
+ if (inst.pins[pin] < 0 || ctx.netListenerCount(inst.pins[pin]) <= 1) {
+ ctx.staticWarning('floatingInput', `${inst.type} ${inst.uid}: pin ${pin + 1} is not connected to anything`, [
+ inst.uid,
+ ]);
+ }
+ }
+ }
+ },
+
+ evaluate(ctx, inst, wake) {
+ // Anything that is not a pin change — init, a self-scheduled timer,
+ // or any wake reason added later — re-evaluates the whole chip.
+ // Testing `=== WAKE_INIT` instead left WAKE_TIMER falling through to
+ // the pin path with wake.pin === -1, where gateOfPin[-1] is
+ // `undefined`, `undefined === NO_GATE` is false, and gateList
+ // [undefined].ins throws. ctx.scheduleSelf is a public API, so that
+ // was reachable by any model author following the registry docs.
+ if (wake.reason !== WAKE_PIN) {
+ evaluateAll(ctx, inst);
+ return;
+ }
+ const pin = wake.pin;
+ if (pin === inst.model.vccIndex || pin === inst.model.gndIndex) {
+ evaluateAll(ctx, inst);
+ return;
+ }
+ const gate = inst.model.gateOfPin[pin];
+ if (!(gate >= 0) || gate === NO_GATE) return;
+ evaluateGate(ctx, inst, gate, isPowered(ctx, inst));
+ },
+ });
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic.js
new file mode 100644
index 0000000..4755f96
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/logic.js
@@ -0,0 +1,80 @@
+/**
+ * Three-valued combinational logic primitives.
+ *
+ * Pure. Inputs and outputs are INPUT_LOW / INPUT_HIGH / INPUT_UNKNOWN.
+ *
+ * Unknown is not "assume zero". A 74HC input left floating is genuinely
+ * indeterminate, and quietly calling it a low produces a simulation that looks
+ * plausible and lies. Instead unknown propagates, EXCEPT where a controlling
+ * input settles the result on its own: a NAND with one input low outputs high
+ * no matter what the other input is doing, and reporting that as unknown would
+ * be its own kind of wrong.
+ */
+
+import { INPUT_LOW, INPUT_HIGH, INPUT_UNKNOWN } from '../constants.js';
+
+export function andOf(values, count) {
+ let unknown = false;
+ for (let i = 0; i < count; i++) {
+ const v = values[i];
+ if (v === INPUT_LOW) return INPUT_LOW; // controlling value
+ if (v === INPUT_UNKNOWN) unknown = true;
+ }
+ return unknown ? INPUT_UNKNOWN : INPUT_HIGH;
+}
+
+export function orOf(values, count) {
+ let unknown = false;
+ for (let i = 0; i < count; i++) {
+ const v = values[i];
+ if (v === INPUT_HIGH) return INPUT_HIGH; // controlling value
+ if (v === INPUT_UNKNOWN) unknown = true;
+ }
+ return unknown ? INPUT_UNKNOWN : INPUT_LOW;
+}
+
+export function xorOf(values, count) {
+ let parity = 0;
+ for (let i = 0; i < count; i++) {
+ const v = values[i];
+ if (v === INPUT_UNKNOWN) return INPUT_UNKNOWN; // XOR has no controlling value
+ parity ^= v;
+ }
+ return parity === 1 ? INPUT_HIGH : INPUT_LOW;
+}
+
+export function invert(value) {
+ return value === INPUT_UNKNOWN ? INPUT_UNKNOWN : value === INPUT_LOW ? INPUT_HIGH : INPUT_LOW;
+}
+
+export const OP_AND = 0;
+export const OP_NAND = 1;
+export const OP_OR = 2;
+export const OP_NOR = 3;
+export const OP_XOR = 4;
+export const OP_XNOR = 5;
+export const OP_NOT = 6;
+export const OP_BUF = 7;
+
+export function applyOp(op, values, count) {
+ switch (op) {
+ case OP_AND:
+ return andOf(values, count);
+ case OP_NAND:
+ return invert(andOf(values, count));
+ case OP_OR:
+ return orOf(values, count);
+ case OP_NOR:
+ return invert(orOf(values, count));
+ case OP_XOR:
+ return xorOf(values, count);
+ case OP_XNOR:
+ return invert(xorOf(values, count));
+ case OP_NOT:
+ return invert(values[0]);
+ case OP_BUF:
+ return values[0];
+ default:
+ return INPUT_UNKNOWN;
+ }
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/registry.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/registry.js
new file mode 100644
index 0000000..fbf3403
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/registry.js
@@ -0,0 +1,146 @@
+/**
+ * Component model registry.
+ *
+ * Deliberately shaped for parts this milestone does NOT ship. Milestone 1 is
+ * seven combinational chips plus passives, which would be satisfied by a much
+ * flatter `{ pins, fn }` shape — but counters, registers, EEPROMs and the NE555
+ * are next, and the spec forbids reshaping the registry for them. So the model
+ * interface already carries the four things stateful parts need:
+ *
+ * (a) per-instance mutable state. `createState(component)` runs once per
+ * placed instance at load. State never lives in a closure captured at
+ * definition time, or every instance of a part would share one counter.
+ * (b) self-scheduled wake-ups. `ctx.scheduleSelf(inst, deltaNs, timerId)`
+ * fires `evaluate` with no input change — an astable NE555 or a clock
+ * source is just a model that reschedules itself.
+ * (c) edge sensitivity. `evaluate` receives a wake record carrying which pin
+ * moved and both its previous and new level, so a flip-flop can test for
+ * a rising edge instead of re-deriving state from levels.
+ * (d) runtime pin direction. Every connected pin owns a driver from load, and
+ * a model may drive or release any pin at any moment. Direction is never
+ * baked into a static descriptor, so a tri-state data bus (an EEPROM
+ * releasing its data pins when /OE is high) needs no new machinery.
+ *
+ * A model definition is frozen, shared by all instances of the type, and pure
+ * apart from the mutation it performs through `ctx`.
+ *
+ * @typedef {object} ModelDefinition
+ * @property {string} type
+ * @property {number} pinCount
+ * @property {number} [delayNs] default propagation delay
+ * @property {number} [vcc] 1-based power pin, if the part needs power
+ * @property {number} [gnd] 1-based ground pin
+ * @property {Array<[number,number]>} [ties] permanent internal shorts, 1-based
+ * @property {(component:object)=>object|null} [createState]
+ * @property {(ctx:object, inst:object)=>void} [init]
+ * @property {(ctx:object, inst:object, wake:object)=>void} evaluate
+ * @property {(component:object)=>Array<{pin:*,hole:*}>} [pinHoles] override
+ * @property {Array<[number,number]>} [functionalPins] 1-based pin pairs that must
+ * land on DIFFERENT nets for the part to do anything (an LED's two legs, a
+ * resistor's two ends). Used only for the load-time self-short check.
+ */
+
+import { componentPinHoles, staticPinBonds, componentSelfShorts, switchablePinBonds } from '../../shared/component-pins.js';
+import { DEFAULT_DELAY_NS } from '../constants.js';
+
+/** Why a model is being evaluated. */
+export const WAKE_INIT = 0;
+export const WAKE_PIN = 1;
+export const WAKE_TIMER = 2;
+
+const definitions = new Map();
+
+/** Registers a model definition. Later registration of the same type replaces it. */
+export function defineModel(definition) {
+ if (!definition || typeof definition.type !== 'string') throw new Error('model definition needs a type');
+ if (typeof definition.evaluate !== 'function') throw new Error(`model ${definition.type} needs evaluate()`);
+ const frozen = Object.freeze({
+ delayNs: DEFAULT_DELAY_NS,
+ pinCount: 0,
+ ties: [],
+ ...definition,
+ });
+ definitions.set(frozen.type, frozen);
+ return frozen;
+}
+
+export function getModel(type) {
+ return definitions.get(type) ?? null;
+}
+
+export function knownTypes() {
+ return [...definitions.keys()];
+}
+
+/**
+ * The façade the rest of the engine uses. Kept as an object rather than loose
+ * functions so tests can substitute a registry with a subset of models.
+ */
+export const registry = Object.freeze({
+ get: getModel,
+ knownTypes,
+
+ /** Pin -> hole mapping. Models may override; default is the shared helper. */
+ pinHoles(component) {
+ const model = getModel(component?.type);
+ if (model?.pinHoles) return model.pinHoles(component);
+ try {
+ return componentPinHoles(component) ?? [];
+ } catch {
+ return [];
+ }
+ },
+
+ /**
+ * Pin pairs the BOARD GEOMETRY puts in a single strip, 0-based.
+ *
+ * shared/component-pins.js already excludes pins that are tied by design, so
+ * a push button's 1-2 and 3-4 do not appear here — verified against every
+ * component type rather than assumed.
+ */
+ selfShorts(component) {
+ const pairs = componentSelfShorts(component) ?? [];
+ return pairs.map(([a, b]) => [a - 1, b - 1]);
+ },
+
+ /**
+ * Pin pairs that are supposed to be SEPARATE nets for the component to have
+ * any effect, 0-based: the two sides of every switch contact, and the two
+ * terminals of a two-terminal passive. Design ties are subtracted, so a
+ * permanently-bonded pair is never reported.
+ */
+ functionalPairs(component) {
+ const model = getModel(component?.type);
+ if (!model) return [];
+ const tied = new Set(
+ (staticPinBonds(component) ?? []).map(([a, b]) => (a < b ? `${a}-${b}` : `${b}-${a}`)),
+ );
+ const pairs = [];
+ const add = (a, b) => {
+ const key = a < b ? `${a}-${b}` : `${b}-${a}`;
+ if (!tied.has(key)) pairs.push([a - 1, b - 1]);
+ };
+ for (const bond of switchablePinBonds(component) ?? []) {
+ if (Array.isArray(bond?.pins) && bond.pins.length === 2) add(bond.pins[0], bond.pins[1]);
+ }
+ for (const [a, b] of model.functionalPins ?? []) add(a, b);
+ return pairs;
+ },
+
+ /**
+ * Permanent internal shorts, as 0-based pin index pairs.
+ *
+ * shared/component-pins.js is the authority here — it also owns the pin
+ * geometry, and a push button's two A-side pins land on different columns,
+ * so getting these out of step with the layout would silently break every
+ * button. A model's own `ties` are only a fallback for types the shared
+ * registry does not know about.
+ */
+ internalTies(component) {
+ const shared = staticPinBonds(component);
+ if (shared && shared.length > 0) return shared.map(([a, b]) => [a - 1, b - 1]);
+ const model = getModel(component?.type);
+ if (!model || model.ties.length === 0) return [];
+ return model.ties.map(([a, b]) => [a - 1, b - 1]);
+ },
+});
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/resistor.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/resistor.js
new file mode 100644
index 0000000..a187cc8
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/resistor.js
@@ -0,0 +1,78 @@
+/**
+ * Resistor — a bidirectional weak pass element.
+ *
+ * A resistor is not unioned into its neighbours' nets at load, because then a
+ * pull-up would be indistinguishable from a wire. Instead each end drives the
+ * other end WEAKLY with whatever it sees. That single rule covers all three
+ * ways resistors get used on a breadboard:
+ *
+ * - pull-up / pull-down: rail at 5 V on one side, weak high on the other,
+ * which any real chip output overrides without a fight and without a
+ * contention warning.
+ * - LED current limiting: the LED model reads `ohms` when it works out its
+ * series resistance.
+ * - net-to-net series link: signal passes, attenuated to weak.
+ *
+ * Each side resolves the level of its own net EXCLUDING this resistor's own
+ * contribution to it. Without that exclusion the resistor would read back its
+ * own output and hold a value forever after the source went away.
+ *
+ * The 1 ns delay is not a real RC time constant, it exists so that two
+ * resistors facing each other cannot form a zero-delay loop and trip the
+ * delta-cycle guard.
+ *
+ * LIMIT OF THIS MODEL, and it is a hard one. There is NO representation of any
+ * voltage between 0 V and 5 V. Two resistors in series from rail to ground put
+ * their midpoint at weakLow + weakHigh, which resolves to LEVEL_CONTENTION and
+ * is read by chip inputs as INPUT_UNKNOWN — not 2.5 V. For digital logic that
+ * is the honest answer, since a divider midpoint IS an invalid logic level. But
+ * anything needing real node voltages — NE555 RC timing above all, and any
+ * analogue behaviour generally — cannot be built on this. That is a nodal
+ * solver, a genuinely different engine, NOT an extension of the weak-pass rule.
+ * Budget for it as new work rather than discovering it during milestone 2.
+ */
+
+import { LEVEL_HIGH, LEVEL_WEAK_HIGH, LEVEL_LOW, LEVEL_WEAK_LOW, STRENGTH_WEAK, STRENGTH_HIGHZ, VALUE_HIGH, VALUE_LOW } from '../constants.js';
+import { defineModel, WAKE_PIN } from './registry.js';
+
+const RESISTOR_DELAY_NS = 1;
+const DEFAULT_OHMS = 330;
+
+/** Passes one side's level to the other, attenuated to weak strength. */
+function pass(ctx, inst, fromPin, toPin) {
+ const level = ctx.levelExcludingSelf(inst, fromPin);
+ if (level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH) {
+ ctx.drive(inst, toPin, STRENGTH_WEAK, VALUE_HIGH, RESISTOR_DELAY_NS);
+ } else if (level === LEVEL_LOW || level === LEVEL_WEAK_LOW) {
+ ctx.drive(inst, toPin, STRENGTH_WEAK, VALUE_LOW, RESISTOR_DELAY_NS);
+ } else {
+ // High-Z or an unresolved fight: pass nothing rather than pass a guess.
+ ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, RESISTOR_DELAY_NS);
+ }
+}
+
+defineModel({
+ type: 'resistor',
+ pinCount: 2,
+ delayNs: RESISTOR_DELAY_NS,
+ functionalPins: [[1, 2]],
+
+ createState(component) {
+ const ohms = Number(component?.props?.ohms);
+ return { ohms: Number.isFinite(ohms) && ohms > 0 ? ohms : DEFAULT_OHMS };
+ },
+
+ init(ctx, inst) {
+ pass(ctx, inst, 0, 1);
+ pass(ctx, inst, 1, 0);
+ },
+
+ evaluate(ctx, inst, wake) {
+ // A non-pin wake re-passes both directions; see the note in switches.js.
+ const all = wake.reason !== WAKE_PIN;
+ if (all || wake.pin === 0) pass(ctx, inst, 0, 1);
+ if (all || wake.pin === 1) pass(ctx, inst, 1, 0);
+ },
+});
+
+export const RESISTOR_TYPE = 'resistor';
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/supply.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/supply.js
new file mode 100644
index 0000000..c83634e
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/supply.js
@@ -0,0 +1,36 @@
+/**
+ * 5 V power supply. Two pins: pin 1 (v+) onto a plus rail, pin 2 (gnd) onto the
+ * matching minus rail.
+ *
+ * Drives at STRENGTH_SUPPLY rather than STRENGTH_STRONG. A rail is not just a
+ * strong output — it wins against one, and two supply drivers of opposite
+ * polarity meeting on one net is a short circuit rather than ordinary
+ * contention. Keeping supply as its own strength is what lets net-state tell
+ * those two faults apart.
+ */
+
+import { STRENGTH_SUPPLY, VALUE_HIGH, VALUE_LOW } from '../constants.js';
+import { defineModel } from './registry.js';
+
+const PIN_PLUS = 0;
+const PIN_GND = 1;
+
+defineModel({
+ type: 'powerSupply5V',
+ pinCount: 2,
+ delayNs: 0,
+
+ init(ctx, inst) {
+ if (inst.pins[PIN_PLUS] < 0 || inst.pins[PIN_GND] < 0) {
+ ctx.staticWarning('unconnectedSupply', `power supply ${inst.uid} is not attached to a rail pair`, [inst.uid]);
+ return;
+ }
+ ctx.driveNow(inst, PIN_PLUS, STRENGTH_SUPPLY, VALUE_HIGH);
+ ctx.driveNow(inst, PIN_GND, STRENGTH_SUPPLY, VALUE_LOW);
+ },
+
+ // A supply is not sensitive to anything: it holds its rails regardless of
+ // what else lands on them, which is exactly how a short circuit becomes
+ // visible instead of being resolved away.
+ evaluate() {},
+});
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js
new file mode 100644
index 0000000..2dde723
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js
@@ -0,0 +1,129 @@
+/**
+ * Mechanical contacts: push button and 8-position DIP switch.
+ *
+ * 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 { defineModel, WAKE_PIN } from './registry.js';
+import { refreshBridge } from './conduction.js';
+
+const CONTACT_DELAY_NS = 1;
+
+/** 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);
+}
+
+/* ------------------------------------------------------------------ *
+ * Push button: 4 pins. Pins 1+2 are permanently tied, as are 3+4 —
+ * those ties are static merges declared below and applied by the net
+ * builder. Pressing connects group A to group B.
+ * ------------------------------------------------------------------ */
+
+const BUTTON_A = 0; // pin 1, representative of the permanently-tied A group
+const BUTTON_B = 2; // pin 3, representative of the B group
+
+defineModel({
+ type: 'pushButton',
+ pinCount: 4,
+ delayNs: CONTACT_DELAY_NS,
+ ties: [
+ [1, 2],
+ [3, 4],
+ ],
+
+ createState(component) {
+ return { pressed: component?.props?.pressed === true };
+ },
+
+ init(ctx, inst) {
+ 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;
+ setContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed);
+ },
+
+ /** `{ type:"input", uid, value }` — value is a boolean: pressed or released. */
+ applyInput(ctx, inst, value) {
+ const pressed = value === true || value?.pressed === true;
+ if (inst.state.pressed === pressed) return;
+ inst.state.pressed = pressed;
+ setContact(ctx, inst, BUTTON_A, BUTTON_B, pressed);
+ },
+});
+
+/* ------------------------------------------------------------------ *
+ * 8-position DIP switch: 16 pins. Switch k (1..8) bridges package pin
+ * k to package pin 17-k, i.e. straight across the centre gap.
+ * ------------------------------------------------------------------ */
+
+const DIP_SWITCH_COUNT = 8;
+
+/** 0-based pin indexes bridged by switch `k` (1-based). */
+function dipPins(k) {
+ return [k - 1, 16 - k];
+}
+
+defineModel({
+ type: 'dipSwitch8',
+ pinCount: 16,
+ delayNs: CONTACT_DELAY_NS,
+
+ createState(component) {
+ const on = new Uint8Array(DIP_SWITCH_COUNT);
+ const source = component?.props?.on;
+ if (Array.isArray(source)) {
+ for (let i = 0; i < DIP_SWITCH_COUNT; i++) on[i] = source[i] === true ? 1 : 0;
+ }
+ return { on };
+ },
+
+ init(ctx, inst) {
+ for (let k = 1; k <= DIP_SWITCH_COUNT; k++) {
+ const [a, b] = dipPins(k);
+ setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
+ }
+ },
+
+ evaluate(ctx, inst, wake) {
+ // Any non-pin wake (init, a self-scheduled timer, anything added later)
+ // refreshes every contact. Falling through to the pin path with
+ // wake.pin === -1 used to compute k = 17 and drive pin -1, which the
+ // old fail-open guard in drive() then aliased onto an unrelated net —
+ // silently wrong output rather than a crash.
+ if (wake.reason !== WAKE_PIN) {
+ for (let k = 1; k <= DIP_SWITCH_COUNT; k++) {
+ const [a, b] = dipPins(k);
+ setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
+ }
+ return;
+ }
+ // Only the one switch straddling the woken pin can be affected.
+ const pin = wake.pin;
+ if (!(pin >= 0) || pin > 15) return;
+ const k = pin < DIP_SWITCH_COUNT ? pin + 1 : 16 - pin;
+ const [a, b] = dipPins(k);
+ setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
+ },
+
+ /**
+ * `{ type:"input", uid, value:{ pin, on } }` where `pin` is the SWITCH
+ * number 1..8, not the 16-pin package pin number.
+ */
+ applyInput(ctx, inst, value) {
+ const k = Number(value?.pin);
+ if (!Number.isInteger(k) || k < 1 || k > DIP_SWITCH_COUNT) return;
+ const on = value.on === true ? 1 : 0;
+ if (inst.state.on[k - 1] === on) return;
+ inst.state.on[k - 1] = on;
+ const [a, b] = dipPins(k);
+ 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/engine/net-builder.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-builder.js
new file mode 100644
index 0000000..098ff27
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-builder.js
@@ -0,0 +1,110 @@
+/**
+ * Net extraction: circuit document -> electrical nets.
+ *
+ * Pure. Given a circuit and the model registry, returns net ids for every
+ * breadboard strip and every component pin. Runs once per `load`.
+ *
+ * Three sources of connectivity are unioned:
+ * 1. breadboard strips and rails — each is a node, supplied by
+ * shared/board-geometry.js `stripKey` (a column's rows a-e are one strip,
+ * f-j another, each rail is one strip).
+ * 2. wires — union the two endpoints' strips.
+ * 3. component pins — a pin adopts the net of the strip it sits in.
+ * 4. permanent internal ties — pins a component shorts together and never
+ * un-shorts, e.g. the two halves of a push button's A contact. These are
+ * real static merges declared by the model.
+ *
+ * Switchable conduction (a pressed button, a closed DIP switch, a resistor) is
+ * deliberately NOT unioned. Those conduct as bidirectional *drivers* so that an
+ * open switch actually opens and a resistor still attenuates.
+ */
+
+import { stripKey, isValidHole } from '../shared/board-geometry.js';
+import { NO_NET } from './constants.js';
+import { UnionFind } from './union-find.js';
+
+/**
+ * @param {object} circuit circuit document v1
+ * @param {import('./models/registry.js').ModelRegistry} registry
+ * @returns {{ netCount:number, netOfStrip:Map,
+ * pinNets:Map, warnings:Array