Feature/breadboard #4

Merged
jheaps merged 25 commits from feature/breadboard into master 2026-08-26 17:36:05 -06:00
5 changed files with 124 additions and 24 deletions
Showing only changes of commit 9c4ff64070 - Show all commits
+18 -2
View File
@@ -502,7 +502,18 @@
/* --- Status strip ---------------------------------------------------------- */ /* --- Status strip ---------------------------------------------------------- */
.bb-status-host { flex: 0 0 auto; } /* Floats over the bottom of the canvas, NOT stacked below it. Sharing the column
* meant every message that appeared or timed out resized the canvas, which reallocated
* the backing stores and forced a full repaint. */
.bb-status-host {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
/* The strip is often empty; the canvas under it must stay clickable. */
pointer-events: none;
}
.bb-status { .bb-status {
display: flex; display: flex;
@@ -510,9 +521,14 @@
max-height: 11rem; max-height: 11rem;
overflow-y: auto; overflow-y: auto;
background: var(--bg-secondary); background: var(--bg-secondary);
border-top: 1px solid var(--border-primary); pointer-events: auto;
} }
/* Border on the groups rather than the panel: both collapse when empty, so an idle
* editor shows no stray rule across the canvas. */
.bb-status-messages,
.bb-status-warnings { border-top: 1px solid var(--border-primary); }
.bb-status-messages:empty, .bb-status-messages:empty,
.bb-status-warnings:not(.is-visible) { display: none; } .bb-status-warnings:not(.is-visible) { display: none; }
@@ -67,9 +67,15 @@ export function boot(root) {
className: 'bb-workspace', className: 'bb-workspace',
children: [paletteHost, canvasContainer, propsHost] children: [paletteHost, canvasContainer, propsHost]
}); });
const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace, statusHost] }); const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace] });
root.appendChild(shell); root.appendChild(shell);
// The status strip floats over the bottom of the canvas rather than sitting below
// it in the column. In the column, every message that appeared or timed out resized
// the canvas container, which reallocated all four backing stores and forced a full
// repaint - twice per message.
canvasContainer.appendChild(statusHost);
const status = createStatus(statusHost); const status = createStatus(statusHost);
if (!projectId) { if (!projectId) {
@@ -91,6 +97,8 @@ export function boot(root) {
let destroyed = false; let destroyed = false;
let saving = false; let saving = false;
/** Pending properties-panel rebuild. See scheduleProperties. */
let propertiesFrame = null;
let simLoadedOnce = false; let simLoadedOnce = false;
/** Components named by a simulation warning, highlighted on the canvas. */ /** Components named by a simulation warning, highlighted on the canvas. */
const warnedUids = new Set(); const warnedUids = new Set();
@@ -121,7 +129,7 @@ export function boot(root) {
if (!failed) status.clearWarnings(); if (!failed) status.clearWarnings();
for (const warning of warnings) status.addWarning(warning); for (const warning of warnings) status.addWarning(warning);
refreshSimState(); refreshSimState();
pushSimScene(); pushSimScene('dynamic', 'overlay');
if (!failed && netCount > 0) { if (!failed && netCount > 0) {
status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`); status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`);
} }
@@ -136,7 +144,7 @@ export function boot(root) {
// Repaint here rather than waiting for an unrelated message: shortCircuit // 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 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. // and those are exactly the warnings whose components most need pointing at.
pushSimScene(); pushSimScene('dynamic', 'overlay');
// A halt is worth an explicit message; the engine refuses to run into it. // A halt is worth an explicit message; the engine refuses to run into it.
if (warning.kind === 'shortCircuit') { if (warning.kind === 'shortCircuit') {
status.error('Simulation halted: the supply rails are shorted together.'); status.error('Simulation halted: the supply rails are shorted together.');
@@ -151,7 +159,14 @@ export function boot(root) {
} }
}); });
function pushSimScene() { /**
* Hand the current simulation state to the renderer.
*
* Defaults to the dynamic layer alone. The overlay only shows simulation state
* through `warnedUids`, which changes on a warning - not on every frame - so
* callers that touch warnedUids pass 'dynamic', 'overlay' explicitly.
*/
function pushSimScene(...layers) {
renderer.setScene({ renderer.setScene({
netLevels: sim.state.netLevels, netLevels: sim.state.netLevels,
netOfStrip: sim.state.netOfStrip, netOfStrip: sim.state.netOfStrip,
@@ -159,7 +174,20 @@ export function boot(root) {
burned: sim.state.burned, burned: sim.state.burned,
warnedUids, warnedUids,
simActive: sim.state.loaded simActive: sim.state.loaded
}, 'dynamic', 'overlay'); }, ...(layers.length > 0 ? layers : ['dynamic']));
}
/**
* Rebuild the properties panel at most once per frame. render() throws away and
* rebuilds the whole panel, and a component drag fires a circuit change on every
* pointermove, so calling it directly meant a full DOM teardown per mouse move.
*/
function scheduleProperties() {
if (propertiesFrame !== null) return;
propertiesFrame = requestAnimationFrame(() => {
propertiesFrame = null;
if (!destroyed) properties.render(state);
});
} }
function refreshSimState() { function refreshSimState() {
@@ -237,7 +265,7 @@ export function boot(root) {
status.clearWarnings(); status.clearWarnings();
sim.reset(); sim.reset();
status.info('Simulation reset.'); status.info('Simulation reset.');
pushSimScene(); pushSimScene('dynamic', 'overlay');
}, },
onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond), onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
onAddBoard: () => { onAddBoard: () => {
@@ -311,7 +339,7 @@ export function boot(root) {
toolbar.setZoom(viewport.zoom); toolbar.setZoom(viewport.zoom);
persistView(); persistView();
}, },
onSelectionChange: () => properties.render(state) onSelectionChange: () => scheduleProperties()
}); });
palette.setActiveTool({ kind: 'select', type: null }); palette.setActiveTool({ kind: 'select', type: null });
@@ -331,9 +359,14 @@ export function boot(root) {
afterViewportChange(); afterViewportChange();
} }
bag.on(window, 'resize', () => renderer.resize()); function onContainerResize() {
tools.invalidateRect();
renderer.resize();
}
bag.on(window, 'resize', onContainerResize);
const resizeObserver = typeof ResizeObserver === 'function' const resizeObserver = typeof ResizeObserver === 'function'
? new ResizeObserver(() => renderer.resize()) ? new ResizeObserver(onContainerResize)
: null; : null;
if (resizeObserver) resizeObserver.observe(canvasContainer); if (resizeObserver) resizeObserver.observe(canvasContainer);
@@ -353,7 +386,7 @@ export function boot(root) {
state.subscribe((change) => { state.subscribe((change) => {
if (change.kind === 'circuit') { if (change.kind === 'circuit') {
tools.refresh('board', 'static', 'dynamic', 'overlay'); tools.refresh('board', 'static', 'dynamic', 'overlay');
properties.render(state); scheduleProperties();
reloadSim(); reloadSim();
autosave(); autosave();
} else if (change.kind === 'runtime') { } else if (change.kind === 'runtime') {
@@ -387,7 +420,7 @@ export function boot(root) {
else afterViewportChange(); else afterViewportChange();
tools.refresh('board', 'static', 'dynamic', 'overlay'); tools.refresh('board', 'static', 'dynamic', 'overlay');
properties.render(state); scheduleProperties();
updateSaveState(); updateSaveState();
if (sim.start()) sim.load(circuit); if (sim.start()) sim.load(circuit);
@@ -408,6 +441,7 @@ export function boot(root) {
reloadSim.cancel(); reloadSim.cancel();
autosave.cancel(); autosave.cancel();
persistView.cancel(); persistView.cancel();
if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame);
bag.removeAll(); bag.removeAll();
if (resizeObserver) resizeObserver.disconnect(); if (resizeObserver) resizeObserver.disconnect();
tools.destroy(); tools.destroy();
@@ -62,9 +62,16 @@ export function createRenderer(container, viewport, palette, options = {}) {
} }
canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard
// Latest MEASURED size, in CSS pixels. Read straight after resize() so callers
// that fit the view can trust it.
let cssWidth = 0; let cssWidth = 0;
let cssHeight = 0; let cssHeight = 0;
let dpr = 1; let dpr = 1;
// Size the backing stores currently hold. Reallocating them costs tens of MB of
// churn, so it happens once per frame at most, inside paint().
let appliedWidth = 0;
let appliedHeight = 0;
let appliedDpr = 0;
const dirty = { board: true, static: true, dynamic: true, overlay: true }; const dirty = { board: true, static: true, dynamic: true, overlay: true };
let frameHandle = null; let frameHandle = null;
@@ -86,25 +93,33 @@ export function createRenderer(container, viewport, palette, options = {}) {
simActive: false simActive: false
}; };
function syncCanvasSize() { /** Read the container size. Cheap - one layout read, no canvas work. */
function measureContainer() {
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const nextDpr = window.devicePixelRatio || 1; const nextDpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width)); const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height)); const height = Math.max(1, Math.round(rect.height));
if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false; if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
cssWidth = width; cssWidth = width;
cssHeight = height; cssHeight = height;
dpr = nextDpr; dpr = nextDpr;
return true;
}
/** Resize the backing stores to the measured size. Wipes them, so all layers dirty. */
function applyCanvasSize() {
if (cssWidth === appliedWidth && cssHeight === appliedHeight && dpr === appliedDpr) return;
appliedWidth = cssWidth;
appliedHeight = cssHeight;
appliedDpr = dpr;
for (const name of LAYER_NAMES) { for (const name of LAYER_NAMES) {
const canvas = canvases[name]; const canvas = canvases[name];
canvas.width = Math.round(width * dpr); canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(height * dpr); canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${width}px`; canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${height}px`; canvas.style.height = `${cssHeight}px`;
} }
invalidateAll(); for (const name of LAYER_NAMES) dirty[name] = true;
return true;
} }
/** Apply the base dpr transform, then the world transform. */ /** Apply the base dpr transform, then the world transform. */
@@ -335,6 +350,7 @@ export function createRenderer(container, viewport, palette, options = {}) {
function paint() { function paint() {
frameHandle = null; frameHandle = null;
applyCanvasSize();
for (const name of LAYER_NAMES) { for (const name of LAYER_NAMES) {
if (!dirty[name]) continue; if (!dirty[name]) continue;
try { try {
@@ -387,7 +403,7 @@ export function createRenderer(container, viewport, palette, options = {}) {
}, },
resize() { resize() {
if (syncCanvasSize()) schedule(); if (measureContainer()) invalidateAll();
}, },
invalidate, invalidate,
@@ -42,6 +42,11 @@ export function createToolbar(root, handlers) {
const addBoardButton = button('Add board', { className: 'bb-btn' }); const addBoardButton = button('Add board', { className: 'bb-btn' });
// Last state written to the DOM. setSimState is called on every simulation frame,
// and writing unchanged text/disabled flags still invalidates layout on a
// wrapping toolbar, so identical calls stop here.
let lastSimSignature = null;
const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children }); const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
const bar = el('div', { const bar = el('div', {
@@ -92,6 +97,10 @@ export function createToolbar(root, handlers) {
* rail-to-rail short - so the run button must visibly not take. * rail-to-rail short - so the run button must visibly not take.
*/ */
setSimState({ available, running, settled, halted, loaded }) { setSimState({ available, running, settled, halted, loaded }) {
const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
if (signature === lastSimSignature) return;
lastSimSignature = signature;
runButton.disabled = !available || running || halted; runButton.disabled = !available || running || halted;
pauseButton.disabled = !available || !running; pauseButton.disabled = !available || !running;
stepButton.disabled = !available || running; stepButton.disabled = !available || running;
@@ -72,14 +72,32 @@ export function createTools(options) {
let hoverHole = null; let hoverHole = null;
let hoverComponentUid = null; let hoverComponentUid = null;
// Cached canvas rect. pointermove is bound to `window` and needs the rect twice per
// event, and a high-poll mouse fires far more often than once per frame - reading it
// live forces a layout every time. Resize and scroll invalidate it explicitly; the
// TTL is the backstop for a layout shift that moves the canvas without either, so
// the rect is never more than RECT_TTL_MS stale.
let canvasRect = null;
let canvasRectAt = 0;
const RECT_TTL_MS = 250;
function bounds() {
const now = performance.now();
if (canvasRect === null || now - canvasRectAt > RECT_TTL_MS) {
canvasRect = canvas.getBoundingClientRect();
canvasRectAt = now;
}
return canvasRect;
}
function screenPoint(event) { function screenPoint(event) {
const rect = canvas.getBoundingClientRect(); const rect = bounds();
return { x: event.clientX - rect.left, y: event.clientY - rect.top }; return { x: event.clientX - rect.left, y: event.clientY - rect.top };
} }
/** Whether a pointer event happened over the canvas itself. */ /** Whether a pointer event happened over the canvas itself. */
function isOverCanvas(event) { function isOverCanvas(event) {
const rect = canvas.getBoundingClientRect(); const rect = bounds();
return event.clientX >= rect.left && event.clientX <= rect.right return event.clientX >= rect.left && event.clientX <= rect.right
&& event.clientY >= rect.top && event.clientY <= rect.bottom; && event.clientY >= rect.top && event.clientY <= rect.bottom;
} }
@@ -480,6 +498,11 @@ export function createTools(options) {
} }
} }
const invalidateRect = () => { canvasRect = null; };
bag.on(window, 'resize', invalidateRect);
// Capture, so an ancestor scrolling the canvas out from under us also counts.
bag.on(window, 'scroll', invalidateRect, true);
bag.on(canvas, 'pointerdown', onPointerDown); bag.on(canvas, 'pointerdown', onPointerDown);
bag.on(window, 'pointermove', onPointerMove); bag.on(window, 'pointermove', onPointerMove);
bag.on(window, 'pointerup', onPointerUp); bag.on(window, 'pointerup', onPointerUp);
@@ -504,6 +527,8 @@ export function createTools(options) {
publishScene('dynamic', 'overlay'); publishScene('dynamic', 'overlay');
}, },
refresh: publishScene, refresh: publishScene,
/** Drop the cached canvas rect. Call whenever the canvas may have moved. */
invalidateRect,
destroy() { destroy() {
bag.removeAll(); bag.removeAll();
} }