} boards
+ * @param {object} colors palette
+ * @param {object} state runtime state: { brightness, burned, pressed }
+ * @param {number} zoom current zoom, used to drop text detail when it would be unreadable
+ */
+export function drawComponent(ctx, component, boards, colors, state, zoom) {
+ const def = getComponentDef(component.type);
+ if (def === null) return;
+ const pins = pinPositions(component, boards);
+
+ if (isChipType(component.type)) {
+ drawChip(ctx, component, pins, colors, state, zoom);
+ return;
+ }
+ switch (component.type) {
+ case 'led': drawLed(ctx, component, pins, colors, state); break;
+ case 'resistor': drawResistor(ctx, component, pins, colors, state, zoom); break;
+ case 'pushButton': drawPushButton(ctx, component, pins, colors, state); break;
+ case 'dipSwitch8': drawDipSwitch(ctx, component, pins, colors, state, zoom); break;
+ case 'powerSupply5V': drawPowerSupply(ctx, component, pins, colors, state, zoom, boards); break;
+ default: break;
+ }
+}
+
+/**
+ * Draw a wire as a shallow arc, so overlapping wires stay distinguishable.
+ * @param {object} [options] levelColor paints a halo showing the net's logic level
+ */
+export function drawWire(ctx, from, to, color, options = {}) {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = Math.hypot(dx, dy);
+ // Perpendicular sag proportional to length, capped so long wires do not balloon.
+ const sag = Math.min(length * 0.14, PITCH * 2.2);
+ const mid = { x: (from.x + to.x) / 2 - (dy / (length || 1)) * sag, y: (from.y + to.y) / 2 + (dx / (length || 1)) * sag };
+
+ const stroke = () => {
+ ctx.beginPath();
+ ctx.moveTo(from.x, from.y);
+ ctx.quadraticCurveTo(mid.x, mid.y, to.x, to.y);
+ ctx.stroke();
+ };
+
+ ctx.lineCap = 'round';
+ if (options.levelColor) {
+ ctx.strokeStyle = options.levelColor;
+ ctx.globalAlpha = 0.5;
+ ctx.lineWidth = PITCH * 0.42;
+ stroke();
+ ctx.globalAlpha = 1;
+ }
+
+ ctx.strokeStyle = options.shadow || 'rgba(0,0,0,0.25)';
+ ctx.lineWidth = PITCH * 0.24;
+ ctx.save();
+ ctx.translate(0, PITCH * 0.06);
+ stroke();
+ ctx.restore();
+
+ ctx.strokeStyle = color;
+ ctx.lineWidth = PITCH * 0.2;
+ stroke();
+
+ // End collars, so a wire visibly plugs into its hole.
+ ctx.fillStyle = color;
+ for (const point of [from, to]) {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, PITCH * 0.15, 0, Math.PI * 2);
+ ctx.fill();
+ }
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/dom.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/dom.js
new file mode 100644
index 0000000..cdaedf9
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/dom.js
@@ -0,0 +1,116 @@
+// Small DOM helpers for the breadboard editor.
+//
+// Everything user-provided reaches the page through textContent, never innerHTML -
+// project names and server error strings are both untrusted as far as this file is
+// concerned.
+
+/**
+ * Create an element.
+ * @param {string} tag
+ * @param {object} [options] className, id, title, type, text, attrs, dataset, children
+ * @returns {HTMLElement}
+ */
+export function el(tag, options = {}) {
+ const node = document.createElement(tag);
+ if (options.className) node.className = options.className;
+ if (options.id) node.id = options.id;
+ if (options.title) node.title = options.title;
+ if (options.type) node.type = options.type;
+ if (options.text !== undefined) node.textContent = String(options.text);
+ if (options.attrs) {
+ for (const [key, value] of Object.entries(options.attrs)) {
+ if (value !== null && value !== undefined) node.setAttribute(key, String(value));
+ }
+ }
+ if (options.dataset) {
+ for (const [key, value] of Object.entries(options.dataset)) node.dataset[key] = String(value);
+ }
+ for (const child of options.children || []) {
+ if (child) node.appendChild(child);
+ }
+ return node;
+}
+
+/** Create a button that never submits a form. */
+export function button(text, options = {}) {
+ return el('button', Object.assign({ type: 'button', text }, options));
+}
+
+/** Replace an element's contents with plain text. Safe for untrusted strings. */
+export function setText(node, text) {
+ node.textContent = text === null || text === undefined ? '' : String(text);
+}
+
+/** Remove every child of an element. */
+export function clear(node) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+}
+
+/**
+ * Collects event listeners so they can all be removed in one call. Every listener in
+ * the editor goes through this - untracked listeners leak when the page is torn down.
+ */
+export function createListenerBag() {
+ const entries = [];
+ return {
+ /** @returns {Function} a function that removes just this listener */
+ on(target, type, handler, options) {
+ target.addEventListener(type, handler, options);
+ const entry = { target, type, handler, options };
+ entries.push(entry);
+ return () => {
+ target.removeEventListener(type, handler, options);
+ const i = entries.indexOf(entry);
+ if (i !== -1) entries.splice(i, 1);
+ };
+ },
+ removeAll() {
+ for (const e of entries) e.target.removeEventListener(e.type, e.handler, e.options);
+ entries.length = 0;
+ }
+ };
+}
+
+/**
+ * Trailing-edge debounce. `cancel()` drops a pending call, `flush()` runs it now.
+ */
+export function debounce(fn, delay) {
+ let timer = null;
+ let pendingArgs = null;
+ const wrapped = (...args) => {
+ pendingArgs = args;
+ if (timer !== null) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ const a = pendingArgs;
+ pendingArgs = null;
+ fn(...a);
+ }, delay);
+ };
+ wrapped.cancel = () => {
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ pendingArgs = null;
+ };
+ wrapped.flush = () => {
+ if (timer === null) return;
+ clearTimeout(timer);
+ timer = null;
+ const a = pendingArgs;
+ pendingArgs = null;
+ fn(...a);
+ };
+ return wrapped;
+}
+
+/** Format a resistance for display: 220 -> "220 Ω", 4700 -> "4.7 kΩ". */
+export function formatOhms(ohms) {
+ if (!Number.isFinite(ohms)) return '—';
+ if (ohms >= 1000000) return `${trimZeros(ohms / 1000000)} MΩ`;
+ if (ohms >= 1000) return `${trimZeros(ohms / 1000)} kΩ`;
+ return `${trimZeros(ohms)} Ω`;
+}
+
+function trimZeros(value) {
+ return String(Number(value.toFixed(2)));
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/editor-state.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/editor-state.js
new file mode 100644
index 0000000..5ed7a22
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/editor-state.js
@@ -0,0 +1,380 @@
+// Editor document state: the circuit, the selection, and what has changed.
+//
+// DIRTY TRACKING (amendment A9). The circuit is stored in PostgreSQL as jsonb, which
+// normalizes object key order and whitespace, so the bytes we send are never the bytes
+// that come back. Comparing serialized strings across a save therefore reports a
+// document as dirty the moment it is reloaded - and with an autosave that is a loop
+// that never settles. So:
+// - the baseline is a deep clone of exactly what we last SENT (never a re-GET),
+// - comparison is structural, over a canonical form with sorted keys,
+// - and no re-GET happens after a save, since PUT returns 204 with no body.
+//
+// UID ALLOCATION. One long-lived allocator lives here, seeded from the loaded
+// document. Allocation is editor session state, not a property of the document, so
+// nothing in this module calls shared/'s nextUid.
+
+import {
+ createUidAllocator,
+ createWire,
+ addBoard as schemaAddBoard,
+ removeBoard as schemaRemoveBoard,
+ boardsByUid,
+ allUids,
+ validateCircuit,
+ MAX_COMPONENTS,
+ MAX_WIRES,
+ MAX_BOARDS,
+ DEFAULT_WIRE_COLOR,
+ orientFor
+} from '../shared/circuit-schema.js';
+
+import { getComponentDef, dipRowForOrient, defaultPropsFor } from '../shared/component-registry.js';
+import { isFullyPlaced, componentSelfShorts } from '../shared/component-pins.js';
+import { holeKey, sameHole } from '../shared/board-geometry.js';
+
+/** Uid carried by the placement preview; never added to the circuit. */
+const GHOST_UID = '__ghost__';
+
+/** Stable stringification: object keys sorted, so key order never affects equality. */
+function canonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
+ const keys = Object.keys(value).sort();
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
+}
+
+export function createEditorState(initialCircuit) {
+ let circuit = initialCircuit;
+ let baseline = canonical(initialCircuit);
+ let allocator = createUidAllocator(allUids(initialCircuit));
+ let boards = boardsByUid(circuit);
+
+ const selection = new Set();
+ const listeners = new Set();
+
+ /** Components the user is physically holding down right now. Never persisted. */
+ const pressed = new Set();
+
+ function notify(change) {
+ for (const listener of listeners) listener(change);
+ }
+
+ function structureChanged() {
+ boards = boardsByUid(circuit);
+ notify({ kind: 'circuit' });
+ }
+
+ return {
+ get circuit() { return circuit; },
+ get boards() { return boards; },
+ get selection() { return selection; },
+ get pressed() { return pressed; },
+ get dirty() { return canonical(circuit) !== baseline; },
+
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+
+ /**
+ * Mark the document saved. Pass the EXACT payload that was sent - the baseline
+ * must be what we sent, never a re-read of the server's copy.
+ */
+ markSaved(sentPayload) {
+ baseline = canonical(sentPayload);
+ notify({ kind: 'saved' });
+ },
+
+ /** Replace the whole document, e.g. after a load. */
+ replace(nextCircuit) {
+ circuit = nextCircuit;
+ baseline = canonical(nextCircuit);
+ allocator = createUidAllocator(allUids(nextCircuit));
+ selection.clear();
+ pressed.clear();
+ structureChanged();
+ },
+
+ // --- Selection ---
+
+ select(uid, additive = false) {
+ if (!additive) selection.clear();
+ if (uid !== null && uid !== undefined) selection.add(uid);
+ notify({ kind: 'selection' });
+ },
+
+ toggleSelect(uid) {
+ if (selection.has(uid)) selection.delete(uid);
+ else selection.add(uid);
+ notify({ kind: 'selection' });
+ },
+
+ clearSelection() {
+ if (selection.size === 0) return;
+ selection.clear();
+ notify({ kind: 'selection' });
+ },
+
+ findSelectedComponent() {
+ if (selection.size !== 1) return null;
+ const uid = [...selection][0];
+ return circuit.components.find(c => c.uid === uid) || null;
+ },
+
+ // --- Components ---
+
+ /**
+ * Build a component of the given type at an anchor, without adding it. Used for
+ * the placement ghost so the preview is the real thing.
+ */
+ buildComponent(type, anchor, extraProps) {
+ const def = getComponentDef(type);
+ if (def === null) return null;
+ // Deliberately does NOT allocate a uid. The placement ghost is rebuilt on
+ // every pointer move, and uid allocation is O(document) - doing it here
+ // would scan the whole circuit once per mousemove.
+ const component = {
+ uid: GHOST_UID,
+ type,
+ props: defaultPropsFor(type)
+ };
+ if (extraProps) Object.assign(component.props, extraProps);
+ if (!def.anchorless) component.anchor = anchor;
+ if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
+ return component;
+ },
+
+ /**
+ * Add a component. Returns { ok, component, reason, warnings }.
+ * `warnings` is advisory - a self-shorted part is legal but useless.
+ */
+ addComponent(type, anchor, extraProps) {
+ if (circuit.components.length >= MAX_COMPONENTS) {
+ return { ok: false, reason: `A circuit can hold at most ${MAX_COMPONENTS} components.` };
+ }
+ const def = getComponentDef(type);
+ if (def === null) return { ok: false, reason: `Unknown component type "${type}".` };
+
+ const component = {
+ uid: allocator.next('c'),
+ type,
+ props: defaultPropsFor(type) // deep-copies array defaults
+ };
+ if (extraProps) Object.assign(component.props, extraProps);
+ if (!def.anchorless) component.anchor = anchor;
+ if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
+
+ if (!isFullyPlaced(component)) {
+ return { ok: false, reason: `A ${def.label} does not fit there — part of it would hang off the board.` };
+ }
+
+ circuit.components.push(component);
+ const check = validateCircuit(circuit);
+ if (!check.ok) {
+ circuit.components.pop();
+ return { ok: false, reason: check.errors[0] };
+ }
+
+ const warnings = [];
+ if (componentSelfShorts(component).length > 0) {
+ warnings.push(`This ${def.label} has both ends in the same connected strip, so it will have no effect.`);
+ }
+ structureChanged();
+ return { ok: true, component, warnings };
+ },
+
+ /** Move a component to a new anchor. Returns { ok, reason }. */
+ moveComponent(uid, anchor) {
+ const component = circuit.components.find(c => c.uid === uid);
+ if (!component) return { ok: false, reason: 'That component no longer exists.' };
+ const def = getComponentDef(component.type);
+ if (def.anchorless) return { ok: false, reason: `A ${def.label} is moved by changing its rail, not by dragging.` };
+
+ const previousAnchor = component.anchor;
+ const previousOrient = component.orient;
+ component.anchor = anchor;
+ if (def.orientable) component.orient = orientFor(def, anchor, component.orient);
+
+ if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
+ component.anchor = previousAnchor;
+ component.orient = previousOrient;
+ return { ok: false, reason: `A ${def.label} does not fit there.` };
+ }
+ structureChanged();
+ return { ok: true };
+ },
+
+ /**
+ * Rotate a component. For DIP-style packages this flips the anchor across the
+ * center channel, which IS the rotation; for an LED it cycles the orient.
+ */
+ rotateComponent(uid) {
+ const component = circuit.components.find(c => c.uid === uid);
+ if (!component) return { ok: false, reason: 'That component no longer exists.' };
+ const def = getComponentDef(component.type);
+ if (!def.orientable) return { ok: false, reason: `A ${def.label} cannot be rotated.` };
+
+ const previousAnchor = component.anchor;
+ const previousOrient = component.orient;
+
+ if (def.dipStyle) {
+ const nextOrient = component.orient === 'right' ? 'left' : 'right';
+ component.anchor = Object.assign({}, component.anchor, { row: dipRowForOrient(nextOrient) });
+ component.orient = nextOrient;
+ } else {
+ const values = def.orientValues;
+ const index = values.indexOf(component.orient);
+ component.orient = values[(index + 1) % values.length];
+ }
+
+ if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
+ component.anchor = previousAnchor;
+ component.orient = previousOrient;
+ return { ok: false, reason: `A ${def.label} does not fit in that orientation here.` };
+ }
+ structureChanged();
+ return { ok: true };
+ },
+
+ /** Update a component's props. Reverts and explains if the result is invalid. */
+ setComponentProps(uid, changes) {
+ const component = circuit.components.find(c => c.uid === uid);
+ if (!component) return { ok: false, reason: 'That component no longer exists.' };
+ const previous = Object.assign({}, component.props);
+ Object.assign(component.props, changes);
+
+ const check = validateCircuit(circuit);
+ if (!check.ok || !isFullyPlaced(component)) {
+ component.props = previous;
+ return { ok: false, reason: check.ok ? 'That change would move a pin off the board.' : check.errors[0] };
+ }
+ structureChanged();
+ return { ok: true };
+ },
+
+ /** Toggle one switch of a DIP package. */
+ toggleSwitch(uid, switchNumber) {
+ const component = circuit.components.find(c => c.uid === uid);
+ if (!component || component.type !== 'dipSwitch8') return null;
+ const on = Array.isArray(component.props.on) ? component.props.on.slice() : new Array(8).fill(false);
+ const index = switchNumber - 1;
+ if (index < 0 || index >= on.length) return null;
+ on[index] = !on[index];
+ component.props.on = on;
+ structureChanged();
+ return on[index];
+ },
+
+ setPressed(uid, isPressed) {
+ if (isPressed) pressed.add(uid);
+ else pressed.delete(uid);
+ notify({ kind: 'runtime' });
+ },
+
+ // --- Wires ---
+
+ /** Add a wire. Returns { ok, wire, reason }. */
+ addWire(from, to, color) {
+ if (circuit.wires.length >= MAX_WIRES) {
+ return { ok: false, reason: `A circuit can hold at most ${MAX_WIRES} wires.` };
+ }
+ if (sameHole(from, to)) {
+ return { ok: false, reason: 'A wire needs two different holes.' };
+ }
+ const duplicate = circuit.wires.some(w =>
+ (sameHole(w.from, from) && sameHole(w.to, to)) || (sameHole(w.from, to) && sameHole(w.to, from)));
+ if (duplicate) return { ok: false, reason: 'Those holes are already joined by a wire.' };
+
+ const wire = {
+ uid: allocator.next('w'),
+ from,
+ to,
+ color: color || DEFAULT_WIRE_COLOR
+ };
+ circuit.wires.push(wire);
+ const check = validateCircuit(circuit);
+ if (!check.ok) {
+ circuit.wires.pop();
+ return { ok: false, reason: check.errors[0] };
+ }
+ structureChanged();
+ return { ok: true, wire };
+ },
+
+ setWireColor(uid, color) {
+ const wire = circuit.wires.find(w => w.uid === uid);
+ if (!wire) return false;
+ wire.color = color;
+ structureChanged();
+ return true;
+ },
+
+ // --- Deletion ---
+
+ /** Delete everything selected. Returns the number of items removed. */
+ deleteSelected() {
+ if (selection.size === 0) return 0;
+ const before = circuit.components.length + circuit.wires.length;
+ circuit.components = circuit.components.filter(c => !selection.has(c.uid));
+ circuit.wires = circuit.wires.filter(w => !selection.has(w.uid));
+ const removed = before - (circuit.components.length + circuit.wires.length);
+ if (removed > 0) {
+ selection.clear();
+ structureChanged();
+ }
+ return removed;
+ },
+
+ // --- Boards ---
+
+ addBoard() {
+ if (circuit.boards.length >= MAX_BOARDS) {
+ return { ok: false, reason: `A circuit can hold at most ${MAX_BOARDS} boards.` };
+ }
+ const board = schemaAddBoard(circuit);
+ if (board === null) return { ok: false, reason: 'Could not add another board.' };
+ allocator.claim(board.uid, 'b');
+ structureChanged();
+ return { ok: true, board };
+ },
+
+ /** Remove a board and everything on it. Returns { ok, reason, removed }. */
+ removeBoard(uid) {
+ if (circuit.boards.length <= 1) {
+ return { ok: false, reason: 'A circuit needs at least one board.' };
+ }
+ const before = circuit.components.length + circuit.wires.length;
+ if (!schemaRemoveBoard(circuit, uid)) {
+ return { ok: false, reason: 'That board no longer exists.' };
+ }
+ const removed = before - (circuit.components.length + circuit.wires.length);
+ selection.clear();
+ structureChanged();
+ return { ok: true, removed };
+ },
+
+ moveBoard(uid, x, y) {
+ const board = circuit.boards.find(b => b.uid === uid);
+ if (!board) return false;
+ board.x = x;
+ board.y = y;
+ structureChanged();
+ return true;
+ },
+
+ /** Component or wire whose uid matches, or null. */
+ findByUid(uid) {
+ return circuit.components.find(c => c.uid === uid)
+ || circuit.wires.find(w => w.uid === uid)
+ || null;
+ },
+
+ /** Wire whose either end is at the given hole, or null. */
+ wireAtHole(hole) {
+ const key = holeKey(hole);
+ return circuit.wires.find(w => holeKey(w.from) === key || holeKey(w.to) === key) || null;
+ }
+ };
+}
+
+export { canonical };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js
new file mode 100644
index 0000000..1a8719e
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js
@@ -0,0 +1,440 @@
+// Breadboard editor entry point.
+//
+// Boots into , 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, statusHost] });
+ root.appendChild(shell);
+
+ 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;
+ 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();
+ 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();
+ // 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();
+ }
+ });
+
+ function pushSimScene() {
+ renderer.setScene({
+ netLevels: sim.state.netLevels,
+ netOfStrip: sim.state.netOfStrip,
+ ledBrightness: sim.state.ledBrightness,
+ burned: sim.state.burned,
+ warnedUids,
+ simActive: sim.state.loaded
+ }, 'dynamic', 'overlay');
+ }
+
+ 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();
+ },
+ 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: () => properties.render(state)
+ });
+
+ 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();
+ }
+
+ bag.on(window, 'resize', () => renderer.resize());
+ const resizeObserver = typeof ResizeObserver === 'function'
+ ? new ResizeObserver(() => renderer.resize())
+ : 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');
+ properties.render(state);
+ 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');
+ properties.render(state);
+ 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();
+ 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..5665946
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/palette.js
@@ -0,0 +1,126 @@
+// 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', 'output', 'input', 'power', 'chip']);
+const CATEGORY_LABELS = Object.freeze(Object.assign(Object.create(null), {
+ passive: 'Passive',
+ 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..2fda55b
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js
@@ -0,0 +1,412 @@
+// 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
+
+ let cssWidth = 0;
+ let cssHeight = 0;
+ let dpr = 1;
+ 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
+ };
+
+ function syncCanvasSize() {
+ 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;
+ for (const name of LAYER_NAMES) {
+ const canvas = canvases[name];
+ canvas.width = Math.round(width * dpr);
+ canvas.height = Math.round(height * dpr);
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ }
+ invalidateAll();
+ return 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;
+ 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 (syncCanvasSize()) schedule();
+ },
+
+ 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..2be70dc
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/status.js
@@ -0,0 +1,163 @@
+// 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',
+ 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'
+}));
+
+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..fa4e547
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/theme-colors.js
@@ -0,0 +1,138 @@
+// 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'],
+ 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..1dc878f
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js
@@ -0,0 +1,122 @@
+// 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' });
+
+ 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 }) {
+ 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..3b0471a
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js
@@ -0,0 +1,511 @@
+// 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;
+
+ function screenPoint(event) {
+ const rect = canvas.getBoundingClientRect();
+ 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 = canvas.getBoundingClientRect();
+ 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';
+ }
+ }
+
+ 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,
+ 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/index.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js
new file mode 100644
index 0000000..00b1240
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/index.js
@@ -0,0 +1,15 @@
+/**
+ * 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';
+
+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..193c1c3
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/models/switches.js
@@ -0,0 +1,157 @@
+/**
+ * Mechanical contacts: push button and 8-position DIP switch.
+ *
+ * A closed contact is modelled as a bidirectional conductor rather than a
+ * dynamic merge of the two nets. Re-running union-find every time a user
+ * presses a button would be both slow and a nightmare for the UI's net index,
+ * which is handed out once at load and must stay valid.
+ *
+ * Conduction preserves STRENGTH, unlike a resistor: a closed button between the
+ * two power rails has to hand SUPPLY strength across so the short reads as a
+ * short circuit and not as garden-variety contention.
+ *
+ * The 1 ns contact delay is a loop breaker, not a debounce model. Two contacts
+ * wired in a ring would otherwise conduct round it at zero delay forever.
+ */
+
+import { STRENGTH_HIGHZ, VALUE_LOW } from '../constants.js';
+import { driveStrength, driveMask, MASK_LOW, MASK_HIGH } from '../drive.js';
+import { defineModel, WAKE_PIN } from './registry.js';
+
+const CONTACT_DELAY_NS = 1;
+
+/**
+ * Passes `fromPin`'s net through to `toPin` at full strength, or opens the
+ * contact. Reads the source net excluding this element's own contribution, so
+ * the contact cannot latch onto the value it is itself asserting.
+ */
+function conduct(ctx, inst, fromPin, toPin, closed) {
+ if (!closed) {
+ ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, CONTACT_DELAY_NS);
+ return;
+ }
+ const drive = ctx.driveExcludingSelf(inst, fromPin);
+ const strength = driveStrength(drive);
+ const mask = driveMask(drive);
+ // A source that is itself unresolved (both polarities present) passes
+ // nothing: there is no single value to conduct.
+ if (strength === STRENGTH_HIGHZ || (mask !== MASK_LOW && mask !== MASK_HIGH)) {
+ ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, CONTACT_DELAY_NS);
+ return;
+ }
+ ctx.drive(inst, toPin, strength, mask === MASK_HIGH ? 1 : 0, CONTACT_DELAY_NS);
+}
+
+function refreshContact(ctx, inst, a, b, closed) {
+ conduct(ctx, inst, a, b, closed);
+ conduct(ctx, inst, b, a, closed);
+}
+
+/* ------------------------------------------------------------------ *
+ * 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) {
+ refreshContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed);
+ },
+
+ evaluate(ctx, inst, wake) {
+ if (wake.reason === WAKE_PIN && wake.pin !== BUTTON_A && wake.pin !== BUTTON_B) return;
+ refreshContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed);
+ },
+
+ /** `{ 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;
+ refreshContact(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);
+ refreshContact(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);
+ refreshContact(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);
+ refreshContact(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);
+ refreshContact(ctx, inst, a, b, on === 1);
+ },
+});
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 }}
+ */
+export function buildNets(circuit, registry) {
+ const warnings = [];
+ const uf = new UnionFind(512);
+
+ const boards = Array.isArray(circuit?.boards) ? circuit.boards : [];
+ const components = Array.isArray(circuit?.components) ? circuit.components : [];
+ const wires = Array.isArray(circuit?.wires) ? circuit.wires : [];
+ const boardUids = new Set(boards.map((b) => b?.uid));
+
+ const keyOf = (hole, context) => {
+ if (!hole || !isValidHole(hole)) {
+ warnings.push({ kind: 'invalidHole', detail: `${context}: hole reference is not a valid position`, hole });
+ return null;
+ }
+ if (!boardUids.has(hole.board)) {
+ warnings.push({ kind: 'invalidHole', detail: `${context}: references unknown board "${hole.board}"`, hole });
+ return null;
+ }
+ return stripKey(hole);
+ };
+
+ // 1. Strips only become nodes when something references them. An untouched
+ // breadboard would otherwise contribute ~700 empty nets per board.
+ // 2. Wires.
+ for (const wire of wires) {
+ const from = keyOf(wire?.from, `wire ${wire?.uid}`);
+ const to = keyOf(wire?.to, `wire ${wire?.uid}`);
+ if (from === null || to === null) continue;
+ uf.unionKeys(from, to);
+ }
+
+ // 3. Component pins. Interning happens before finish() so every referenced
+ // strip gets a net even if no wire touches it.
+ /** @type {Map>} */
+ const pinKeys = new Map();
+ for (const component of components) {
+ const uid = component?.uid;
+ if (typeof uid !== 'string') continue;
+ if (pinKeys.has(uid)) {
+ warnings.push({ kind: 'duplicateUid', detail: `duplicate component uid "${uid}"`, uids: [uid] });
+ continue;
+ }
+ const holes = registry.pinHoles(component);
+ const keys = new Array(holes.length);
+ for (let i = 0; i < holes.length; i++) {
+ const hole = holes[i]?.hole ?? null;
+ if (hole === null) {
+ keys[i] = null;
+ continue;
+ }
+ const key = keyOf(hole, `${component.type} ${uid} pin ${i + 1}`);
+ keys[i] = key;
+ if (key !== null) uf.intern(key);
+ }
+ pinKeys.set(uid, keys);
+
+ // 4. Permanent internal ties (e.g. a push button's two A-side pins).
+ for (const [a, b] of registry.internalTies(component)) {
+ const ka = keys[a];
+ const kb = keys[b];
+ if (ka !== null && ka !== undefined && kb !== null && kb !== undefined) uf.unionKeys(ka, kb);
+ }
+ }
+
+ const { netCount, netOfKey } = uf.finish();
+
+ /** @type {Map} */
+ const pinNets = new Map();
+ for (const [uid, keys] of pinKeys) {
+ const nets = new Int32Array(keys.length);
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ nets[i] = key === null ? NO_NET : netOfKey.get(key);
+ }
+ pinNets.set(uid, nets);
+ }
+
+ return { netCount, netOfStrip: netOfKey, pinNets, warnings };
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-state.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-state.js
new file mode 100644
index 0000000..33dc2da
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/net-state.js
@@ -0,0 +1,221 @@
+/**
+ * Net state: the driver table and per-net signal resolution.
+ *
+ * MUTABLE HOT PATH. Everything here is typed arrays updated in place. Callers
+ * must go through the methods; nothing outside this file may touch the buffers.
+ *
+ * Resolution is O(1) per driver change, never a sweep. Instead of re-folding a
+ * net's driver list, each net carries six counters — one per (strength, value)
+ * pair for the three real strengths — and a driver change decrements one
+ * counter and increments another. Reading the winner is then a fixed sequence
+ * of integer tests. This is exactly equivalent to the pure fold in drive.js
+ * (`foldDrives`): the counters ARE the value-mask union, tallied. drive.spec
+ * cross-checks the two implementations against each other over random
+ * permutations.
+ */
+
+import {
+ LEVEL_LOW,
+ LEVEL_HIGH,
+ LEVEL_HIGHZ,
+ LEVEL_WEAK_LOW,
+ LEVEL_WEAK_HIGH,
+ LEVEL_CONTENTION,
+ STRENGTH_HIGHZ,
+ STRENGTH_WEAK,
+ STRENGTH_STRONG,
+ STRENGTH_SUPPLY,
+} from './constants.js';
+import {
+ FAULT_NONE,
+ FAULT_CONTENTION,
+ FAULT_SHORT_CIRCUIT,
+ DRIVE_HIGHZ,
+ MASK_LOW,
+ MASK_HIGH,
+ driveToLevel,
+} from './drive.js';
+
+const COUNTERS_PER_NET = 6;
+
+export class NetState {
+ /**
+ * @param {number} netCount
+ * @param {number} driverCapacity initial driver-table size; grows by doubling
+ */
+ constructor(netCount, driverCapacity = 64) {
+ this.netCount = netCount;
+ /** Counters: net*6 + (strength-1)*2 + value. */
+ this.counts = new Int32Array(netCount * COUNTERS_PER_NET);
+ /** Wire-format level code per net. Handed to the UI verbatim. */
+ this.levels = new Uint8Array(netCount).fill(LEVEL_HIGHZ);
+ /** Fault classification per net (FAULT_* from drive.js). */
+ this.faults = new Uint8Array(netCount);
+
+ this.driverCount = 0;
+ this.driverNet = new Int32Array(driverCapacity);
+ this.driverStrength = new Uint8Array(driverCapacity);
+ this.driverValue = new Uint8Array(driverCapacity);
+ }
+
+ /** Registers a new driver on `netId`, initially high-Z. Returns its id. */
+ addDriver(netId) {
+ const id = this.driverCount++;
+ if (id >= this.driverNet.length) this.#growDrivers();
+ this.driverNet[id] = netId;
+ this.driverStrength[id] = STRENGTH_HIGHZ;
+ this.driverValue[id] = 0;
+ return id;
+ }
+
+ #growDrivers() {
+ const capacity = this.driverNet.length * 2;
+ const net = new Int32Array(capacity);
+ net.set(this.driverNet);
+ const strength = new Uint8Array(capacity);
+ strength.set(this.driverStrength);
+ const value = new Uint8Array(capacity);
+ value.set(this.driverValue);
+ this.driverNet = net;
+ this.driverStrength = strength;
+ this.driverValue = value;
+ }
+
+ /**
+ * Applies a driver change.
+ * @returns {boolean} true when the net's resolved level actually moved —
+ * the scheduler only propagates on a real change, so redundant drives
+ * cost two counter updates and stop there.
+ */
+ setDriver(driverId, strength, value) {
+ const oldStrength = this.driverStrength[driverId];
+ const oldValue = this.driverValue[driverId];
+ if (oldStrength === strength && (strength === STRENGTH_HIGHZ || oldValue === value)) return false;
+
+ const netId = this.driverNet[driverId];
+ if (netId < 0) {
+ this.driverStrength[driverId] = strength;
+ this.driverValue[driverId] = value;
+ return false;
+ }
+
+ const base = netId * COUNTERS_PER_NET;
+ const counts = this.counts;
+ if (oldStrength !== STRENGTH_HIGHZ) counts[base + (oldStrength - 1) * 2 + oldValue]--;
+ if (strength !== STRENGTH_HIGHZ) counts[base + (strength - 1) * 2 + value]++;
+ this.driverStrength[driverId] = strength;
+ this.driverValue[driverId] = value;
+
+ return this.refresh(netId);
+ }
+
+ /** Recomputes one net's level and fault. Returns true if the level changed. */
+ refresh(netId) {
+ const base = netId * COUNTERS_PER_NET;
+ const counts = this.counts;
+
+ const supplyLow = counts[base + 4] !== 0;
+ const supplyHigh = counts[base + 5] !== 0;
+ const strongLow = counts[base + 2] !== 0;
+ const strongHigh = counts[base + 3] !== 0;
+
+ let level;
+ let fault = FAULT_NONE;
+
+ if (supplyLow || supplyHigh) {
+ if (supplyLow && supplyHigh) {
+ // Rail+ tied to rail-.
+ level = LEVEL_CONTENTION;
+ fault = FAULT_SHORT_CIRCUIT;
+ } else if (supplyHigh) {
+ level = LEVEL_HIGH;
+ // A chip output pulling against the rail is still a fight.
+ if (strongLow) fault = FAULT_CONTENTION;
+ } else {
+ level = LEVEL_LOW;
+ if (strongHigh) fault = FAULT_CONTENTION;
+ }
+ } else if (strongLow || strongHigh) {
+ if (strongLow && strongHigh) {
+ level = LEVEL_CONTENTION;
+ fault = FAULT_CONTENTION;
+ } else {
+ level = strongHigh ? LEVEL_HIGH : LEVEL_LOW;
+ }
+ } else {
+ const weakLow = counts[base] !== 0;
+ const weakHigh = counts[base + 1] !== 0;
+ if (weakLow && weakHigh) {
+ // Pull-up versus pull-down. Indeterminate as a logic level, but a
+ // resistor divider is not a fault, so no warning is raised.
+ level = LEVEL_CONTENTION;
+ } else if (weakHigh) {
+ level = LEVEL_WEAK_HIGH;
+ } else if (weakLow) {
+ level = LEVEL_WEAK_LOW;
+ } else {
+ level = LEVEL_HIGHZ;
+ }
+ }
+
+ this.faults[netId] = fault;
+ if (this.levels[netId] === level) return false;
+ this.levels[netId] = level;
+ return true;
+ }
+
+ levelOf(netId) {
+ return netId < 0 ? LEVEL_HIGHZ : this.levels[netId];
+ }
+
+ faultOf(netId) {
+ return netId < 0 ? FAULT_NONE : this.faults[netId];
+ }
+
+ /**
+ * Resolves a net as it would be WITHOUT one particular driver's
+ * contribution, returning a packed drive (see drive.js) so the caller keeps
+ * the STRENGTH, not just the level.
+ *
+ * Bidirectional elements need this. A closed switch contact must pass what
+ * the other side sees, and if it counted its own output it would latch onto
+ * its own value forever. Strength has to survive the trip too: a button
+ * bridging the two rails has to pass SUPPLY strength through, or the far
+ * rail sees a merely-strong driver and the short reads as ordinary
+ * contention instead of a short circuit.
+ *
+ * Temporarily decrements the driver's own counter rather than copying the
+ * net — hot path, and the mutation is restored before returning.
+ */
+ driveExcluding(netId, driverId) {
+ if (netId < 0) return DRIVE_HIGHZ;
+ const strength = this.driverStrength[driverId];
+ const base = netId * COUNTERS_PER_NET;
+ if (strength === STRENGTH_HIGHZ) return this.#peek(base);
+
+ const counts = this.counts;
+ const slot = base + (strength - 1) * 2 + this.driverValue[driverId];
+ counts[slot]--;
+ const drive = this.#peek(base);
+ counts[slot]++;
+ return drive;
+ }
+
+ levelExcluding(netId, driverId) {
+ return driveToLevel(this.driveExcluding(netId, driverId));
+ }
+
+ /** Folded drive on a net, as a packed (strength, valueMask) pair. */
+ #peek(base) {
+ const counts = this.counts;
+ let mask = (counts[base + 4] !== 0 ? MASK_LOW : 0) | (counts[base + 5] !== 0 ? MASK_HIGH : 0);
+ if (mask !== 0) return STRENGTH_SUPPLY * 4 + mask;
+ mask = (counts[base + 2] !== 0 ? MASK_LOW : 0) | (counts[base + 3] !== 0 ? MASK_HIGH : 0);
+ if (mask !== 0) return STRENGTH_STRONG * 4 + mask;
+ mask = (counts[base] !== 0 ? MASK_LOW : 0) | (counts[base + 1] !== 0 ? MASK_HIGH : 0);
+ if (mask !== 0) return STRENGTH_WEAK * 4 + mask;
+ return DRIVE_HIGHZ;
+ }
+}
+
+export { FAULT_NONE, FAULT_CONTENTION, FAULT_SHORT_CIRCUIT };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/simulation.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/simulation.js
new file mode 100644
index 0000000..4251727
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/simulation.js
@@ -0,0 +1,661 @@
+/**
+ * Event-driven simulation core.
+ *
+ * MUTABLE HOT PATH. Owns the event queue, the driver table and the device
+ * instances. Models talk to it through the context methods on this class; they
+ * never reach into its buffers.
+ *
+ * Scheduling model
+ * ----------------
+ * Events carry (timeNs, seq) so equal-time events fire in insertion order and
+ * the whole run is reproducible whatever order components appear in the
+ * document. Applying a driver change resolves its net in O(1) and, only if the
+ * resolved LEVEL actually moved, wakes the devices listening on that net. Waking
+ * a device is a direct call, not another queue entry — the queue holds delayed
+ * *effects*, not intentions, which halves event volume.
+ *
+ * Delay is INERTIAL, not transport. If a gate's input moves again before its
+ * pending output event fires, the pending event is cancelled rather than
+ * queued behind it, so a pulse narrower than the gate's tPD is swallowed the
+ * way a real gate swallows it. Cancellation is O(1): every driver carries a
+ * generation counter, scheduling bumps it, and an event whose generation no
+ * longer matches is dropped when popped. The heap is never scanned.
+ *
+ * Two independent limits, which must not be confused
+ * --------------------------------------------------
+ * 1. The delta-cycle guard counts events resolved at ONE UNCHANGED simTime.
+ * Exceeding it means a combinational loop with no delay, so time can never
+ * advance — a genuine fault, reported as an "oscillation" warning, and the
+ * sim pauses.
+ * 2. The per-batch budget in `runEvents` is cooperative yielding, nothing more.
+ * It exists so the worker returns to its message loop and stays responsive.
+ * A 74HC04 ring oscillator hits it constantly and that is entirely healthy:
+ * simTime advances on every event, so the delta guard never sees it.
+ */
+
+import { LEVEL_HIGHZ, MAX_EVENTS_PER_INSTANT, NO_NET, STRENGTH_HIGHZ } from './constants.js';
+import { DRIVE_HIGHZ, FAULT_NONE, FAULT_CONTENTION, FAULT_SHORT_CIRCUIT } from './drive.js';
+import { EventQueue, EVENT_DRIVE, EVENT_TIMER } from './event-queue.js';
+import { NetState } from './net-state.js';
+import { buildNets } from './net-builder.js';
+import { registry as defaultRegistry, WAKE_INIT, WAKE_PIN, WAKE_TIMER } from './models/index.js';
+import { RESISTOR_TYPE } from './models/resistor.js';
+import { LED_TYPE } from './models/led.js';
+
+/** Packs (strength, value) into the small code carried by a queued event. */
+function packCode(strength, value) {
+ return strength === STRENGTH_HIGHZ ? 0 : strength * 2 + (value ? 1 : 0);
+}
+
+const MAX_OSCILLATION_CULPRITS = 12;
+/** Cap on self-short reports per document, so a pathological circuit cannot flood. */
+const MAX_SELF_SHORT_WARNINGS = 20;
+/** Ring buffer of the most recent drivers applied at one instant, for diagnostics. */
+const DELTA_RING_SIZE = 32;
+const DELTA_RING_MASK = DELTA_RING_SIZE - 1;
+
+export class Simulation {
+ /**
+ * @param {object} circuit circuit document v1
+ * @param {object} [options]
+ * @param {object} [options.registry] model registry (tests may substitute)
+ * @param {Map} [options.delayOverridesNs] per-type delay override,
+ * used by tests to force zero-delay loops
+ */
+ constructor(circuit, options = {}) {
+ this.registry = options.registry ?? defaultRegistry;
+ this.delayOverridesNs = options.delayOverridesNs ?? null;
+
+ const extraction = buildNets(circuit, this.registry);
+ this.netCount = extraction.netCount;
+ this.netOfStrip = extraction.netOfStrip;
+ this.warnings = extraction.warnings.slice();
+
+ this.queue = new EventQueue(1024);
+ this.timeNs = 0;
+ this.halted = false;
+ this.haltReason = null;
+
+ this.#buildDevices(circuit, extraction.pinNets);
+ this.#buildListeners();
+ this.#buildSeriesResistance();
+ this.#checkSelfShorts(circuit);
+
+ // Reused across every model wake so evaluation allocates nothing.
+ this.wake = { reason: WAKE_INIT, pin: -1, prevLevel: 0, level: 0, timerId: 0 };
+
+ this.netFaultReported = new Uint8Array(this.netCount);
+ this.shortedNetCount = 0;
+ // Depth of nested model evaluation. Guards `driveNow` and documents the
+ // invariant that `wake` and model scratch buffers are only safe because
+ // evaluation never re-enters itself.
+ this.evaluating = 0;
+ this.deltaTimeNs = -1;
+ this.deltaCount = 0;
+ this.deltaRing = new Int32Array(DELTA_RING_SIZE).fill(-1);
+ this.deltaRingCount = 0;
+ this.oscillationReported = false;
+
+ this.#initDevices();
+ }
+
+ /* ---------------------------------------------------------------- *
+ * Construction
+ * ---------------------------------------------------------------- */
+
+ #buildDevices(circuit, pinNets) {
+ const components = Array.isArray(circuit?.components) ? circuit.components : [];
+ this.devices = [];
+ this.deviceByUid = new Map();
+ this.ledOrder = [];
+
+ let driverCapacityHint = 0;
+ for (const component of components) {
+ const model = this.registry.get(component?.type);
+ if (!model) {
+ if (component?.type) {
+ this.warnings.push({
+ kind: 'unknownComponent',
+ detail: `no simulation model for component type "${component.type}"`,
+ uids: [component.uid],
+ });
+ }
+ continue;
+ }
+ const nets = pinNets.get(component.uid);
+ if (!nets) continue;
+
+ const index = this.devices.length;
+ const pinCount = Math.max(model.pinCount, nets.length);
+ const pins = new Int32Array(pinCount).fill(NO_NET);
+ pins.set(nets.subarray(0, Math.min(nets.length, pinCount)));
+
+ const inst = {
+ index,
+ uid: component.uid,
+ type: component.type,
+ model,
+ props: component.props ?? {},
+ state: model.createState ? model.createState(component) : null,
+ pins,
+ drivers: new Int32Array(pinCount).fill(-1),
+ delayNs: this.#delayFor(model),
+ ledOrdinal: -1,
+ };
+ this.devices.push(inst);
+ this.deviceByUid.set(component.uid, inst);
+ if (component.type === LED_TYPE) {
+ inst.ledOrdinal = this.ledOrder.length;
+ this.ledOrder.push(component.uid);
+ }
+ driverCapacityHint += pinCount;
+ }
+
+ // Every connected pin owns a driver from the start, whether or not the
+ // model ever drives it. That is what makes pin direction a runtime
+ // property: a model can assert or release any pin at any moment without
+ // the engine having been told in advance which pins are outputs.
+ this.nets = new NetState(this.netCount, Math.max(64, driverCapacityHint));
+ this.driverOwner = new Int32Array(Math.max(64, driverCapacityHint)).fill(-1);
+ for (const inst of this.devices) {
+ for (let pin = 0; pin < inst.pins.length; pin++) {
+ if (inst.pins[pin] === NO_NET) continue;
+ const driverId = this.nets.addDriver(inst.pins[pin]);
+ inst.drivers[pin] = driverId;
+ if (driverId >= this.driverOwner.length) this.#growDriverOwners(driverId + 1);
+ this.driverOwner[driverId] = inst.index;
+ }
+ }
+ // Float64, matching event-queue's `seq` for the same reason: these are
+ // unbounded monotonic counters, and an int32 that silently wraps turns a
+ // stale event into a live one. Practically unreachable, but the cost of
+ // consistency here is zero.
+ this.driverGen = new Float64Array(this.nets.driverCount + 1);
+ this.driverPending = new Int32Array(this.nets.driverCount + 1).fill(-1);
+ this.ledCurrentsMilliamps = new Float32Array(this.ledOrder.length);
+ }
+
+ #growDriverOwners(minimum) {
+ let capacity = this.driverOwner.length * 2;
+ while (capacity < minimum) capacity *= 2;
+ const owner = new Int32Array(capacity).fill(-1);
+ owner.set(this.driverOwner);
+ this.driverOwner = owner;
+ }
+
+ #delayFor(model) {
+ if (this.delayOverridesNs) {
+ const override = this.delayOverridesNs.get(model.type);
+ if (override !== undefined) return override;
+ }
+ return model.delayNs;
+ }
+
+ /**
+ * Net -> listening (device, pin) pairs, in compressed sparse row form. A flat
+ * pair of typed arrays plus an offset table keeps wake dispatch to one
+ * contiguous scan instead of chasing per-net sub-arrays.
+ */
+ #buildListeners() {
+ const counts = new Int32Array(this.netCount + 1);
+ let total = 0;
+ for (const inst of this.devices) {
+ for (let pin = 0; pin < inst.pins.length; pin++) {
+ const net = inst.pins[pin];
+ if (net === NO_NET) continue;
+ counts[net + 1]++;
+ total++;
+ }
+ }
+ for (let i = 0; i < this.netCount; i++) counts[i + 1] += counts[i];
+ this.listenerStart = counts;
+ this.listenerDevice = new Int32Array(total);
+ this.listenerPin = new Uint8Array(total);
+
+ const cursor = counts.slice(0, this.netCount);
+ for (const inst of this.devices) {
+ for (let pin = 0; pin < inst.pins.length; pin++) {
+ const net = inst.pins[pin];
+ if (net === NO_NET) continue;
+ const slot = cursor[net]++;
+ this.listenerDevice[slot] = inst.index;
+ this.listenerPin[slot] = pin;
+ }
+ }
+ }
+
+ /** Smallest resistor touching each net, for LED series-resistance lookup. */
+ #buildSeriesResistance() {
+ /** @type {Map} */
+ this.minOhmsByNet = new Map();
+ for (const inst of this.devices) {
+ if (inst.type !== RESISTOR_TYPE) continue;
+ const ohms = inst.state?.ohms;
+ if (!Number.isFinite(ohms)) continue;
+ for (let pin = 0; pin < 2; pin++) {
+ const net = inst.pins[pin];
+ if (net === NO_NET) continue;
+ const existing = this.minOhmsByNet.get(net);
+ if (existing === undefined || ohms < existing) this.minOhmsByNet.set(net, ohms);
+ }
+ }
+ }
+
+ /**
+ * Load-time only: reports components whose own terminals share a net, which
+ * makes the component electrically inert.
+ *
+ * This is worth a warning precisely because it is INVISIBLE. An LED wired
+ * across a single terminal strip reports 0 mA — and 0 mA is also what a
+ * normally-off LED reports, so neither the user nor the UI can tell the
+ * difference between "not lit right now" and "can never light". Same for a
+ * resistor bridged across one strip (silently contributing nothing) and a
+ * switch whose two contacts are already common (pressing it does nothing).
+ *
+ * Two sources, because neither alone is sufficient:
+ * - GEOMETRY, via shared's componentSelfShorts: both legs placed in one
+ * strip. Already excludes pins bonded by design, so a push button's 1-2
+ * and 3-4 never appear.
+ * - NETS: the same pin pairs resolved through union-find, which also
+ * catches a short made by a WIRE rather than by placement — invisible to
+ * geometry, and just as dead. Design ties are subtracted here too.
+ *
+ * Never emitted at runtime, and capped per document.
+ */
+ #checkSelfShorts(circuit) {
+ const components = Array.isArray(circuit?.components) ? circuit.components : [];
+ let emitted = 0;
+ for (const component of components) {
+ if (emitted >= MAX_SELF_SHORT_WARNINGS) break;
+ const inst = this.deviceByUid.get(component?.uid);
+ if (!inst) continue;
+
+ const seen = new Set();
+ const offending = [];
+ const record = (a, b, cause) => {
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ offending.push({ a, b, cause });
+ };
+
+ for (const [a, b] of this.registry.selfShorts(component)) record(a, b, 'placed in the same terminal strip');
+ for (const [a, b] of this.registry.functionalPairs(component)) {
+ const netA = inst.pins[a];
+ const netB = inst.pins[b];
+ if (netA >= 0 && netA === netB) record(a, b, 'connected to the same net');
+ }
+ if (offending.length === 0) continue;
+
+ emitted++;
+ const described = offending.map((o) => `pins ${o.a + 1} and ${o.b + 1} are ${o.cause}`).join('; ');
+ this.warnings.push({
+ kind: 'selfShorted',
+ uids: [inst.uid],
+ netId: inst.pins[offending[0].a],
+ detail: `${inst.type} ${inst.uid} is shorted across itself and can have no effect: ${described}`,
+ });
+ }
+ }
+
+ #initDevices() {
+ this.wake.reason = WAKE_INIT;
+ this.wake.pin = -1;
+ for (const inst of this.devices) {
+ if (inst.model.init) inst.model.init(this, inst);
+ }
+ for (const inst of this.devices) {
+ this.wake.reason = WAKE_INIT;
+ this.wake.pin = -1;
+ this.#evaluate(inst, this.wake);
+ }
+ }
+
+ /* ---------------------------------------------------------------- *
+ * Model-facing context
+ * ---------------------------------------------------------------- */
+
+ /*
+ * PIN ACCESSORS FAIL CLOSED — note the `>= 0` tests rather than `< 0`.
+ * Reading a typed array past its end yields `undefined`, and `undefined < 0`
+ * is FALSE, so a `< 0` guard lets an out-of-range pin through. It then
+ * indexes a real driver (commonly driver 0, which is live on a live net) and
+ * silently drives an unrelated part of the circuit. `>= 0` is false for
+ * `undefined`, so the bad pin is rejected instead of aliased.
+ */
+
+ level(inst, pin) {
+ const net = inst.pins[pin];
+ return net >= 0 ? this.nets.levels[net] : LEVEL_HIGHZ;
+ }
+
+ netOf(inst, pin) {
+ const net = inst.pins[pin];
+ return net >= 0 ? net : NO_NET;
+ }
+
+ levelExcludingSelf(inst, pin) {
+ const net = inst.pins[pin];
+ const driverId = inst.drivers[pin];
+ if (!(net >= 0) || !(driverId >= 0)) return LEVEL_HIGHZ;
+ return this.nets.levelExcluding(net, driverId);
+ }
+
+ driveExcludingSelf(inst, pin) {
+ const net = inst.pins[pin];
+ const driverId = inst.drivers[pin];
+ if (!(net >= 0) || !(driverId >= 0)) return DRIVE_HIGHZ;
+ return this.nets.driveExcluding(net, driverId);
+ }
+
+ /**
+ * Schedules a driver change `delayNs` from now, with inertial semantics: a
+ * still-pending change on the same driver is cancelled, and a request that
+ * matches what is already pending (or already applied) costs nothing.
+ */
+ drive(inst, pin, strength, value, delayNs) {
+ const driverId = inst.drivers[pin];
+ if (!(driverId >= 0)) return;
+ const code = packCode(strength, value);
+ const currentCode = packCode(this.nets.driverStrength[driverId], this.nets.driverValue[driverId]);
+ const pending = this.driverPending[driverId];
+ const effective = pending >= 0 ? pending : currentCode;
+ if (code === effective) return;
+
+ this.driverGen[driverId]++;
+ if (code === currentCode) {
+ // The input moved back before the pending edge fired: glitch swallowed.
+ this.driverPending[driverId] = -1;
+ return;
+ }
+ this.driverPending[driverId] = code;
+ const delay = delayNs === undefined ? inst.delayNs : delayNs;
+ this.queue.push(this.timeNs + delay, EVENT_DRIVE, driverId, code, this.driverGen[driverId]);
+ }
+
+ /**
+ * Applies a driver change immediately, bypassing the queue.
+ *
+ * SAFE TO CALL FROM `evaluate`. Applying immediately from inside a model's
+ * evaluate would re-enter #applyDriver -> #propagate, which clobbers the
+ * shared `wake` record mid-loop and hands every remaining listener the wrong
+ * pin — a silent wrong answer, not a crash. Rather than document that as a
+ * rule for future model authors to remember, the hazard is removed: during
+ * evaluation this degrades to a zero-delay queued change, which lands in the
+ * same sim instant and goes through the normal, non-reentrant path.
+ */
+ driveNow(inst, pin, strength, value) {
+ const driverId = inst.drivers[pin];
+ if (!(driverId >= 0)) return;
+ if (this.evaluating > 0) {
+ this.drive(inst, pin, strength, value, 0);
+ return;
+ }
+ this.driverGen[driverId]++;
+ this.driverPending[driverId] = -1;
+ this.#applyDriver(driverId, strength, value);
+ }
+
+ /** Wakes this device again at `timeNs + deltaNs` with no input change. */
+ scheduleSelf(inst, deltaNs, timerId = 0) {
+ if (!(inst?.index >= 0) || !Number.isFinite(deltaNs) || deltaNs < 0) return;
+ this.queue.push(this.timeNs + deltaNs, EVENT_TIMER, inst.index, timerId, 0);
+ }
+
+ warn(kind, uids, netId, detail) {
+ this.warnings.push({ kind, uids, netId, detail });
+ }
+
+ /** A load-time issue: reported once in the `loaded` message, never repeated. */
+ staticWarning(kind, detail, uids) {
+ this.warnings.push({ kind, detail, uids });
+ }
+
+ setLedCurrent(inst, amps) {
+ if (inst.ledOrdinal >= 0) this.ledCurrentsMilliamps[inst.ledOrdinal] = amps * 1000;
+ }
+
+ /**
+ * How many component pins sit on `netId`. A pin whose net has no other
+ * occupant is genuinely floating — nothing can ever drive it — which is how
+ * models tell "unwired input" apart from "input not driven yet at load".
+ */
+ netListenerCount(netId) {
+ if (netId === NO_NET) return 0;
+ return this.listenerStart[netId + 1] - this.listenerStart[netId];
+ }
+
+ /** Smallest resistance touching `netId`, or 0 when nothing limits it. */
+ dominantSeriesOhms(netId) {
+ if (netId === NO_NET) return 0;
+ return this.minOhmsByNet.get(netId) ?? 0;
+ }
+
+ /* ---------------------------------------------------------------- *
+ * Running
+ * ---------------------------------------------------------------- */
+
+ /**
+ * Processes up to `budget` events.
+ * @returns {number} events actually processed. Fewer than the budget means
+ * the circuit settled or the sim halted; neither is an error.
+ */
+ runEvents(budget) {
+ const queue = this.queue;
+ let processed = 0;
+ while (processed < budget && !this.halted && queue.size > 0) {
+ // The delta check reads the NEXT event's time before popping, so a
+ // guard trip leaves that event in the queue. Losing it would make the
+ // circuit look settled on the next run, hiding the fault instead of
+ // reporting it again.
+ const nextTime = queue.peekTime();
+ if (nextTime === this.deltaTimeNs) {
+ if (++this.deltaCount > MAX_EVENTS_PER_INSTANT) {
+ this.#reportOscillation();
+ break;
+ }
+ } else {
+ this.deltaTimeNs = nextTime;
+ this.deltaCount = 0;
+ this.deltaRingCount = 0;
+ }
+
+ queue.pop();
+ this.timeNs = queue.outTime;
+
+ if (queue.outKind === EVENT_DRIVE) {
+ const driverId = queue.outTarget;
+ if (queue.outGen === this.driverGen[driverId]) {
+ this.driverPending[driverId] = -1;
+ this.deltaRing[this.deltaRingCount++ & DELTA_RING_MASK] = driverId;
+ this.#applyDriver(driverId, queue.outArg >> 1, queue.outArg & 1);
+ }
+ } else {
+ const inst = this.devices[queue.outTarget];
+ this.wake.reason = WAKE_TIMER;
+ this.wake.pin = -1;
+ this.wake.timerId = queue.outArg;
+ this.#evaluate(inst, this.wake);
+ }
+ processed++;
+ }
+ return processed;
+ }
+
+ /** True when nothing more will happen without outside input. */
+ get isSettled() {
+ return this.queue.size === 0;
+ }
+
+ /**
+ * Clears a halt and the delta counter so the user can run or step again.
+ * A short circuit is the exception: resuming into a still-shorted supply
+ * would let the sim run in a state that would have destroyed real hardware,
+ * so the halt stands until the offending connection is removed. The warning
+ * is not re-emitted — the fault is already latched per net.
+ *
+ * @returns {boolean} whether the simulation is now runnable.
+ */
+ resume() {
+ if (this.shortedNetCount > 0) {
+ this.halted = true;
+ this.haltReason = 'shortCircuit';
+ return false;
+ }
+ this.halted = false;
+ this.haltReason = null;
+ this.deltaTimeNs = -1;
+ this.deltaCount = 0;
+ this.deltaRingCount = 0;
+ return true;
+ }
+
+ #applyDriver(driverId, strength, value) {
+ const nets = this.nets;
+ const netId = nets.driverNet[driverId];
+ const prevLevel = nets.levelOf(netId);
+ if (!nets.setDriver(driverId, strength, value)) {
+ // The level held, but the fault classification may still have moved
+ // (a second driver of the same polarity arriving, say).
+ this.#checkFault(netId);
+ return;
+ }
+ this.#checkFault(netId);
+ this.#propagate(netId, prevLevel, nets.levelOf(netId));
+ }
+
+ /**
+ * The single place a model's `evaluate` is invoked. Everything that makes
+ * evaluation allocation-free — the reused `wake` record, the shared input
+ * scratch buffer in logic-ic.js — depends on evaluation never re-entering
+ * itself, so the depth counter lives here rather than at each call site.
+ */
+ #evaluate(inst, wake) {
+ this.evaluating++;
+ inst.model.evaluate(this, inst, wake);
+ this.evaluating--;
+ }
+
+ #propagate(netId, prevLevel, newLevel) {
+ const start = this.listenerStart[netId];
+ const end = this.listenerStart[netId + 1];
+ const wake = this.wake;
+ for (let i = start; i < end; i++) {
+ const inst = this.devices[this.listenerDevice[i]];
+ wake.reason = WAKE_PIN;
+ wake.pin = this.listenerPin[i];
+ wake.prevLevel = prevLevel;
+ wake.level = newLevel;
+ this.#evaluate(inst, wake);
+ }
+ }
+
+ #checkFault(netId) {
+ const fault = this.nets.faultOf(netId);
+ const previous = this.netFaultReported[netId];
+ if (fault === previous) return;
+ if (previous === FAULT_SHORT_CIRCUIT) this.shortedNetCount--;
+ if (fault === FAULT_SHORT_CIRCUIT) this.shortedNetCount++;
+ this.netFaultReported[netId] = fault;
+ if (fault === FAULT_NONE) return;
+
+ const uids = this.#driversOnNet(netId);
+ if (fault === FAULT_SHORT_CIRCUIT) {
+ this.halted = true;
+ this.haltReason = 'shortCircuit';
+ this.warn('shortCircuit', uids, netId, `net ${netId} ties the 5V rail directly to ground`);
+ } else if (fault === FAULT_CONTENTION) {
+ this.warn('contention', uids, netId, `net ${netId} is driven high and low at the same time`);
+ }
+ }
+
+ /** uids of every device with a pin on `netId`, for warning payloads. */
+ #driversOnNet(netId) {
+ const uids = [];
+ const start = this.listenerStart[netId];
+ const end = this.listenerStart[netId + 1];
+ for (let i = start; i < end; i++) {
+ const uid = this.devices[this.listenerDevice[i]].uid;
+ if (!uids.includes(uid)) uids.push(uid);
+ }
+ return uids;
+ }
+
+ #reportOscillation() {
+ this.halted = true;
+ this.haltReason = 'oscillation';
+ if (this.oscillationReported) return;
+ this.oscillationReported = true;
+
+ // Name the participants from the drivers most recently applied at this
+ // same timestamp, plus whatever is still queued for it. Between them
+ // those are exactly the elements going round the loop.
+ const uids = [];
+ const netIds = [];
+ const note = (driverId) => {
+ if (driverId < 0 || uids.length >= MAX_OSCILLATION_CULPRITS) return;
+ const owner = this.driverOwner[driverId];
+ if (owner >= 0) {
+ const uid = this.devices[owner].uid;
+ if (!uids.includes(uid)) uids.push(uid);
+ }
+ const netId = this.nets.driverNet[driverId];
+ if (netId >= 0 && !netIds.includes(netId)) netIds.push(netId);
+ };
+ for (let i = 0; i < DELTA_RING_SIZE; i++) note(this.deltaRing[i]);
+ const queue = this.queue;
+ for (let i = 0; i < queue.length; i++) {
+ if (queue.time[i] === this.deltaTimeNs && queue.kind[i] === EVENT_DRIVE) note(queue.target[i]);
+ }
+ this.warnings.push({
+ kind: 'oscillation',
+ uids,
+ netId: netIds.length > 0 ? netIds[0] : -1,
+ detail:
+ `circuit will not settle: over ${MAX_EVENTS_PER_INSTANT} events resolved at ${this.deltaTimeNs} ns ` +
+ `without simulated time advancing. Zero-delay feedback loop through net(s) ${netIds.join(', ')}` +
+ (uids.length > 0 ? ` and component(s) ${uids.join(', ')}` : '') +
+ '. Simulation paused.',
+ });
+ }
+
+ /* ---------------------------------------------------------------- *
+ * Outside world
+ * ---------------------------------------------------------------- */
+
+ /** Handles a `{ type:"input", uid, value }` message. */
+ applyInput(uid, value) {
+ const inst = this.deviceByUid.get(uid);
+ if (!inst || !inst.model.applyInput) return false;
+ inst.model.applyInput(this, inst, value);
+ return true;
+ }
+
+ /**
+ * Net map handed to the UI once at load, so it can colour holes and wires
+ * straight from `frame.netLevels` without re-deriving connectivity.
+ * Unconnected pins are -1.
+ */
+ netIndex() {
+ const strips = {};
+ for (const [key, netId] of this.netOfStrip) strips[key] = netId;
+ const components = {};
+ for (const inst of this.devices) components[inst.uid] = Array.from(inst.pins);
+ return { strips, components, ledOrder: this.ledOrder.slice() };
+ }
+
+ /** Drains accumulated warnings. Callers own the returned array. */
+ drainWarnings() {
+ if (this.warnings.length === 0) return [];
+ const drained = this.warnings;
+ this.warnings = [];
+ return drained;
+ }
+
+ /** Runs to quiescence, bounded. Used right after load. */
+ settle(maxEvents = 200000) {
+ return this.runEvents(maxEvents);
+ }
+}
+
+export { EVENT_DRIVE, EVENT_TIMER };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/union-find.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/union-find.js
new file mode 100644
index 0000000..a7da92c
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/union-find.js
@@ -0,0 +1,90 @@
+/**
+ * Union-find over string keys, backed by typed arrays.
+ *
+ * Used once per `load` to collapse breadboard strips, rails, wires and
+ * component pins into nets. Keys arrive as strings (strip keys from
+ * shared/board-geometry.js) and are interned to dense integer ids so the
+ * parent/rank arrays can be Int32Array.
+ */
+
+export class UnionFind {
+ constructor(expectedKeys = 256) {
+ /** @type {Map} */
+ this.ids = new Map();
+ this.parent = new Int32Array(expectedKeys);
+ this.rank = new Uint8Array(expectedKeys);
+ this.size = 0;
+ }
+
+ /** Interns a key, returning its dense id. Creates the key if it is new. */
+ intern(key) {
+ const existing = this.ids.get(key);
+ if (existing !== undefined) return existing;
+ const id = this.size++;
+ if (id >= this.parent.length) this.#grow();
+ this.parent[id] = id;
+ this.rank[id] = 0;
+ this.ids.set(key, id);
+ return id;
+ }
+
+ #grow() {
+ const parent = new Int32Array(this.parent.length * 2);
+ parent.set(this.parent);
+ const rank = new Uint8Array(parent.length);
+ rank.set(this.rank);
+ this.parent = parent;
+ this.rank = rank;
+ }
+
+ find(id) {
+ const parent = this.parent;
+ let root = id;
+ while (parent[root] !== root) root = parent[root];
+ // Path compression, iterative so deep chains cannot blow the stack.
+ while (parent[id] !== root) {
+ const next = parent[id];
+ parent[id] = root;
+ id = next;
+ }
+ return root;
+ }
+
+ union(a, b) {
+ let ra = this.find(a);
+ let rb = this.find(b);
+ if (ra === rb) return ra;
+ const rank = this.rank;
+ if (rank[ra] < rank[rb]) {
+ const t = ra;
+ ra = rb;
+ rb = t;
+ }
+ this.parent[rb] = ra;
+ if (rank[ra] === rank[rb]) rank[ra]++;
+ return ra;
+ }
+
+ unionKeys(keyA, keyB) {
+ return this.union(this.intern(keyA), this.intern(keyB));
+ }
+
+ /**
+ * Assigns each root a dense net id in ascending root order, then returns
+ * `{ netCount, netOfKey }` where netOfKey maps every interned key to its net.
+ * Deterministic: net ids depend only on intern order, which depends only on
+ * the circuit document, never on hash iteration of the roots.
+ */
+ finish() {
+ const netOfRoot = new Int32Array(this.size).fill(-1);
+ let netCount = 0;
+ for (let id = 0; id < this.size; id++) {
+ const root = this.find(id);
+ if (netOfRoot[root] === -1) netOfRoot[root] = netCount++;
+ }
+ /** @type {Map} */
+ const netOfKey = new Map();
+ for (const [key, id] of this.ids) netOfKey.set(key, netOfRoot[this.find(id)]);
+ return { netCount, netOfKey };
+ }
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/worker.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/worker.js
new file mode 100644
index 0000000..3738efb
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/engine/worker.js
@@ -0,0 +1,428 @@
+/**
+ * Module Web Worker hosting the simulation. Speaks the spec's protocol.
+ *
+ * Main -> worker:
+ * { type:"load", circuit }
+ * { type:"run" } | { type:"pause" } | { type:"step", count }
+ * { type:"setSpeed", eventsPerSecond }
+ * { type:"input", uid, value }
+ * { type:"reset" }
+ *
+ * Worker -> main:
+ * { type:"loaded", nets, warnings, netIndex }
+ * { type:"frame", netLevels, ledStates, simTimeNs, running, settled, halted }
+ * { type:"warning", kind, uids, netId, detail }
+ * { type:"error", context, message }
+ *
+ * This file's whole job is to keep the main thread healthy. The simulation can
+ * legitimately produce millions of events and hundreds of thousands of warnings
+ * per second; none of that may reach the UI at that rate. Three independent
+ * limiters enforce that, and they are independent ON PURPOSE — each bounds a
+ * different resource, and collapsing them would leave a hole:
+ *
+ * 1. TIME per tick (`TICK_BUDGET_MS`) bounds how long the worker can be deaf
+ * to incoming messages. A count-based budget cannot do this job: per-event
+ * cost is circuit-dependent, so any fixed event count is simultaneously too
+ * slow on a heavy circuit and too coarse on a light one. The budget is
+ * checked every `EVENTS_PER_TIME_CHECK` events, so `pause` is always heard
+ * within a few milliseconds regardless of what the circuit is doing.
+ * 2. FRAMES per second (`FRAME_HZ`) bounds render pressure. EVERY frame goes
+ * through `requestFrame`, including the ones triggered by pause, step,
+ * input and load — a UI sending an input per mousemove would otherwise
+ * punch straight through the throttle. Suppressed frames are not dropped:
+ * a trailing-edge timer flushes the final state, so the UI can never be
+ * left showing something stale.
+ * 3. WARNINGS per tick and per key (`MAX_WARNINGS_PER_TICK`,
+ * `WARNING_COOLDOWN_MS`) bound message volume. See `flushWarnings`.
+ *
+ * `eventsPerSecond` remains the user-facing SPEED control and is applied on top
+ * of the time budget; whichever binds first wins.
+ *
+ * Frame buffers are allocated per frame rather than double-buffered. They are
+ * TRANSFERRED, which neuters them on this side, so a reused buffer would come
+ * back detached and throw; and at 60 Hz a few kilobytes is far below the noise
+ * floor of anything else the worker does.
+ */
+
+import { Simulation } from './simulation.js';
+
+const FRAME_HZ = 60;
+const FRAME_INTERVAL_MS = 1000 / FRAME_HZ;
+const DEFAULT_EVENTS_PER_SECOND = 1_000_000;
+
+/** Wall-clock ceiling for one tick, so `pause` is never more than this away. */
+const TICK_BUDGET_MS = 8;
+/** How often the time budget is consulted. Small enough to be responsive. */
+const EVENTS_PER_TIME_CHECK = 4096;
+
+/** At most this many `warning` messages leave the worker per tick. */
+const MAX_WARNINGS_PER_TICK = 20;
+/** The same warning identity is not re-sent more often than this. */
+const WARNING_COOLDOWN_MS = 1000;
+/** Cap on remembered warning identities, so the dedupe map cannot grow forever. */
+const MAX_WARNING_KEYS = 500;
+
+/** @type {Simulation|null} */
+let sim = null;
+let circuit = null;
+let running = false;
+let eventsPerSecond = DEFAULT_EVENTS_PER_SECOND;
+let timer = null;
+
+let lastFrameAt = -Infinity;
+let frameTimer = null;
+
+/** key -> timestamp last posted, for warning dedupe across ticks. */
+const warningLastPosted = new Map();
+let suppressedWarnings = 0;
+let lastSuppressionReportAt = -Infinity;
+
+function now() {
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
+}
+
+function post(message, transfers) {
+ self.postMessage(message, transfers ?? []);
+}
+
+/* ------------------------------------------------------------------ *
+ * Warnings
+ * ------------------------------------------------------------------ */
+
+/**
+ * Identity of a warning for dedupe purposes. Deliberately excludes `detail`,
+ * which carries changing numbers (a fluctuating current, a timestamp) and would
+ * make every repeat look unique — which is precisely how an unbounded flood
+ * gets through a naive dedupe.
+ */
+function warningKey(warning) {
+ return `${warning.kind}|${warning.netId ?? -1}|${(warning.uids ?? []).join(',')}`;
+}
+
+/**
+ * Posts warnings under a hard budget.
+ *
+ * A warning is a NOTIFICATION, not a log entry. The engine may generate them at
+ * event rate — an LED driven at 25 mA by a running oscillator crosses the 20 mA
+ * threshold on every cycle, which is hundreds of thousands of warnings per
+ * second, and contention on a toggling net does the same. Posting one message
+ * each would wedge the main thread, which is the single failure this worker
+ * exists to prevent.
+ *
+ * So: identical warnings are collapsed by identity with a cooldown, at most
+ * MAX_WARNINGS_PER_TICK escape per tick, and anything held back is reported as
+ * a single coalesced count rather than silently dropped. Bounding it HERE
+ * rather than in the models covers every warning kind at once, including kinds
+ * added later.
+ */
+function flushWarnings() {
+ if (!sim) return;
+ const drained = sim.drainWarnings();
+ if (drained.length === 0 && suppressedWarnings === 0) return;
+
+ const at = now();
+ if (warningLastPosted.size > MAX_WARNING_KEYS) warningLastPosted.clear();
+
+ let emitted = 0;
+ for (const warning of drained) {
+ const key = warningKey(warning);
+ const last = warningLastPosted.get(key);
+ if (last !== undefined && at - last < WARNING_COOLDOWN_MS) {
+ suppressedWarnings++;
+ continue;
+ }
+ if (emitted >= MAX_WARNINGS_PER_TICK) {
+ suppressedWarnings++;
+ continue;
+ }
+ warningLastPosted.set(key, at);
+ emitted++;
+ post({
+ type: 'warning',
+ kind: warning.kind,
+ uids: warning.uids ?? [],
+ netId: warning.netId ?? -1,
+ detail: warning.detail ?? '',
+ });
+ }
+
+ if (suppressedWarnings > 0 && at - lastSuppressionReportAt >= WARNING_COOLDOWN_MS) {
+ const count = suppressedWarnings;
+ suppressedWarnings = 0;
+ lastSuppressionReportAt = at;
+ post({
+ type: 'warning',
+ kind: 'warningsSuppressed',
+ uids: [],
+ netId: -1,
+ detail: `${count} further warning${count === 1 ? '' : 's'} suppressed — the circuit is repeating a fault every cycle`,
+ });
+ }
+}
+
+function resetWarningThrottle() {
+ warningLastPosted.clear();
+ suppressedWarnings = 0;
+ lastSuppressionReportAt = -Infinity;
+}
+
+/**
+ * Turns an unexpected throw into something the UI can show.
+ *
+ * Without this, any throw escapes as an unhandled worker error: the protocol
+ * has no error path, so main never gets `loaded`, never gets a `frame`, and
+ * never learns why — the UI simply waits forever on a dead worker. A silent
+ * hang is the worst possible failure mode, so every entry point funnels here.
+ *
+ * The failure is reported as the top-level `{ type:"error", context, message }`
+ * rather than as a `warning`. The runtime `warning.kind` enum is CLOSED, so an
+ * engine fault is not expressible in it — and an engine fault is categorically
+ * different anyway: a warning describes the user's circuit, an error describes
+ * the simulator failing to run at all.
+ */
+function reportEngineError(context, error) {
+ running = false;
+ stopTimer();
+ const message = error?.message ?? String(error);
+ try {
+ post({ type: 'error', context, message });
+ // `{type:"error"}` alone still leaves a `load` caller blocked on the
+ // `loaded` it is waiting for, so answer that too. Inside
+ // `loaded.warnings` the kind set is open, so `loadFailed` is legal there.
+ if (context === 'load' && !sim) {
+ post({
+ type: 'loaded',
+ nets: 0,
+ warnings: [{ kind: 'loadFailed', uids: [], netId: -1, detail: `circuit failed to load: ${message}` }],
+ netIndex: { strips: {}, components: {}, ledOrder: [] },
+ });
+ }
+ } catch {
+ // postMessage itself failed; nothing further is possible.
+ }
+}
+
+/* ------------------------------------------------------------------ *
+ * Frames
+ * ------------------------------------------------------------------ */
+
+function postFrame() {
+ if (!sim) return;
+ clearFrameTimer();
+ // Fresh buffers: these are transferred and neutered on the way out.
+ const netLevels = new Uint8Array(sim.nets.levels);
+ const ledStates = new Float32Array(sim.ledCurrentsMilliamps);
+ lastFrameAt = now();
+ post(
+ {
+ type: 'frame',
+ netLevels,
+ ledStates,
+ simTimeNs: sim.timeNs,
+ running,
+ settled: sim.isSettled,
+ halted: sim.halted,
+ },
+ [netLevels.buffer, ledStates.buffer],
+ );
+}
+
+function clearFrameTimer() {
+ if (frameTimer !== null) {
+ clearTimeout(frameTimer);
+ frameTimer = null;
+ }
+}
+
+/**
+ * The ONLY way a frame leaves this worker. Emits immediately when the throttle
+ * allows, otherwise arms a trailing-edge timer so the final state still arrives
+ * — a suppressed frame is delayed, never dropped, or the UI would be left
+ * rendering a stale circuit.
+ */
+function requestFrame(force = false) {
+ if (!sim) return;
+ const since = now() - lastFrameAt;
+ if (force || since >= FRAME_INTERVAL_MS) {
+ postFrame();
+ return;
+ }
+ if (frameTimer === null) frameTimer = setTimeout(postFrame, Math.max(0, FRAME_INTERVAL_MS - since));
+}
+
+/* ------------------------------------------------------------------ *
+ * Running
+ * ------------------------------------------------------------------ */
+
+function stopTimer() {
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+}
+
+function scheduleTick() {
+ stopTimer();
+ if (!running) return;
+ timer = setTimeout(tick, FRAME_INTERVAL_MS);
+}
+
+/** Events allowed this tick by the user's speed setting. */
+function speedBudget() {
+ return Math.max(1, Math.round(eventsPerSecond / FRAME_HZ));
+}
+
+/**
+ * Runs up to `eventBudget` events, but never for longer than TICK_BUDGET_MS.
+ * Time is checked every EVENTS_PER_TIME_CHECK events rather than every event,
+ * so the clock read is amortised to nothing on the hot path.
+ */
+function runBudgeted(eventBudget) {
+ if (!sim) return 0;
+ const deadline = now() + TICK_BUDGET_MS;
+ let processed = 0;
+ while (processed < eventBudget && !sim.halted) {
+ const chunk = Math.min(EVENTS_PER_TIME_CHECK, eventBudget - processed);
+ const did = sim.runEvents(chunk);
+ processed += did;
+ if (did < chunk) break; // settled, or halted mid-chunk
+ if (now() >= deadline) break;
+ }
+ return processed;
+}
+
+function tick() {
+ timer = null;
+ if (!sim || !running) return;
+ try {
+ runTick();
+ } catch (error) {
+ reportEngineError('run', error);
+ }
+}
+
+function runTick() {
+ runBudgeted(speedBudget());
+ flushWarnings();
+
+ if (sim.halted || sim.isSettled) {
+ // A settled circuit needs no more ticks until the user touches
+ // something; `input` restarts the loop. This is not a stall.
+ running = false;
+ requestFrame();
+ return;
+ }
+ requestFrame();
+ scheduleTick();
+}
+
+function load(nextCircuit) {
+ stopTimer();
+ clearFrameTimer();
+ resetWarningThrottle();
+ running = false;
+ lastFrameAt = -Infinity;
+ // Dropped BEFORE constructing, so a throw part-way through leaves no
+ // simulation rather than the previous one — otherwise a failed load leaves
+ // the worker quietly serving frames from a circuit the user has replaced.
+ sim = null;
+ // Replaces the whole simulation object. Nothing from the previous load is
+ // carried over, so repeated loads cannot accumulate nets, drivers or events.
+ const next = new Simulation(nextCircuit);
+ next.settle();
+ sim = next;
+ // Only remembered once the load succeeded, so `reset` cannot replay a
+ // circuit that could not be built.
+ circuit = nextCircuit;
+
+ const warnings = sim.drainWarnings();
+ post({
+ type: 'loaded',
+ nets: sim.netCount,
+ warnings,
+ netIndex: sim.netIndex(),
+ });
+ requestFrame(true);
+}
+
+self.onmessage = (event) => {
+ const message = event.data;
+ if (!message || typeof message.type !== 'string') return;
+ try {
+ handleMessage(message);
+ } catch (error) {
+ reportEngineError(message.type, error);
+ }
+};
+
+function handleMessage(message) {
+ switch (message.type) {
+ case 'load':
+ load(message.circuit);
+ break;
+
+ case 'run':
+ if (!sim) break;
+ // resume() refuses while the supply is still shorted; honour that
+ // rather than pretending to run.
+ if (sim.halted && !sim.resume()) {
+ requestFrame();
+ break;
+ }
+ running = true;
+ scheduleTick();
+ break;
+
+ case 'pause':
+ running = false;
+ stopTimer();
+ requestFrame();
+ break;
+
+ case 'step': {
+ if (!sim) break;
+ // Stepping clears a halt so the user can single-step into a
+ // non-converging loop and look at it, but the guard is still armed:
+ // the step itself terminates rather than spinning.
+ if (sim.halted) sim.resume();
+ running = false;
+ stopTimer();
+ const count = Number.isFinite(message.count) && message.count > 0 ? Math.floor(message.count) : 1;
+ sim.runEvents(count);
+ flushWarnings();
+ requestFrame();
+ break;
+ }
+
+ case 'setSpeed': {
+ const rate = Number(message.eventsPerSecond);
+ if (Number.isFinite(rate) && rate > 0) eventsPerSecond = rate;
+ break;
+ }
+
+ case 'input': {
+ if (!sim) break;
+ sim.applyInput(message.uid, message.value);
+ if (sim.halted) sim.resume();
+ if (!running) {
+ // Settle the consequences of the interaction even while paused,
+ // otherwise a button press appears to do nothing.
+ runBudgeted(speedBudget());
+ if (!sim.isSettled && !sim.halted) {
+ running = true;
+ scheduleTick();
+ }
+ }
+ flushWarnings();
+ requestFrame();
+ break;
+ }
+
+ case 'reset':
+ if (circuit) load(circuit);
+ break;
+
+ default:
+ break;
+ }
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/board-geometry.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/board-geometry.js
new file mode 100644
index 0000000..dad5789
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/board-geometry.js
@@ -0,0 +1,353 @@
+// Breadboard geometry: hole coordinates and strip connectivity.
+//
+// PURE MODULE. No DOM, no globals, no dependencies. It is imported by the editor on
+// the main thread AND by the simulation engine inside a module Worker, so it must be
+// loadable in bare node too. Do not add side effects at import time.
+//
+// Coordinate spaces used here:
+// board space - px relative to a board's own top-left corner
+// world space - board space + the board's {x, y}; pan/zoom is applied on top of
+// this by the renderer, never in this file
+// This file never sees screen/CSS pixels.
+//
+// Physical model: full-size 830-point breadboard.
+// 63 columns; rows a-e and f-j are separate 5-hole terminal strips per column;
+// a center channel between rows e and f; four power rails of 50 holes each, and
+// each rail is ONE continuous net end-to-end (no mid-board split) for v1.
+
+/** World px between adjacent holes (one 0.1" pitch). */
+export const PITCH = 20;
+
+/** Number of main-grid columns, numbered 1..BOARD_COLUMNS. */
+export const BOARD_COLUMNS = 63;
+
+/** Holes per power rail, numbered 1..RAIL_HOLES. */
+export const RAIL_HOLES = 50;
+
+/** Main-grid row letters, ordered top to bottom. */
+export const MAIN_ROWS = Object.freeze(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']);
+
+/** Power rail names, ordered top to bottom as they appear on the board. */
+export const RAIL_NAMES = Object.freeze(['topPlus', 'topMinus', 'bottomMinus', 'bottomPlus']);
+
+/** Rows above the center channel (one electrical strip per column). */
+export const UPPER_ROWS = Object.freeze(['a', 'b', 'c', 'd', 'e']);
+
+/** Rows below the center channel (one electrical strip per column). */
+export const LOWER_ROWS = Object.freeze(['f', 'g', 'h', 'i', 'j']);
+
+// --- Board space layout, expressed in pitch units from the board's top-left ---
+
+const MARGIN_COLS = 1; // blank margin left of column 1
+const ROW_Y_PITCH = Object.freeze(Object.assign(Object.create(null), {
+ a: 4.5, b: 5.5, c: 6.5, d: 7.5, e: 8.5,
+ f: 11.5, g: 12.5, h: 13.5, i: 14.5, j: 15.5
+}));
+const RAIL_Y_PITCH = Object.freeze(Object.assign(Object.create(null), {
+ topPlus: 1, topMinus: 2, bottomMinus: 18, bottomPlus: 19
+}));
+
+// Rail holes sit in 10 groups of 5 with a one-pitch gap between groups, and the whole
+// run is inset from the main grid - matching a real board. Derived independently of
+// the main columns on purpose: rail hole 7 is NOT under column 7.
+const RAIL_GROUP_SIZE = 5;
+const RAIL_GROUP_STRIDE = 6; // 5 holes + 1 blank
+const RAIL_X0_PITCH = 3;
+
+/** Board width in world px. */
+export const BOARD_WIDTH = (BOARD_COLUMNS + 2 * MARGIN_COLS) * PITCH;
+
+/** Board height in world px. */
+export const BOARD_HEIGHT = 20 * PITCH;
+
+/** Y of the top of the center channel, in board space. */
+export const CHANNEL_TOP = (ROW_Y_PITCH.e + 1) * PITCH;
+
+/** Y of the bottom of the center channel, in board space. */
+export const CHANNEL_BOTTOM = (ROW_Y_PITCH.f - 1) * PITCH;
+
+/** How close (world px) a point must be to a hole to count as over it. */
+export const HOLE_HIT_RADIUS = PITCH * 0.5;
+
+const MAIN_ROW_INDEX = Object.freeze(
+ MAIN_ROWS.reduce((acc, row, i) => { acc[row] = i; return acc; }, Object.create(null))
+);
+
+function isPositiveInt(value, max) {
+ return Number.isInteger(value) && value >= 1 && value <= max;
+}
+
+/**
+ * Structural validity of a hole reference. Does NOT check that the referenced board
+ * exists in a circuit - that is circuit-schema's job.
+ * @param {*} hole
+ * @returns {boolean}
+ */
+export function isValidHole(hole) {
+ if (!hole || typeof hole !== 'object') return false;
+ if (typeof hole.board !== 'string' || hole.board.length === 0) return false;
+ if (hole.kind === 'main') {
+ return isPositiveInt(hole.col, BOARD_COLUMNS)
+ && typeof hole.row === 'string'
+ && Object.prototype.hasOwnProperty.call(MAIN_ROW_INDEX, hole.row);
+ }
+ if (hole.kind === 'rail') {
+ return RAIL_NAMES.indexOf(hole.rail) !== -1 && isPositiveInt(hole.index, RAIL_HOLES);
+ }
+ return false;
+}
+
+/**
+ * Canonical identity of a single hole.
+ *
+ * Assumes board uids conform to circuit-schema's UID_PATTERN, which excludes the '|'
+ * delimiter. normalizeCircuit enforces that charset, so a uid can never split a key
+ * into the wrong number of segments.
+ * @returns {string|null} e.g. "b1|m|12|e" or "b1|h|topPlus|7"
+ */
+export function holeKey(hole) {
+ if (!isValidHole(hole)) return null;
+ return hole.kind === 'main'
+ ? `${hole.board}|m|${hole.col}|${hole.row}`
+ : `${hole.board}|h|${hole.rail}|${hole.index}`;
+}
+
+/**
+ * Canonical identity of the electrically-common strip a hole belongs to. Two holes
+ * are directly connected by the board itself iff their stripKeys are equal.
+ * @returns {string|null} e.g. "b1|s|12|ae", "b1|s|12|fj", "b1|r|topPlus"
+ */
+export function stripKey(hole) {
+ if (!isValidHole(hole)) return null;
+ if (hole.kind === 'rail') return `${hole.board}|r|${hole.rail}`;
+ const half = MAIN_ROW_INDEX[hole.row] <= MAIN_ROW_INDEX.e ? 'ae' : 'fj';
+ return `${hole.board}|s|${hole.col}|${half}`;
+}
+
+/**
+ * Inverse of holeKey.
+ * @returns {object|null} a hole reference, or null if the key is malformed
+ */
+export function parseHoleKey(key) {
+ if (typeof key !== 'string') return null;
+ const parts = key.split('|');
+ if (parts.length !== 4) return null;
+ const [board, tag, a, b] = parts;
+ let hole = null;
+ if (tag === 'm') {
+ hole = { board, kind: 'main', col: Number(a), row: b };
+ } else if (tag === 'h') {
+ hole = { board, kind: 'rail', rail: a, index: Number(b) };
+ }
+ return isValidHole(hole) ? hole : null;
+}
+
+/**
+ * Every hole on the same strip as the given hole, including the hole itself.
+ * @returns {object[]} 5 holes for a main strip, RAIL_HOLES for a rail, [] if invalid
+ */
+export function holesInStrip(hole) {
+ if (!isValidHole(hole)) return [];
+ if (hole.kind === 'rail') {
+ const holes = [];
+ for (let i = 1; i <= RAIL_HOLES; i++) {
+ holes.push({ board: hole.board, kind: 'rail', rail: hole.rail, index: i });
+ }
+ return holes;
+ }
+ const rows = MAIN_ROW_INDEX[hole.row] <= MAIN_ROW_INDEX.e ? UPPER_ROWS : LOWER_ROWS;
+ return rows.map(row => ({ board: hole.board, kind: 'main', col: hole.col, row }));
+}
+
+/** True when both holes are valid and share one electrical strip. */
+export function sameStrip(a, b) {
+ const ka = stripKey(a);
+ return ka !== null && ka === stripKey(b);
+}
+
+/** True when both holes are valid and are the same physical hole. */
+export function sameHole(a, b) {
+ const ka = holeKey(a);
+ return ka !== null && ka === holeKey(b);
+}
+
+/**
+ * Step from a hole in a direction. Main-grid up/down move one row and may cross the
+ * center channel (e <-> f). Rail up/down is meaningless and returns null.
+ * @param {object} hole
+ * @param {'left'|'right'|'up'|'down'} dir
+ * @param {number} [n=1] number of steps
+ * @returns {object|null} the hole n steps away, or null if it falls off the board
+ */
+export function offsetHole(hole, dir, n = 1) {
+ if (!isValidHole(hole) || !Number.isInteger(n)) return null;
+ if (hole.kind === 'rail') {
+ if (dir !== 'left' && dir !== 'right') return null;
+ const index = hole.index + (dir === 'right' ? n : -n);
+ const moved = { board: hole.board, kind: 'rail', rail: hole.rail, index };
+ return isValidHole(moved) ? moved : null;
+ }
+ if (dir === 'left' || dir === 'right') {
+ const col = hole.col + (dir === 'right' ? n : -n);
+ const moved = { board: hole.board, kind: 'main', col, row: hole.row };
+ return isValidHole(moved) ? moved : null;
+ }
+ if (dir === 'up' || dir === 'down') {
+ const rowIndex = MAIN_ROW_INDEX[hole.row] + (dir === 'down' ? n : -n);
+ if (rowIndex < 0 || rowIndex >= MAIN_ROWS.length) return null;
+ return { board: hole.board, kind: 'main', col: hole.col, row: MAIN_ROWS[rowIndex] };
+ }
+ return null;
+}
+
+/**
+ * Build a main-grid hole, or null when the column is off the board. Convenience for
+ * pin-mapping code that computes columns arithmetically.
+ */
+export function mainHole(board, col, row) {
+ const hole = { board, kind: 'main', col, row };
+ return isValidHole(hole) ? hole : null;
+}
+
+/** Build a rail hole, or null when out of range. */
+export function railHole(board, rail, index) {
+ const hole = { board, kind: 'rail', rail, index };
+ return isValidHole(hole) ? hole : null;
+}
+
+// --- Positions ---
+
+/** X of a main-grid column, in board space. */
+export function columnX(col) {
+ return (MARGIN_COLS + col - 1) * PITCH;
+}
+
+/** Y of a main-grid row, in board space. */
+export function rowY(row) {
+ const p = ROW_Y_PITCH[row];
+ return p === undefined ? null : p * PITCH;
+}
+
+/** X of a rail hole, in board space. Deliberately not aligned to columnX. */
+export function railHoleX(index) {
+ const group = Math.floor((index - 1) / RAIL_GROUP_SIZE);
+ const within = (index - 1) % RAIL_GROUP_SIZE;
+ return (RAIL_X0_PITCH + group * RAIL_GROUP_STRIDE + within) * PITCH;
+}
+
+/** Y of a rail, in board space. */
+export function railY(rail) {
+ const p = RAIL_Y_PITCH[rail];
+ return p === undefined ? null : p * PITCH;
+}
+
+/**
+ * Position of a hole in board space.
+ * @returns {{x:number,y:number}|null}
+ */
+export function holeLocalPos(hole) {
+ if (!isValidHole(hole)) return null;
+ return hole.kind === 'main'
+ ? { x: columnX(hole.col), y: rowY(hole.row) }
+ : { x: railHoleX(hole.index), y: railY(hole.rail) };
+}
+
+/**
+ * Position of a hole in world space.
+ * @param {object} hole
+ * @param {Map|Record} boards
+ * @returns {{x:number,y:number}|null}
+ */
+export function holeWorldPos(hole, boards) {
+ const local = holeLocalPos(hole);
+ if (local === null) return null;
+ const board = boards instanceof Map ? boards.get(hole.board) : (boards ? boards[hole.board] : null);
+ if (!board) return null;
+ return { x: board.x + local.x, y: board.y + local.y };
+}
+
+/** World-space bounding box of a board. */
+export function boardBounds(board) {
+ return {
+ x: board.x,
+ y: board.y,
+ w: BOARD_WIDTH,
+ h: BOARD_HEIGHT,
+ right: board.x + BOARD_WIDTH,
+ bottom: board.y + BOARD_HEIGHT
+ };
+}
+
+/** True when a world-space point lies within a board's outline. */
+export function pointInBoard(board, worldX, worldY) {
+ return worldX >= board.x && worldX <= board.x + BOARD_WIDTH
+ && worldY >= board.y && worldY <= board.y + BOARD_HEIGHT;
+}
+
+/**
+ * Nearest hole on one board to a world-space point.
+ * Snaps to the grid rather than scanning every hole.
+ * @returns {{hole:object, dist:number}|null}
+ */
+export function nearestHoleOnBoard(board, worldX, worldY, maxDist = HOLE_HIT_RADIUS) {
+ const lx = worldX - board.x;
+ const ly = worldY - board.y;
+
+ let best = null;
+ const consider = (hole) => {
+ const pos = holeLocalPos(hole);
+ if (pos === null) return;
+ const dx = pos.x - lx;
+ const dy = pos.y - ly;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist <= maxDist && (best === null || dist < best.dist)) best = { hole, dist };
+ };
+
+ const col = Math.round(lx / PITCH) - MARGIN_COLS + 1;
+ for (const row of MAIN_ROWS) {
+ consider(mainHole(board.uid, col, row));
+ }
+
+ // Rails: invert railHoleX to get the candidate index without scanning all 50.
+ const railUnit = lx / PITCH - RAIL_X0_PITCH;
+ const group = Math.floor(railUnit / RAIL_GROUP_STRIDE);
+ for (let g = group - 1; g <= group + 1; g++) {
+ if (g < 0 || g >= RAIL_HOLES / RAIL_GROUP_SIZE) continue;
+ const within = Math.round(railUnit - g * RAIL_GROUP_STRIDE);
+ if (within < 0 || within >= RAIL_GROUP_SIZE) continue;
+ const index = g * RAIL_GROUP_SIZE + within + 1;
+ for (const rail of RAIL_NAMES) {
+ consider(railHole(board.uid, rail, index));
+ }
+ }
+
+ return best;
+}
+
+/**
+ * Nearest hole to a world-space point across all boards.
+ * @param {number} worldX
+ * @param {number} worldY
+ * @param {Array<{uid:string,x:number,y:number}>} boards
+ * @param {number} [maxDist]
+ * @returns {object|null} the hole reference, or null when nothing is close enough
+ */
+export function holeAtWorldPoint(worldX, worldY, boards, maxDist = HOLE_HIT_RADIUS) {
+ let best = null;
+ for (const board of boards) {
+ const hit = nearestHoleOnBoard(board, worldX, worldY, maxDist);
+ if (hit !== null && (best === null || hit.dist < best.dist)) best = hit;
+ }
+ return best === null ? null : best.hole;
+}
+
+/**
+ * The board a world-space point falls on, or null.
+ * @param {Array<{uid:string,x:number,y:number}>} boards
+ */
+export function boardAtWorldPoint(worldX, worldY, boards) {
+ for (let i = boards.length - 1; i >= 0; i--) {
+ if (pointInBoard(boards[i], worldX, worldY)) return boards[i];
+ }
+ return null;
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/circuit-schema.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/circuit-schema.js
new file mode 100644
index 0000000..525609a
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/circuit-schema.js
@@ -0,0 +1,729 @@
+// Circuit document schema v1: create, normalize and validate.
+//
+// PURE MODULE. No DOM, no globals. The structural rules here are mirrored by the
+// server-side C# validator, so keep them explicit and keep the error strings terse
+// and stable.
+//
+// Two entry points with deliberately different temperaments:
+// normalizeCircuit - LENIENT. Coerces anything into a usable document so a user is
+// never locked out of their own project. It REPORTS everything
+// it could not keep; it never discards silently.
+// validateCircuit - STRICT. Mirrors the server. This is the gate.
+// INVARIANT: the output of normalizeCircuit always passes validateCircuit. Several
+// call sites depend on it, and the shared test suite asserts it by fuzzing.
+
+import {
+ isValidHole,
+ holeKey,
+ BOARD_WIDTH,
+ BOARD_HEIGHT
+} from './board-geometry.js';
+
+import {
+ getComponentDef,
+ isKnownType,
+ validateProps,
+ defaultPropsFor,
+ dipOrientForRow
+} from './component-registry.js';
+
+import { componentPinHoles, isFullyPlaced } from './component-pins.js';
+
+/** Schema version emitted by this build. */
+export const CIRCUIT_VERSION = 1;
+
+/** Maximum length of any uid in the document. */
+export const MAX_UID_LENGTH = 40;
+
+// Count caps pinned by team-lead amendment A11, identical in the C# validator. These
+// are PRODUCT limits only: passing them says nothing about the byte cap below, because
+// a document at every count cap with 40-character uids still measures over 3 MB. The
+// two limits are independent and both are enforced.
+export const MAX_BOARDS = 50;
+export const MAX_COMPONENTS = 3000;
+export const MAX_WIRES = 10000;
+
+/**
+ * Hard byte ceiling on the serialized document (UTF-8), enforced by the server
+ * independently of the count caps. The save path MUST preflight against this with a
+ * real byte count - see circuitByteSize - so an oversized document is refused with an
+ * explanation rather than surfacing as a bare 400.
+ */
+export const MAX_CIRCUIT_BYTES = 2 * 1024 * 1024;
+
+/** Uid character set accepted by the server-side validator. */
+export const UID_PATTERN = new RegExp(`^[A-Za-z0-9_.:-]{1,${MAX_UID_LENGTH}}$`);
+
+const UID_INVALID_CHARS = /[^A-Za-z0-9_.:-]/g;
+
+/** Horizontal gap left between boards when a new one is appended. */
+export const BOARD_STACK_GAP = 60;
+
+const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
+
+/** Largest absolute board coordinate the server accepts. */
+export const MAX_BOARD_COORD = 1000000;
+
+/** Default wire colors offered in the editor, in picker order. */
+export const WIRE_COLORS = Object.freeze([
+ '#d13438', '#2b6cb0', '#2f9e44', '#e8a33d', '#7048e8', '#f2f2f2', '#1a1a1a', '#e07a9c'
+]);
+
+/** Color used for a wire with no explicit color. */
+export const DEFAULT_WIRE_COLOR = WIRE_COLORS[1];
+
+/**
+ * The orient value a component should carry.
+ *
+ * For DIP-style packages the real rotation is carried by which side of the channel
+ * the anchor sits on, so `orient` is derived from the anchor row rather than stored
+ * independently - that keeps the two from ever contradicting each other. Everything
+ * else uses `orient` directly.
+ */
+export function orientFor(def, anchor, requested) {
+ if (!def.orientable) return undefined;
+ if (def.dipStyle) {
+ return anchor && anchor.kind === 'main' ? dipOrientForRow(anchor.row) : def.defaultOrient;
+ }
+ return def.orientValues.indexOf(requested) !== -1 ? requested : def.defaultOrient;
+}
+
+// --- Uid allocation ---
+
+/**
+ * Coerce a string into the server's uid charset, or null when nothing usable remains.
+ * Renaming beats dropping: a bad uid must never cost the user a component.
+ */
+export function sanitizeUid(value) {
+ if (typeof value !== 'string') return null;
+ const cleaned = value.replace(UID_INVALID_CHARS, '-').slice(0, MAX_UID_LENGTH);
+ return cleaned.length > 0 ? cleaned : null;
+}
+
+/**
+ * Allocates document-unique uids in amortized constant time.
+ *
+ * nextUid() rebuilds the used-set on every call, which is fine for placing one
+ * component but quadratic over a whole document. Any loop that creates more than a
+ * handful of items must use this instead.
+ * @param {string[]} [existing] uids already taken
+ */
+export function createUidAllocator(existing = []) {
+ const used = new Set(existing);
+ const counters = new Map();
+ return {
+ has(uid) { return used.has(uid); },
+ /** Claim `preferred` if it is free and well-formed, otherwise mint one. */
+ claim(preferred, prefix) {
+ const clean = sanitizeUid(preferred);
+ if (clean !== null && !used.has(clean)) {
+ used.add(clean);
+ return clean;
+ }
+ return this.next(prefix);
+ },
+ /** Mint the next free uid with the given prefix. The counter never rewinds. */
+ next(prefix) {
+ let n = counters.get(prefix) || 1;
+ while (used.has(prefix + n)) n++;
+ counters.set(prefix, n + 1);
+ const uid = prefix + n;
+ used.add(uid);
+ return uid;
+ }
+ };
+}
+
+// --- Construction ---
+
+/**
+ * A new, empty circuit with a single board at the origin.
+ * @returns {object}
+ */
+export function createCircuit() {
+ return {
+ version: CIRCUIT_VERSION,
+ boards: [{ uid: 'b1', x: 0, y: 0 }],
+ components: [],
+ wires: []
+ };
+}
+
+/** Structured clone of a circuit, safe to mutate. */
+export function cloneCircuit(circuit) {
+ return JSON.parse(JSON.stringify(circuit));
+}
+
+/**
+ * Next unused uid with the given prefix, e.g. nextUid(c, 'c') -> "c7".
+ *
+ * A pure function of the document: same content, same answer, every time. O(document),
+ * which is fine for placing a single item.
+ *
+ * DO NOT call this in a loop. Filling a document one nextUid at a time is quadratic.
+ * Bulk paths use createUidAllocator, and a long-lived editor should keep one allocator
+ * alongside its circuit - uid allocation is session state, not a property of the
+ * document, and modelling it as the latter does not work.
+ */
+export function nextUid(circuit, prefix) {
+ const used = new Set(allUids(circuit));
+ let n = 1;
+ while (used.has(prefix + n)) n++;
+ return prefix + n;
+}
+
+/** Every uid in the document. */
+export function allUids(circuit) {
+ const uids = [];
+ for (const list of [circuit.boards, circuit.components, circuit.wires]) {
+ if (Array.isArray(list)) {
+ for (const item of list) {
+ if (item && typeof item.uid === 'string') uids.push(item.uid);
+ }
+ }
+ }
+ return uids;
+}
+
+/** Board with the given uid, or null. */
+export function findBoard(circuit, uid) {
+ return circuit.boards.find(b => b.uid === uid) || null;
+}
+
+/** Component with the given uid, or null. */
+export function findComponent(circuit, uid) {
+ return circuit.components.find(c => c.uid === uid) || null;
+}
+
+/** Wire with the given uid, or null. */
+export function findWire(circuit, uid) {
+ return circuit.wires.find(w => w.uid === uid) || null;
+}
+
+/**
+ * Boards keyed by uid - the shape board-geometry's holeWorldPos expects.
+ * @returns {Map}
+ */
+export function boardsByUid(circuit) {
+ return new Map(circuit.boards.map(b => [b.uid, b]));
+}
+
+/**
+ * Append a board below the existing ones and return it. Mutates `circuit`.
+ * @returns {object|null} the new board, or null when MAX_BOARDS is reached
+ */
+export function addBoard(circuit) {
+ if (circuit.boards.length >= MAX_BOARDS) return null;
+ const bottom = circuit.boards.reduce(
+ (max, b) => Math.max(max, b.y + BOARD_HEIGHT), -BOARD_STACK_GAP
+ );
+ const board = { uid: nextUid(circuit, 'b'), x: 0, y: bottom + BOARD_STACK_GAP };
+ circuit.boards.push(board);
+ return board;
+}
+
+/**
+ * Remove a board and everything anchored to or wired into it. Mutates `circuit`.
+ * Refuses to remove the last remaining board.
+ * @returns {boolean} whether the board was removed
+ */
+export function removeBoard(circuit, uid) {
+ if (circuit.boards.length <= 1) return false;
+ const index = circuit.boards.findIndex(b => b.uid === uid);
+ if (index === -1) return false;
+
+ circuit.boards.splice(index, 1);
+ circuit.wires = circuit.wires.filter(w => !wireTouchesBoard(w, uid));
+ circuit.components = circuit.components.filter(c => !componentTouchesBoard(c, uid));
+ return true;
+}
+
+/** True when either end of a wire sits on the given board. */
+export function wireTouchesBoard(wire, boardUid) {
+ return (wire.from && wire.from.board === boardUid) || (wire.to && wire.to.board === boardUid);
+}
+
+/** True when any part of a component sits on the given board. */
+export function componentTouchesBoard(component, boardUid) {
+ if (component.anchor && component.anchor.board === boardUid) return true;
+ if (component.props && component.props.board === boardUid) return true;
+ if (component.props && component.props.to && component.props.to.board === boardUid) return true;
+ return componentPinHoles(component).some(p => p.hole !== null && p.hole.board === boardUid);
+}
+
+/**
+ * A component of the given type with registry defaults applied. Does not add it to
+ * the circuit.
+ *
+ * Anchorless types ignore `anchor` and take their placement from `extraProps`
+ * instead - a powerSupply5V needs `{ board, side }` there or it will not validate.
+ * @param {object} circuit used only to allocate a uid
+ * @param {string} type
+ * @param {object|null} anchor hole reference, ignored for anchorless types
+ * @param {object} [extraProps] merged over the type defaults
+ */
+export function createComponent(circuit, type, anchor, extraProps) {
+ const def = getComponentDef(type);
+ if (def === null) return null;
+ const component = {
+ uid: nextUid(circuit, 'c'),
+ type,
+ props: Object.assign(defaultPropsFor(type), extraProps || {})
+ };
+ // The server-side validator whitelists keys strictly, so an anchorless type omits
+ // `anchor` entirely rather than carrying an explicit null.
+ if (!def.anchorless) component.anchor = anchor;
+ if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
+ return component;
+}
+
+/** A wire between two holes, with a uid allocated from the circuit. */
+export function createWire(circuit, from, to, color) {
+ return {
+ uid: nextUid(circuit, 'w'),
+ from,
+ to,
+ color: HEX_COLOR.test(color) ? color : DEFAULT_WIRE_COLOR
+ };
+}
+
+// --- Normalization ---
+
+function normalizeBoardCoord(value, fallback) {
+ if (!Number.isFinite(value)) return fallback;
+ return Math.max(-MAX_BOARD_COORD, Math.min(MAX_BOARD_COORD, value));
+}
+
+/**
+ * Coerce anything - a parsed JSONB blob, `{}`, null, a JSON string - into a usable
+ * circuit, reporting everything that could not be kept.
+ *
+ * Nothing is discarded silently. Entries with a broken uid are RENAMED rather than
+ * dropped, and a board rename is propagated to every hole reference that pointed at
+ * it, because dropping a board would cascade into deleting all of its components and
+ * wires.
+ *
+ * @param {*} raw
+ * @returns {{circuit: object, problems: Array<{kind:string, index:number, uid:string|null, reason:string}>}}
+ */
+export function normalizeCircuitWithReport(raw) {
+ const problems = [];
+ const note = (kind, index, uid, reason) => problems.push({ kind, index, uid, reason });
+
+ let source = raw;
+ if (typeof source === 'string') {
+ try {
+ source = JSON.parse(source);
+ } catch {
+ note('circuit', -1, null, 'the saved document was not valid JSON');
+ source = null;
+ }
+ }
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
+ return { circuit: createCircuit(), problems };
+ }
+
+ // --- boards, with a rename map so hole references follow their board ---
+ const rawBoards = Array.isArray(source.boards) ? source.boards : [];
+ const allocator = createUidAllocator();
+ const renames = new Map();
+ const boards = [];
+
+ for (let i = 0; i < rawBoards.length && boards.length < MAX_BOARDS; i++) {
+ const rawBoard = rawBoards[i];
+ if (!rawBoard || typeof rawBoard !== 'object') {
+ note('board', i, null, 'entry was not an object');
+ continue;
+ }
+ const original = typeof rawBoard.uid === 'string' ? rawBoard.uid : null;
+ // The FIRST board claiming a name keeps it, so references stay unambiguous.
+ const uid = allocator.claim(original, 'b');
+ if (original === null) {
+ note('board', i, null, `a board had no id and was given "${uid}"`);
+ } else if (original !== uid) {
+ // Reported even when the name was already remapped: this is the duplicate
+ // case, where every reference to it attaches to the FIRST board and this
+ // one is left empty. Silent data movement is what the report exists for.
+ note('board', i, original, renames.has(original)
+ ? `a second board also called "${original}" was renamed to "${uid}"; anything referring to "${original}" stayed with the first one`
+ : `board "${original}" was renamed to "${uid}" (unsupported characters)`);
+ }
+ if (original !== null && !renames.has(original)) renames.set(original, uid);
+ boards.push({
+ uid,
+ x: normalizeBoardCoord(Number(rawBoard.x), 0),
+ y: normalizeBoardCoord(Number(rawBoard.y), i * (BOARD_HEIGHT + BOARD_STACK_GAP))
+ });
+ }
+ if (rawBoards.length > MAX_BOARDS) {
+ note('board', -1, null, `only the first ${MAX_BOARDS} boards were kept`);
+ }
+ if (boards.length === 0) boards.push({ uid: allocator.claim('b1', 'b'), x: 0, y: 0 });
+
+ const boardUids = new Set(boards.map(b => b.uid));
+
+ /** Resolve a raw hole reference, following any board rename. */
+ const normalizeHole = (rawHole) => {
+ if (!rawHole || typeof rawHole !== 'object') return null;
+ const board = renames.has(rawHole.board) ? renames.get(rawHole.board) : rawHole.board;
+ const hole = rawHole.kind === 'rail'
+ ? { board, kind: 'rail', rail: rawHole.rail, index: Number(rawHole.index) }
+ : { board, kind: 'main', col: Number(rawHole.col), row: rawHole.row };
+ if (!isValidHole(hole) || !boardUids.has(hole.board)) return null;
+ return hole;
+ };
+
+ // --- components ---
+ const rawComponents = Array.isArray(source.components) ? source.components : [];
+ const components = [];
+ const suppliedRailPairs = new Set();
+ for (let i = 0; i < rawComponents.length; i++) {
+ if (components.length >= MAX_COMPONENTS) {
+ note('component', -1, null, `only the first ${MAX_COMPONENTS} components were kept`);
+ break;
+ }
+ const rawComponent = rawComponents[i];
+ const label = rawComponent && typeof rawComponent.uid === 'string' ? rawComponent.uid : null;
+
+ if (!rawComponent || typeof rawComponent !== 'object') {
+ note('component', i, null, 'entry was not an object');
+ continue;
+ }
+ if (!isKnownType(rawComponent.type)) {
+ note('component', i, label, `unknown component type "${String(rawComponent.type)}"`);
+ continue;
+ }
+ const def = getComponentDef(rawComponent.type);
+ const component = {
+ uid: allocator.claim(label, 'c'),
+ type: rawComponent.type,
+ props: defaultPropsFor(rawComponent.type)
+ };
+ if (label !== null && label !== component.uid) {
+ note('component', i, label, `renamed to "${component.uid}" (duplicate or unsupported characters)`);
+ }
+
+ if (!def.anchorless) {
+ const anchor = normalizeHole(rawComponent.anchor);
+ if (anchor === null) {
+ note('component', i, label, `${def.label} was not on a board that still exists`);
+ continue;
+ }
+ component.anchor = anchor;
+ }
+
+ const rawProps = rawComponent.props && typeof rawComponent.props === 'object'
+ ? rawComponent.props : {};
+ for (const [key, spec] of Object.entries(def.propSpecs)) {
+ const value = rawProps[key];
+ if (spec.kind === 'enum' && spec.values.indexOf(value) !== -1) {
+ component.props[key] = value;
+ } else if (spec.kind === 'number' && Number.isFinite(Number(value))) {
+ component.props[key] = Math.min(spec.max, Math.max(spec.min, Number(value)));
+ } else if (spec.kind === 'boolArray') {
+ const rawList = Array.isArray(value) ? value : [];
+ component.props[key] = Array.from({ length: spec.length }, (_, slot) => rawList[slot] === true);
+ }
+ }
+
+ if (rawComponent.type === 'resistor') {
+ const to = normalizeHole(rawProps.to);
+ if (to === null) {
+ note('component', i, label, 'resistor had no valid second terminal');
+ continue;
+ }
+ component.props.to = to;
+ }
+
+ if (rawComponent.type === 'powerSupply5V') {
+ const board = renames.has(rawProps.board) ? renames.get(rawProps.board) : rawProps.board;
+ if (typeof board !== 'string' || !boardUids.has(board)) {
+ note('component', i, label, '5V supply was not on a board that still exists');
+ continue;
+ }
+ component.props.board = board;
+ // Two supplies on one rail pair would be spurious contention, and validate
+ // rejects it - so normalize must not emit it either.
+ const pair = `${board}|${component.props.side}`;
+ if (suppliedRailPairs.has(pair)) {
+ note('component', i, label,
+ `a second 5V supply on the ${component.props.side} rails of board "${board}" was removed`);
+ continue;
+ }
+ suppliedRailPairs.add(pair);
+ }
+
+ if (def.orientable) component.orient = orientFor(def, component.anchor, rawComponent.orient);
+
+ // Enforced here so normalize's output always passes validate: a component with
+ // a pin hanging off the end of the board is rejected by the server.
+ if (!isFullyPlaced(component)) {
+ note('component', i, label, `${def.label} did not fit on the board`);
+ continue;
+ }
+ components.push(component);
+ }
+
+ // --- wires ---
+ const rawWires = Array.isArray(source.wires) ? source.wires : [];
+ const wires = [];
+ for (let i = 0; i < rawWires.length; i++) {
+ if (wires.length >= MAX_WIRES) {
+ note('wire', -1, null, `only the first ${MAX_WIRES} wires were kept`);
+ break;
+ }
+ const rawWire = rawWires[i];
+ const label = rawWire && typeof rawWire.uid === 'string' ? rawWire.uid : null;
+ if (!rawWire || typeof rawWire !== 'object') {
+ note('wire', i, null, 'entry was not an object');
+ continue;
+ }
+ const from = normalizeHole(rawWire.from);
+ const to = normalizeHole(rawWire.to);
+ if (from === null || to === null) {
+ note('wire', i, label, 'wire did not connect two holes that still exist');
+ continue;
+ }
+ if (holeKey(from) === holeKey(to)) {
+ note('wire', i, label, 'wire had both ends in the same hole');
+ continue;
+ }
+ const uid = allocator.claim(label, 'w');
+ if (label !== null && label !== uid) {
+ note('wire', i, label, `renamed to "${uid}" (duplicate or unsupported characters)`);
+ }
+ wires.push({
+ uid,
+ from,
+ to,
+ color: typeof rawWire.color === 'string' && HEX_COLOR.test(rawWire.color)
+ ? rawWire.color : DEFAULT_WIRE_COLOR
+ });
+ }
+
+ return { circuit: { version: CIRCUIT_VERSION, boards, components, wires }, problems };
+}
+
+/**
+ * normalizeCircuitWithReport, discarding the report. Prefer the reporting form
+ * anywhere the user should be told what happened - notably on load.
+ * @returns {object} a circuit that passes validateCircuit()
+ */
+export function normalizeCircuit(raw) {
+ return normalizeCircuitWithReport(raw).circuit;
+}
+
+/**
+ * The exact payload to PUT to the server.
+ *
+ * The server-side validator whitelists keys strictly at every level and rejects any
+ * unknown property, so this rebuilds the document from scratch with only the
+ * permitted keys rather than trusting whatever the editor has been mutating. In
+ * particular there is NO slot for editor state - viewport, zoom, selection and tool
+ * are deliberately kept out of the document and persisted separately.
+ * @returns {object}
+ */
+export function serializeCircuit(circuit) {
+ return normalizeCircuit(circuit);
+}
+
+/** UTF-8 byte length of the serialized document, for the pre-save size check. */
+export function circuitByteSize(circuit) {
+ const json = JSON.stringify(circuit);
+ if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(json).length;
+ let bytes = 0;
+ for (let i = 0; i < json.length; i++) {
+ const code = json.codePointAt(i);
+ if (code > 0xffff) { bytes += 4; i++; } else if (code > 0x7ff) { bytes += 3; }
+ else if (code > 0x7f) { bytes += 2; } else { bytes += 1; }
+ }
+ return bytes;
+}
+
+// --- Validation ---
+
+function checkUid(uid, label, seen, errors) {
+ if (typeof uid !== 'string' || uid.length === 0) {
+ errors.push(`${label}: uid must be a non-empty string`);
+ return false;
+ }
+ if (!UID_PATTERN.test(uid)) {
+ errors.push(`${label}: uid must be 1-${MAX_UID_LENGTH} characters from A-Z a-z 0-9 _ . : -`);
+ return false;
+ }
+ if (seen.has(uid)) {
+ errors.push(`${label}: duplicate uid "${uid}"`);
+ return false;
+ }
+ seen.add(uid);
+ return true;
+}
+
+function checkHole(hole, boardUids, label, errors) {
+ if (!isValidHole(hole)) {
+ errors.push(`${label}: invalid hole reference`);
+ return false;
+ }
+ if (!boardUids.has(hole.board)) {
+ errors.push(`${label}: references unknown board "${hole.board}"`);
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Full structural validation of a circuit document.
+ * @param {*} circuit
+ * @returns {{ok: boolean, errors: string[]}}
+ */
+export function validateCircuit(circuit) {
+ const errors = [];
+ if (!circuit || typeof circuit !== 'object' || Array.isArray(circuit)) {
+ return { ok: false, errors: ['circuit must be an object'] };
+ }
+ if (circuit.version !== CIRCUIT_VERSION) {
+ errors.push(`version must be ${CIRCUIT_VERSION}`);
+ }
+ if (!Array.isArray(circuit.boards) || circuit.boards.length === 0) {
+ return { ok: false, errors: errors.concat('boards must be a non-empty array') };
+ }
+ if (!Array.isArray(circuit.components) || !Array.isArray(circuit.wires)) {
+ if (!Array.isArray(circuit.components)) errors.push('components must be an array');
+ if (!Array.isArray(circuit.wires)) errors.push('wires must be an array');
+ return { ok: false, errors };
+ }
+
+ if (circuit.boards.length > MAX_BOARDS) errors.push(`boards: at most ${MAX_BOARDS} allowed`);
+ if (circuit.components.length > MAX_COMPONENTS) errors.push(`components: at most ${MAX_COMPONENTS} allowed`);
+ if (circuit.wires.length > MAX_WIRES) errors.push(`wires: at most ${MAX_WIRES} allowed`);
+
+ const seen = new Set();
+ const boardUids = new Set();
+ const suppliedRailPairs = new Set();
+ circuit.boards.forEach((board, i) => {
+ const label = `boards[${i}]`;
+ if (!board || typeof board !== 'object') {
+ errors.push(`${label}: must be an object`);
+ return;
+ }
+ if (!checkUid(board.uid, label, seen, errors)) return;
+ boardUids.add(board.uid);
+ if (!Number.isFinite(board.x) || !Number.isFinite(board.y)) {
+ errors.push(`${label}: x and y must be finite numbers`);
+ } else if (Math.abs(board.x) > MAX_BOARD_COORD || Math.abs(board.y) > MAX_BOARD_COORD) {
+ errors.push(`${label}: x and y must be within +/-${MAX_BOARD_COORD}`);
+ }
+ });
+
+ circuit.components.forEach((component, i) => {
+ const label = `components[${i}]`;
+ if (!component || typeof component !== 'object') {
+ errors.push(`${label}: must be an object`);
+ return;
+ }
+ if (!checkUid(component.uid, label, seen, errors)) return;
+
+ const def = getComponentDef(component.type);
+ if (def === null) {
+ errors.push(`${label}: unknown component type "${String(component.type)}"`);
+ return;
+ }
+
+ if (def.anchorless) {
+ if (component.anchor !== null && component.anchor !== undefined) {
+ errors.push(`${label}: ${def.type} must not have an anchor`);
+ }
+ } else if (!checkHole(component.anchor, boardUids, `${label}.anchor`, errors)) {
+ return;
+ } else {
+ if (def.anchorKinds && def.anchorKinds.indexOf(component.anchor.kind) === -1) {
+ errors.push(`${label}: ${def.type} cannot be anchored to a ${component.anchor.kind} hole`);
+ }
+ if (def.anchorRows && component.anchor.kind === 'main'
+ && def.anchorRows.indexOf(component.anchor.row) === -1) {
+ errors.push(`${label}: ${def.type} must be anchored on row ${def.anchorRows.join(' or ')}`);
+ }
+ }
+
+ if (def.orientable) {
+ if (def.orientValues.indexOf(component.orient) === -1) {
+ errors.push(`${label}: orient must be one of ${def.orientValues.join(', ')}`);
+ } else if (def.dipStyle && component.anchor && component.anchor.kind === 'main'
+ && component.orient !== dipOrientForRow(component.anchor.row)) {
+ // A DIP package's rotation is carried by its anchor row; a conflicting
+ // orient would make the document self-contradictory.
+ errors.push(`${label}: orient "${component.orient}" contradicts anchor row "${component.anchor.row}"`);
+ }
+ } else if (component.orient !== undefined) {
+ errors.push(`${label}: ${def.type} must not have an orient`);
+ }
+
+ for (const message of validateProps(component.type, component.props)) {
+ errors.push(`${label}: ${message}`);
+ }
+
+ const props = component.props || {};
+ if (component.type === 'resistor') {
+ checkHole(props.to, boardUids, `${label}.props.to`, errors);
+ }
+ if (component.type === 'powerSupply5V') {
+ if (!boardUids.has(props.board)) {
+ errors.push(`${label}: props.board references unknown board "${String(props.board)}"`);
+ } else {
+ // Two supplies on one rail pair is a contradiction the engine would
+ // have to resolve as spurious contention.
+ const pair = `${props.board}|${props.side}`;
+ if (suppliedRailPairs.has(pair)) {
+ errors.push(`${label}: the ${props.side} rail pair of board "${props.board}" already has a 5V supply`);
+ }
+ suppliedRailPairs.add(pair);
+ }
+ }
+
+ const unplaced = componentPinHoles(component).filter(p => p.hole === null).map(p => p.pin);
+ if (unplaced.length === 1) {
+ errors.push(`${label}: pin ${unplaced[0]} falls off the board`);
+ } else if (unplaced.length > 1) {
+ errors.push(`${label}: pins ${unplaced.join(', ')} fall off the board`);
+ }
+ });
+
+ circuit.wires.forEach((wire, i) => {
+ const label = `wires[${i}]`;
+ if (!wire || typeof wire !== 'object') {
+ errors.push(`${label}: must be an object`);
+ return;
+ }
+ if (!checkUid(wire.uid, label, seen, errors)) return;
+ const fromOk = checkHole(wire.from, boardUids, `${label}.from`, errors);
+ const toOk = checkHole(wire.to, boardUids, `${label}.to`, errors);
+ if (fromOk && toOk && holeKey(wire.from) === holeKey(wire.to)) {
+ errors.push(`${label}: both ends are the same hole`);
+ }
+ if (typeof wire.color !== 'string' || !HEX_COLOR.test(wire.color)) {
+ errors.push(`${label}: color must be a #rrggbb string`);
+ }
+ });
+
+ return { ok: errors.length === 0, errors };
+}
+
+/**
+ * World-space bounding box covering every board, used to frame the initial view.
+ * @returns {{x:number,y:number,w:number,h:number}}
+ */
+export function circuitBounds(circuit) {
+ if (circuit.boards.length === 0) {
+ return { x: 0, y: 0, w: BOARD_WIDTH, h: BOARD_HEIGHT };
+ }
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const board of circuit.boards) {
+ minX = Math.min(minX, board.x);
+ minY = Math.min(minY, board.y);
+ maxX = Math.max(maxX, board.x + BOARD_WIDTH);
+ maxY = Math.max(maxY, board.y + BOARD_HEIGHT);
+ }
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
+}
+
+export { componentPinHoles };
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-pins.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-pins.js
new file mode 100644
index 0000000..267a6fa
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-pins.js
@@ -0,0 +1,239 @@
+// Pin-to-hole mapping for every component type.
+//
+// PURE MODULE. No DOM, no globals. Imported by the editor, by the engine inside a
+// module Worker, and mirrored structurally by the C# validator - so this file is the
+// single source of truth for where a component's pins physically land.
+//
+// componentPinHoles() always returns one entry per pin of the type, in pin order,
+// with array index 0 being pin 1. A pin whose hole would fall off the board comes
+// back as `hole: null` - an unplaced pin, never an exception.
+
+import {
+ MAIN_ROWS,
+ mainHole,
+ railHole,
+ offsetHole,
+ isValidHole,
+ sameStrip
+} from './board-geometry.js';
+
+import { getComponentDef, isChipType } from './component-registry.js';
+
+const DIP8_PIN_COUNT = 16;
+const DIP8_PINS_PER_SIDE = 8;
+
+function entry(pin, hole) {
+ return { pin, hole: hole === undefined ? null : hole };
+}
+
+function nullPins(count) {
+ const pins = [];
+ for (let i = 1; i <= count; i++) pins.push(entry(i, null));
+ return pins;
+}
+
+function isMainAnchor(anchor) {
+ return isValidHole(anchor) && anchor.kind === 'main';
+}
+
+/** Row directly across the center channel from `row`, or null when there isn't one. */
+function acrossChannel(row) {
+ const i = MAIN_ROWS.indexOf(row);
+ if (i === -1) return null;
+ return row === 'e' ? 'f' : (row === 'f' ? 'e' : null);
+}
+
+// --- Per-type mappings ---
+
+function ledPins(component) {
+ const anchor = component.anchor;
+ if (!isValidHole(anchor)) return nullPins(2);
+ // The default lives in the registry so it cannot drift from what the schema writes.
+ const dir = component.orient || getComponentDef('led').defaultOrient;
+ return [entry(1, anchor), entry(2, offsetHole(anchor, dir, 1))];
+}
+
+function resistorPins(component) {
+ const anchor = isValidHole(component.anchor) ? component.anchor : null;
+ const to = component.props && isValidHole(component.props.to) ? component.props.to : null;
+ return [entry(1, anchor), entry(2, to)];
+}
+
+/**
+ * DIP-style package straddling the center channel, anchored at pin 1.
+ *
+ * The offsets come from `def.footprint.pins` in the registry rather than being
+ * recomputed here, so the published footprint table and this resolver cannot drift.
+ * Each entry gives a column offset `dCol` in the package's reading direction and a
+ * `side` of the channel.
+ *
+ * Anchoring on row 'e' reads left to right (notch at the left); anchoring on row 'f'
+ * is the identical package rotated 180 degrees, which is why only the sign of the
+ * column step changes - the pin numbering never does.
+ */
+function dipFootprint(component, footprint) {
+ const anchor = component.anchor;
+ if (!isMainAnchor(anchor)) return nullPins(footprint.pinCount);
+ const farRow = acrossChannel(anchor.row);
+ if (farRow === null) return nullPins(footprint.pinCount);
+
+ const { board, col, row } = anchor;
+ const step = row === 'e' ? 1 : -1;
+ return footprint.pins.map(spec => entry(
+ spec.pin,
+ mainHole(board, col + step * spec.dCol, spec.side === 'anchor' ? row : farRow)
+ ));
+}
+
+/** Two-pin part whose second pin is one step from the anchor in `orient`. */
+function orientStepFootprint(component, footprint, def) {
+ const anchor = component.anchor;
+ if (!isValidHole(anchor)) return nullPins(footprint.pinCount);
+ // The default lives in the registry so it cannot drift from what the schema writes.
+ const dir = component.orient || def.defaultOrient;
+ return footprint.pins.map(spec => entry(
+ spec.pin,
+ spec.at === 'anchor' ? anchor : offsetHole(anchor, dir, spec.steps)
+ ));
+}
+
+/** Two-pin part whose second pin is a free hole reference carried in props. */
+function endpointsFootprint(component, footprint) {
+ const props = component.props || {};
+ return footprint.pins.map(spec => {
+ const hole = spec.at === 'anchor' ? component.anchor : props[spec.prop];
+ return entry(spec.pin, isValidHole(hole) ? hole : null);
+ });
+}
+
+/**
+ * Anchorless part that clamps onto a rail pair. Returning real hole references keeps
+ * it uniform with every other component, so consumers need no special case.
+ */
+function railPairFootprint(component, footprint) {
+ const props = component.props || {};
+ const board = props.board;
+ const side = props.side;
+ if (typeof board !== 'string' || board.length === 0) return nullPins(footprint.pinCount);
+ // Validation is the single authority on `side`. Silently coercing an unrecognized
+ // value to 'top' here would make the pin map disagree with the validator, and the
+ // engine follows the pin map.
+ if (side !== 'top' && side !== 'bottom') return nullPins(footprint.pinCount);
+ const prefix = side === 'top' ? 'top' : 'bottom';
+ return footprint.pins.map(spec => entry(
+ spec.pin,
+ railHole(board, prefix + (spec.polarity === 'plus' ? 'Plus' : 'Minus'), spec.index)
+ ));
+}
+
+/**
+ * Where each pin of a component physically lands.
+ *
+ * Dispatches on the registry's declarative footprint, so adding a component type is a
+ * registry edit unless it needs a genuinely new footprint kind.
+ * @param {object} component a circuit component: { type, anchor, orient?, props? }
+ * @returns {Array<{pin:number, hole:object|null}>} index 0 is pin 1; [] for an
+ * unknown type. `hole` is null for a pin that falls off the board.
+ */
+export function componentPinHoles(component) {
+ if (!component || typeof component !== 'object') return [];
+ const def = getComponentDef(component.type);
+ if (def === null) return [];
+ const footprint = def.footprint;
+ if (!footprint) return nullPins(def.pins.length);
+
+ switch (footprint.kind) {
+ case 'dip': return dipFootprint(component, footprint);
+ case 'orientStep': return orientStepFootprint(component, footprint, def);
+ case 'endpoints': return endpointsFootprint(component, footprint);
+ case 'railPair': return railPairFootprint(component, footprint);
+ default: return nullPins(footprint.pinCount);
+ }
+}
+
+/**
+ * Pin holes annotated with the pin names from the registry.
+ * @returns {Array<{pin:number, name:string, hole:object|null}>}
+ */
+export function componentPinsWithNames(component) {
+ const def = getComponentDef(component && component.type);
+ const holes = componentPinHoles(component);
+ return holes.map((h, i) => ({
+ pin: h.pin,
+ name: def && def.pins[i] ? def.pins[i].name : String(h.pin),
+ hole: h.hole
+ }));
+}
+
+/**
+ * Every hole a component occupies, skipping unplaced pins.
+ * @returns {object[]}
+ */
+export function componentHoles(component) {
+ return componentPinHoles(component)
+ .map(p => p.hole)
+ .filter(h => h !== null);
+}
+
+/**
+ * True when every pin of the component landed on a real hole.
+ * @returns {boolean}
+ */
+export function isFullyPlaced(component) {
+ const pins = componentPinHoles(component);
+ return pins.length > 0 && pins.every(p => p.hole !== null);
+}
+
+/**
+ * Pins that land in the same electrical strip as another pin of the same component.
+ *
+ * Structurally legal but almost always a mistake - an LED with both legs in one
+ * terminal strip can never light, because both ends sit on the same net. The editor
+ * warns on it and the engine can use it to explain a dead component. It is
+ * deliberately NOT a validation error.
+ * @returns {Array<[number, number]>} pairs of pin numbers sharing a strip
+ */
+export function componentSelfShorts(component) {
+ const pins = componentPinHoles(component).filter(p => p.hole !== null);
+ const bonded = new Set(
+ staticPinBonds(component).map(([a, b]) => a < b ? `${a}:${b}` : `${b}:${a}`)
+ );
+ const shorts = [];
+ for (let i = 0; i < pins.length; i++) {
+ for (let j = i + 1; j < pins.length; j++) {
+ const key = `${pins[i].pin}:${pins[j].pin}`;
+ if (bonded.has(key)) continue; // an intentional internal tie, not a fault
+ if (sameStrip(pins[i].hole, pins[j].hole)) shorts.push([pins[i].pin, pins[j].pin]);
+ }
+ }
+ return shorts;
+}
+
+/**
+ * Pin pairs a component ties together unconditionally, regardless of simulation
+ * state. The engine may safely union these at load time.
+ * @returns {Array<[number, number]>} pairs of pin numbers
+ */
+export function staticPinBonds(component) {
+ if (!component || component.type !== 'pushButton') return [];
+ return [[1, 2], [3, 4]];
+}
+
+/**
+ * Pin pairs a switch closes when it is on. Not for the engine's signal model - it is
+ * here so the editor can draw switch state consistently with the engine.
+ * @returns {Array<{control:number, pins:[number, number]}>} `control` is the switch
+ * number the user toggles (1..8 for a DIP, 1 for a push button)
+ */
+export function switchablePinBonds(component) {
+ if (!component) return [];
+ if (component.type === 'pushButton') return [{ control: 1, pins: [1, 3] }];
+ if (component.type === 'dipSwitch8') {
+ const bonds = [];
+ for (let k = 1; k <= DIP8_PINS_PER_SIDE; k++) {
+ bonds.push({ control: k, pins: [k, DIP8_PIN_COUNT + 1 - k] });
+ }
+ return bonds;
+ }
+ return [];
+}
diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js
new file mode 100644
index 0000000..b2cc569
--- /dev/null
+++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/shared/component-registry.js
@@ -0,0 +1,391 @@
+// Component type registry: metadata shared by the editor palette, the schema
+// validator and the engine.
+//
+// PURE MODULE. No DOM, no globals, no dependencies. Runs in a module Worker and in
+// bare node. Physical facts only - nothing theme-dependent lives here except LED
+// colors, which are a property of the part rather than of the UI.
+//
+// Milestone 1 registry. New types bolt on by adding an entry; nothing else in the
+// codebase enumerates types.
+
+/** DIP logic chips available in milestone 1. All 14-pin. */
+export const CHIP_TYPES = Object.freeze([
+ '74HC00', '74HC02', '74HC04', '74HC08', '74HC32', '74HC86', '74HC30'
+]);
+
+const CHIP_LABELS = Object.assign(Object.create(null), {
+ '74HC00': 'Quad 2-input NAND',
+ '74HC02': 'Quad 2-input NOR',
+ '74HC04': 'Hex inverter',
+ '74HC08': 'Quad 2-input AND',
+ '74HC32': 'Quad 2-input OR',
+ '74HC86': 'Quad 2-input XOR',
+ '74HC30': '8-input NAND'
+});
+Object.freeze(CHIP_LABELS);
+
+/** Selectable LED colors. `hex` is the physical lens color, not a theme color. */
+export const LED_COLORS = Object.freeze([
+ Object.freeze({ value: 'red', label: 'Red', hex: '#ff3b30' }),
+ Object.freeze({ value: 'green', label: 'Green', hex: '#34c759' }),
+ Object.freeze({ value: 'blue', label: 'Blue', hex: '#4a90ff' }),
+ Object.freeze({ value: 'yellow', label: 'Yellow', hex: '#ffd60a' }),
+ Object.freeze({ value: 'orange', label: 'Orange', hex: '#ff9f0a' }),
+ Object.freeze({ value: 'white', label: 'White', hex: '#f2f2f7' })
+]);
+
+const LED_COLOR_VALUES = Object.freeze(LED_COLORS.map(c => c.value));
+
+/** Minimum / maximum resistance a resistor may be given, in ohms. */
+export const RESISTOR_MIN_OHMS = 1;
+export const RESISTOR_MAX_OHMS = 10000000;
+
+/** Common resistor values offered in the property editor. */
+export const RESISTOR_PRESETS = Object.freeze([100, 220, 330, 470, 1000, 2200, 4700, 10000, 100000]);
+
+function numberedPins(count) {
+ const pins = [];
+ for (let i = 1; i <= count; i++) pins.push(Object.freeze({ pin: i, name: String(i) }));
+ return Object.freeze(pins);
+}
+
+function namedPins(names) {
+ return Object.freeze(names.map((name, i) => Object.freeze({ pin: i + 1, name })));
+}
+
+/**
+ * Orientations a DIP-style package may carry. The package's real rotation is derived
+ * from which side of the channel its anchor sits on (row 'e' reads left-to-right,
+ * row 'f' is the same package turned 180 degrees), and `orient` is kept consistent
+ * with that so the document never contradicts itself.
+ */
+export const DIP_ORIENTATIONS = Object.freeze(['right', 'left']);
+
+/** The orient value implied by a DIP-style package's anchor row. */
+export function dipOrientForRow(row) {
+ return row === 'f' ? 'left' : 'right';
+}
+
+/** The anchor row implied by a DIP-style package's orient. */
+export function dipRowForOrient(orient) {
+ return orient === 'left' ? 'f' : 'e';
+}
+
+/**
+ * Pin offsets for a standard DIP package straddling the center channel.
+ *
+ * Declarative on purpose: this table IS the footprint contract, mirrored as data by
+ * the C# validator. Resolving it is component-pins' job, so the rule can never drift
+ * between the description and the implementation.
+ *
+ * `dCol` is a column offset from the anchor, applied in the package's reading
+ * direction; `side` says which side of the channel the pin sits on.
+ */
+function dipPinOffsets(count) {
+ const half = count / 2;
+ const pins = [];
+ for (let pin = 1; pin <= half; pin++) {
+ pins.push(Object.freeze({ pin, dCol: pin - 1, side: 'anchor' }));
+ }
+ for (let pin = half + 1; pin <= count; pin++) {
+ pins.push(Object.freeze({ pin, dCol: count - pin, side: 'far' }));
+ }
+ return Object.freeze(pins);
+}
+
+function chipDef(type) {
+ return {
+ type,
+ label: type,
+ description: CHIP_LABELS[type],
+ category: 'chip',
+ pins: numberedPins(14),
+ // A 14-pin DIP straddles the center channel; its body covers 7 columns.
+ bodyColumns: 7,
+ straddlesGap: true,
+ anchorRows: Object.freeze(['e', 'f']),
+ anchorKinds: Object.freeze(['main']),
+ anchorless: false,
+ orientable: true,
+ orientValues: DIP_ORIENTATIONS,
+ defaultOrient: 'right',
+ dipStyle: true,
+ footprint: Object.freeze({ kind: 'dip', pinCount: 14, pins: dipPinOffsets(14) }),
+ defaultProps: Object.freeze({}),
+ propSpecs: Object.freeze({})
+ };
+}
+
+const DEFS = Object.create(null);
+
+function define(def) {
+ DEFS[def.type] = Object.freeze(def);
+}
+
+define({
+ type: 'led',
+ label: 'LED',
+ description: 'Light emitting diode. Anchor is the anode; the cathode sits one hole away.',
+ category: 'output',
+ pins: namedPins(['anode', 'cathode']),
+ bodyColumns: 1,
+ straddlesGap: false,
+ anchorRows: null, // any row
+ anchorKinds: Object.freeze(['main', 'rail']),
+ anchorless: false,
+ orientable: true,
+ orientValues: Object.freeze(['up', 'down', 'left', 'right']),
+ // 'right' is the only default that reaches a different strip from every main
+ // column and also works from a rail hole. 'down' would land in the SAME 5-hole
+ // terminal strip from 8 of 10 rows, giving a shorted LED that can never light.
+ defaultOrient: 'right',
+ dipStyle: false,
+ footprint: Object.freeze({
+ kind: 'orientStep',
+ pinCount: 2,
+ pins: Object.freeze([
+ Object.freeze({ pin: 1, at: 'anchor' }),
+ Object.freeze({ pin: 2, at: 'orientStep', steps: 1 })
+ ])
+ }),
+ defaultProps: Object.freeze({ color: 'red' }),
+ propSpecs: Object.freeze({
+ color: Object.freeze({ kind: 'enum', values: LED_COLOR_VALUES, default: 'red', label: 'Color' })
+ })
+});
+
+define({
+ type: 'resistor',
+ label: 'Resistor',
+ description: 'Two-terminal resistor. The second terminal is a free hole reference, so it may span boards or reach a rail.',
+ category: 'passive',
+ pins: namedPins(['p1', 'p2']),
+ bodyColumns: 1,
+ straddlesGap: false,
+ anchorRows: null,
+ anchorKinds: Object.freeze(['main', 'rail']),
+ anchorless: false,
+ orientable: false,
+ footprint: Object.freeze({
+ kind: 'endpoints',
+ pinCount: 2,
+ pins: Object.freeze([
+ Object.freeze({ pin: 1, at: 'anchor' }),
+ Object.freeze({ pin: 2, at: 'prop', prop: 'to' })
+ ])
+ }),
+ // `to` is a hole reference rather than a scalar, so it is not in propSpecs -
+ // circuit-schema validates it structurally.
+ defaultProps: Object.freeze({ ohms: 220 }),
+ propSpecs: Object.freeze({
+ ohms: Object.freeze({
+ kind: 'number', min: RESISTOR_MIN_OHMS, max: RESISTOR_MAX_OHMS,
+ integer: false, default: 220, label: 'Resistance', unit: 'Ω',
+ presets: RESISTOR_PRESETS
+ })
+ })
+});
+
+define({
+ type: 'pushButton',
+ label: 'Push button',
+ description: 'Momentary tactile switch straddling the center channel. Its two same-side pins are permanently tied; pressing bridges the two sides.',
+ category: 'input',
+ pins: namedPins(['a1', 'a2', 'b1', 'b2']),
+ bodyColumns: 3,
+ straddlesGap: true,
+ anchorRows: Object.freeze(['e', 'f']),
+ anchorKinds: Object.freeze(['main']),
+ anchorless: false,
+ orientable: true,
+ orientValues: DIP_ORIENTATIONS,
+ defaultOrient: 'right',
+ dipStyle: true,
+ // Pins 1/2 are tied inside the package, as are 3/4; pressing bridges the pairs.
+ // The tied pins sit two columns apart so each tie bonds two separate strips.
+ footprint: Object.freeze({
+ kind: 'dip',
+ pinCount: 4,
+ pins: Object.freeze([
+ Object.freeze({ pin: 1, dCol: 0, side: 'anchor' }),
+ Object.freeze({ pin: 2, dCol: 2, side: 'anchor' }),
+ Object.freeze({ pin: 3, dCol: 0, side: 'far' }),
+ Object.freeze({ pin: 4, dCol: 2, side: 'far' })
+ ])
+ }),
+ defaultProps: Object.freeze({}),
+ propSpecs: Object.freeze({})
+});
+
+define({
+ type: 'dipSwitch8',
+ label: 'DIP switch (8)',
+ description: 'Eight independent switches straddling the center channel; switch k bridges the gap in its own column.',
+ category: 'input',
+ pins: numberedPins(16),
+ bodyColumns: 8,
+ straddlesGap: true,
+ anchorRows: Object.freeze(['e', 'f']),
+ anchorKinds: Object.freeze(['main']),
+ anchorless: false,
+ orientable: true,
+ orientValues: DIP_ORIENTATIONS,
+ defaultOrient: 'right',
+ dipStyle: true,
+ switchCount: 8,
+ footprint: Object.freeze({ kind: 'dip', pinCount: 16, pins: dipPinOffsets(16) }),
+ // Switch positions ARE persisted: they change what the circuit does, so they
+ // belong in the document that describes it. Bounded by construction - exactly 8
+ // booleans - which is what made this acceptable where a free-form slot was not.
+ defaultProps: Object.freeze({ on: Object.freeze([false, false, false, false, false, false, false, false]) }),
+ propSpecs: Object.freeze({
+ on: Object.freeze({
+ kind: 'boolArray', length: 8, label: 'Switches',
+ default: Object.freeze([false, false, false, false, false, false, false, false])
+ })
+ })
+});
+
+define({
+ type: 'powerSupply5V',
+ label: '5V supply',
+ description: 'Drives one rail pair: the plus rail to 5V and the minus rail to ground.',
+ category: 'power',
+ pins: namedPins(['v+', 'gnd']),
+ bodyColumns: 0,
+ straddlesGap: false,
+ anchorRows: null,
+ anchorKinds: null,
+ anchorless: true, // positioned by props.board + props.side
+ orientable: false,
+ footprint: Object.freeze({
+ kind: 'railPair',
+ pinCount: 2,
+ pins: Object.freeze([
+ Object.freeze({ pin: 1, at: 'rail', polarity: 'plus', index: 1 }),
+ Object.freeze({ pin: 2, at: 'rail', polarity: 'minus', index: 1 })
+ ])
+ }),
+ defaultProps: Object.freeze({ side: 'top' }),
+ propSpecs: Object.freeze({
+ side: Object.freeze({ kind: 'enum', values: Object.freeze(['top', 'bottom']), default: 'top', label: 'Rail pair' })
+ })
+});
+
+for (const type of CHIP_TYPES) define(chipDef(type));
+Object.freeze(DEFS);
+
+/** All registered component type names, in palette order. */
+export const COMPONENT_TYPES = Object.freeze([
+ 'led', 'resistor', 'pushButton', 'dipSwitch8', 'powerSupply5V', ...CHIP_TYPES
+]);
+
+/** Valid `orient` values for orientable components. */
+export const ORIENTATIONS = Object.freeze(['up', 'down', 'left', 'right']);
+
+/**
+ * Definition for a component type.
+ * @returns {object|null} frozen definition, or null for an unknown type
+ */
+export function getComponentDef(type) {
+ return DEFS[type] || null;
+}
+
+/** True when `type` is a registered component type. */
+export function isKnownType(type) {
+ return typeof type === 'string' && DEFS[type] !== undefined;
+}
+
+/** True when `type` is one of the DIP logic chips. */
+export function isChipType(type) {
+ return CHIP_TYPES.indexOf(type) !== -1;
+}
+
+/** Number of pins a component type has. */
+export function pinCount(type) {
+ const def = DEFS[type];
+ return def ? def.pins.length : 0;
+}
+
+/** Physical lens color for an LED color name; falls back to red. */
+export function ledColorHex(value) {
+ const found = LED_COLORS.find(c => c.value === value);
+ return found ? found.hex : LED_COLORS[0].hex;
+}
+
+/**
+ * Props filled in with the type's defaults. Returns a fresh mutable object; array
+ * defaults (the DIP switch state) are copied so callers cannot mutate the registry.
+ * @returns {object}
+ */
+export function defaultPropsFor(type) {
+ const def = DEFS[type];
+ if (!def) return {};
+ const props = {};
+ for (const [key, value] of Object.entries(def.defaultProps)) {
+ props[key] = Array.isArray(value) ? value.slice() : value;
+ }
+ return props;
+}
+
+/**
+ * Validate a component's scalar props against the type's propSpecs. Hole-reference
+ * props (resistor `to`) and placement rules are checked by circuit-schema instead.
+ * @returns {string[]} error messages, empty when valid
+ */
+export function validateProps(type, props) {
+ const def = DEFS[type];
+ if (!def) return [`unknown component type "${String(type)}"`];
+ if (props === undefined || props === null) return [];
+ if (typeof props !== 'object' || Array.isArray(props)) return ['props must be an object'];
+
+ const errors = [];
+ for (const [key, spec] of Object.entries(def.propSpecs)) {
+ const value = props[key];
+ if (value === undefined) continue; // absent props fall back to defaults
+ if (spec.kind === 'enum' && spec.values.indexOf(value) === -1) {
+ errors.push(`${type}.${key} must be one of ${spec.values.join(', ')}`);
+ } else if (spec.kind === 'number') {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ errors.push(`${type}.${key} must be a finite number`);
+ } else if (value < spec.min || value > spec.max) {
+ errors.push(`${type}.${key} must be between ${spec.min} and ${spec.max}`);
+ }
+ } else if (spec.kind === 'boolArray') {
+ if (!Array.isArray(value)) {
+ errors.push(`${type}.${key} must be an array`);
+ } else if (value.length !== spec.length) {
+ errors.push(`${type}.${key} must have exactly ${spec.length} entries`);
+ } else if (value.some(v => typeof v !== 'boolean')) {
+ errors.push(`${type}.${key} entries must all be true or false`);
+ }
+ }
+ }
+
+ // The server-side validator whitelists props keys per type and rejects anything
+ // else, so catch a stray key here rather than as a 400 after the save is sent.
+ const allowed = allowedPropKeys(type);
+ for (const key of Object.keys(props)) {
+ if (allowed.indexOf(key) === -1) {
+ errors.push(`${type}.${key} is not a valid property`);
+ }
+ }
+ return errors;
+}
+
+// Props that are hole references rather than scalars, so they have no propSpec entry
+// but are still permitted by the server.
+const EXTRA_PROP_KEYS = Object.freeze(Object.assign(Object.create(null), {
+ resistor: Object.freeze(['to']),
+ powerSupply5V: Object.freeze(['board'])
+}));
+
+/**
+ * Every props key a component type may carry in a persisted document.
+ * @returns {string[]}
+ */
+export function allowedPropKeys(type) {
+ const def = DEFS[type];
+ if (!def) return [];
+ return Object.keys(def.propSpecs).concat(EXTRA_PROP_KEYS[type] || []);
+}