velxio/frontend/src/__tests__/breadboard-snap.test.ts

369 lines
15 KiB
TypeScript
Raw Normal View History

feat(breadboard): Wokwi-style parts-on-breadboard — hole snapping + invisible seating wires Parts now plug INTO the breadboard instead of using it as a junction box: - Drag magnetism: while dragging, the part's anchor pin snaps to the nearest hole center (9 px range, 9.6 px grid) so parts land perfectly aligned, like Wokwi. - Seating: every pin within 4 px of a hole gets an invisible zero-length wire (Wire.bb) from pin to hole — the exact model Wokwi persists as ["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary wires, so the netlist builder, digital trace and SPICE need zero changes; they are simply not rendered and not hit-testable. Seating re-computes on every move/rotation (updateComponent), and moving the breadboard carries its seated parts along. - Resistors auto-rotate to vertical when dragged over a breadboard (their 58.8 px pin span bridges the center trench rows b-f exactly). - Seat tolerance 4 px: absorbs the worst element pin-spacing residual (~1.6 px) while staying under half the hole pitch, so a pin is never ambiguous between holes. Wokwi interchange fixes that fell out of the diagram.json research: - import maps the top-level rotate attr onto properties.rotation (previously every rotated part imported flat) and export emits it back as rotate instead of leaking it into attrs; - $bb / empty-color connections import as bb seating wires and export back as ["$bb"] entries, so parts-on-breadboard projects round-trip; - wokwi-breadboard-half aliases to the full breadboard (hole names are a strict superset, so every connection stays valid). Breadboard elements now export their pure hole grids and import cleanly without a DOM (node tests); geometry + store seating covered by breadboard-snap.test.ts and breadboard-seating.test.ts.
2026-07-18 13:47:25 +07:00
/**
* Parts-on-breadboard geometry: hole grids, nearest-hole lookup and the
* wire flag plumbing that keeps seating wires invisible.
*/
import { describe, it, expect } from 'vitest';
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
import {
breadboardHoles,
nearestHole,
solvePlacement,
SEAT_TOLERANCE,
} from '../utils/breadboardSnap';
feat(breadboard): Wokwi-style parts-on-breadboard — hole snapping + invisible seating wires Parts now plug INTO the breadboard instead of using it as a junction box: - Drag magnetism: while dragging, the part's anchor pin snaps to the nearest hole center (9 px range, 9.6 px grid) so parts land perfectly aligned, like Wokwi. - Seating: every pin within 4 px of a hole gets an invisible zero-length wire (Wire.bb) from pin to hole — the exact model Wokwi persists as ["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary wires, so the netlist builder, digital trace and SPICE need zero changes; they are simply not rendered and not hit-testable. Seating re-computes on every move/rotation (updateComponent), and moving the breadboard carries its seated parts along. - Resistors auto-rotate to vertical when dragged over a breadboard (their 58.8 px pin span bridges the center trench rows b-f exactly). - Seat tolerance 4 px: absorbs the worst element pin-spacing residual (~1.6 px) while staying under half the hole pitch, so a pin is never ambiguous between holes. Wokwi interchange fixes that fell out of the diagram.json research: - import maps the top-level rotate attr onto properties.rotation (previously every rotated part imported flat) and export emits it back as rotate instead of leaking it into attrs; - $bb / empty-color connections import as bb seating wires and export back as ["$bb"] entries, so parts-on-breadboard projects round-trip; - wokwi-breadboard-half aliases to the full breadboard (hole names are a strict superset, so every connection stays valid). Breadboard elements now export their pure hole grids and import cleanly without a DOM (node tests); geometry + store seating covered by breadboard-snap.test.ts and breadboard-seating.test.ts.
2026-07-18 13:47:25 +07:00
import { BREADBOARD_PINS } from '../velxio-elements/breadboard-element';
import { BREADBOARD_MINI_PINS } from '../velxio-elements/breadboard-mini-element';
const WRAPPER_INSET = 6;
describe('breadboardHoles', () => {
it('exposes the full-size grid (830 holes) and the mini grid (170)', () => {
expect(breadboardHoles('breadboard')).toHaveLength(830);
expect(breadboardHoles('breadboard-mini')).toHaveLength(170);
expect(breadboardHoles('led')).toBeNull();
});
it('uses the wokwi 9.6 px pitch on both axes', () => {
const holes = BREADBOARD_PINS;
const c1a = holes.find((h) => h.name === '1t.a')!;
const c2a = holes.find((h) => h.name === '2t.a')!;
const c1b = holes.find((h) => h.name === '1t.b')!;
expect(c2a.x - c1a.x).toBeCloseTo(9.6, 5);
expect(c1b.y - c1a.y).toBeCloseTo(9.6, 5);
});
it('spans exactly 6 pitches from row b to row f (resistor trench bridge)', () => {
const b = BREADBOARD_PINS.find((h) => h.name === '10t.b')!;
const f = BREADBOARD_PINS.find((h) => h.name === '10b.f')!;
expect(f.y - b.y).toBeCloseTo(6 * 9.6, 5);
expect(f.x).toBeCloseTo(b.x, 5);
});
it('mini board has no rails', () => {
expect(BREADBOARD_MINI_PINS.some((h) => h.name.includes('p.'))).toBe(false);
});
});
describe('nearestHole', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const hole = BREADBOARD_PINS.find((h) => h.name === '5t.c')!;
const world = { x: 100 + WRAPPER_INSET + hole.x, y: 200 + WRAPPER_INSET + hole.y };
it('finds the exact hole under a world point (wrapper inset accounted)', () => {
const found = nearestHole(bb, world, SEAT_TOLERANCE);
expect(found?.name).toBe('5t.c');
expect(found?.dist).toBeCloseTo(0, 5);
});
it('absorbs the resistor 1.2 px pitch residual but rejects beyond tolerance', () => {
expect(nearestHole(bb, { x: world.x + 1.2, y: world.y }, SEAT_TOLERANCE)?.name).toBe('5t.c');
// Center of the e-f trench: 14.4 px from the nearest rows — no hole.
const rowE = BREADBOARD_PINS.find((h) => h.name === '5t.e')!;
const trench = { x: 100 + WRAPPER_INSET + rowE.x, y: 200 + WRAPPER_INSET + rowE.y + 14.4 };
expect(nearestHole(bb, trench, SEAT_TOLERANCE)).toBeNull();
});
it('tolerance stays below half the hole pitch (no ambiguous seating)', () => {
expect(SEAT_TOLERANCE).toBeLessThan(9.6 / 2);
});
it('refuses rotated breadboards', () => {
const rotated = { ...bb, properties: { rotation: 90 } };
expect(nearestHole(rotated, world, SEAT_TOLERANCE)).toBeNull();
});
});
feat(breadboard): hover-gated labels + full-footprint seating solver Three changes, all driven by a real project where a 4-digit 7-segment clock was unreadable and half its parts were not actually seated. Labels on hover only -------------------- Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω" labels on top of each other, hiding the parts and the breadboard holes; the SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover: hovering a part also lights up the voltages of every wire touching it. The label is hidden with OPACITY and stays in flow. pinPositionCalculator derives the rotation pivot from wrapper.offsetHeight, so taking it out of flow would move the pins of every rotated component in every saved project. Seat-on-drop ------------ The drag-time magnet only aligned the anchor pin and assumed the rest followed, which is how parts ended up HALF-seated: some pins in holes, the rest dead in the air. It looks mounted in a screenshot and silently breaks the circuit. On release we now re-solve properly — nearest position where EVERY pin is in a free hole, sliding past occupied columns — via the new solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo, so there is no part whitelist. Sub-pitch translation --------------------- solvePlacement first assigned pins to holes at half-pitch, then translates by the centroid of the residuals before judging fit. Pinning the anchor dead centre refused every off-lattice footprint: a diode spans 7.5 pitches, so one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance — what bending the leads does on a real board. Measured over the catalog this takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators, optocouplers and flip-flops are rescued with no artwork change. Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole unambiguous, so computeSeating resolves the same holes and the netlist is unaffected by the small offset. Also: refuse a placement that would put two of a part's own pins in one strip. A column strip — and far worse, a power rail — is a single net, so such a seating shorts the part to itself. Without it a 7-segment happily lays its pins across a rail. And deduplicate pin names before solving: calculatePinPosition resolves by name and returns the first match, so a board carrying GND x5 collided with itself and was refused outright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:28:40 +07:00
describe('solvePlacement', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
/** Pin offsets for a vertical resistor bridging the trench (rows b -> f). */
const resistorPins = () => {
const b = hole('10t.b');
const f = hole('10b.f');
return [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: f.x - b.x, dy: f.y - b.y },
];
};
/** Position that puts pin 1 exactly on `name`. */
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
it('leaves an already-correct part exactly where it is', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 5);
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
});
it('pulls a part dropped slightly off back onto the holes', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(), want.x + 3, want.y - 2)!;
expect(got.holes.map((h) => h.holeName)).toEqual(['10t.b', '10b.f']);
expect(got.x).toBeCloseTo(want.x, 5);
expect(got.y).toBeCloseTo(want.y, 5);
});
it('slides to the next free column when the target is occupied', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10t.b']), want.x, want.y)!;
expect(got.holes[0].holeName).not.toBe('10t.b');
// Nearest free column, not a jump across the board.
expect(got.moved).toBeLessThanOrEqual(9.6 * 2);
});
it('rejects a placement whose SECOND pin would collide', () => {
const want = posFor('10t.b');
const got = solvePlacement(resistorPins(), bb, new Set(['10b.f']), want.x, want.y)!;
expect(got.holes.map((h) => h.holeName)).not.toContain('10b.f');
});
it('returns null when every hole in range is taken', () => {
const all = new Set(BREADBOARD_PINS.map((h) => h.name));
const want = posFor('10t.b');
expect(solvePlacement(resistorPins(), bb, all, want.x, want.y)).toBeNull();
});
it('never returns a half-seated placement (the 7-segment bug)', () => {
// The real invariant: a returned placement always assigns EVERY pin a
// hole. Never a partial seating, whatever the geometry. Swept across
// spans that are on-pitch, off-pitch and half-pitch.
const want = posFor('10t.b');
for (let extra = 0; extra <= 9.6; extra += 0.4) {
const pins = [
{ name: 'top', dx: 0, dy: 0 },
{ name: 'bottom', dx: 0, dy: 6 * 9.6 + extra },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) expect(got.holes).toHaveLength(pins.length);
}
});
it('refuses to drag a part more than the search radius', () => {
const want = posFor('10t.b');
// Occupy a wide band around the drop so nothing fits within 6 pitches.
const taken = new Set(
BREADBOARD_PINS.filter((h) => Math.abs(h.x - hole('10t.b').x) < 9.6 * 8).map((h) => h.name),
);
expect(solvePlacement(resistorPins(), bb, taken, want.x, want.y)).toBeNull();
});
});
describe('solvePlacement — sub-pitch translation (off-lattice footprints)', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const posFor = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
/** Nearest-hole distance for every pin of a placement, for assertions. */
const residuals = (pins: { name: string; dx: number; dy: number }[], p: { x: number; y: number }) =>
pins.map((pin) => {
const px = p.x + pin.dx;
const py = p.y + pin.dy;
let best = Infinity;
for (const h of BREADBOARD_PINS) best = Math.min(best, Math.hypot(ox + h.x - px, oy + h.y - py));
return best;
});
it('seats a diode: 7.5-pitch span splits the error between both legs', () => {
// DiodeElements.ts diodePinInfo(): A at x=0, C at x=72 = 7.5 * 9.6.
// Anchor-exact placement leaves C 4.8 px out; the fine translation puts
// both legs 2.4 px off centre instead, which is inside tolerance.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(2);
for (const r of residuals(pins, got!)) {
expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
expect(r).toBeGreaterThan(0.5); // genuinely off-centre, not a lucky exact fit
}
});
it('keeps every pin inside tolerance, so hole resolution stays unambiguous', () => {
// SEAT_TOLERANCE < half pitch is what guarantees computeSeating later
// picks the SAME holes the solver assigned — i.e. the netlist is
// unaffected by the part rendering a couple of px off centre.
const pins = [
{ name: 'A', dx: 0, dy: 0 },
{ name: 'C', dx: 72, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
for (const r of residuals(pins, got)) expect(r).toBeLessThan(9.6 / 2);
});
it('still refuses a DIP-14 on the wrong pitch — 8 px cannot be rescued', () => {
// LogicICElements.ts dip14Pins(): y = 12 + i*8. Seven pins at 8 px drift
// 1.6 px per step against the 9.6 grid; by pin 7 that is 4.8 px, and no
// single translation can absorb a spread that large.
const pins = Array.from({ length: 7 }, (_, i) => ({ name: `p${i + 1}`, dx: 0, dy: i * 8 }));
const want = posFor('10t.a');
expect(solvePlacement(pins, bb, new Set(), want.x, want.y)).toBeNull();
});
it('makes EVERY two-pin footprint seatable, at any span', () => {
// Guarantee of the centroid rule: with two pins the worst span error
// against the lattice is half a pitch (4.8 px), which splits into 2.4 px
// per pin — always inside SEAT_TOLERANCE. This is what rescues the whole
// 72 px family (diodes, transistors, regulators) with no artwork change.
const want = posFor('10t.b');
for (let span = 9.6; span <= 96; span += 0.4) {
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: span, dy: 0 },
];
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
expect(got, `span ${span.toFixed(1)} px should seat`).not.toBeNull();
for (const r of residuals(pins, got!)) expect(r).toBeLessThanOrEqual(SEAT_TOLERANCE);
}
});
it('an exactly-on-grid part is still placed dead centre, not nudged', () => {
const pins = [
{ name: '1', dx: 0, dy: 0 },
{ name: '2', dx: 9.6 * 4, dy: 0 },
];
const want = posFor('10t.b');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y)!;
expect(got.moved).toBeCloseTo(0, 6);
for (const r of residuals(pins, got)) expect(r).toBeCloseTo(0, 6);
});
});
describe('solvePlacement — real catalog footprints', () => {
// Coordinates copied verbatim from the element sources, so this locks in
// the measured coverage boundary. If artwork changes, these move with it.
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const seats = (pins: { name: string; dx: number; dy: number }[], anchor = '10t.a') => {
const want = at(anchor);
return solvePlacement(pins, bb, new Set(), want.x, want.y);
};
it('SEATS a diode — DiodeElements.ts, A/C 72 px apart', () => {
expect(
seats([
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
]),
).not.toBeNull();
});
it('SEATS a TO-92 transistor — TransistorElements.ts C/B/E', () => {
const got = seats([
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
]);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(3);
// Distinct holes — a transistor shorting two of its own legs is useless.
expect(new Set(got!.holes.map((h) => h.holeName)).size).toBe(3);
});
it('SEATS the 74HC595 — IC74HC595.ts, already on an exact 9.6 pitch', () => {
const xs = [8.1, 17.7, 27.3, 36.9, 46.5, 56.1, 65.7, 75.3];
const pins = [
...xs.map((x, i) => ({ name: `b${i}`, dx: x, dy: 51.3 })),
...xs.map((x, i) => ({ name: `t${i}`, dx: x, dy: 3 })),
];
const got = seats(pins);
expect(got).not.toBeNull();
expect(got!.holes).toHaveLength(16);
});
it('REFUSES the 74HC00 family — LogicICElements.ts dip14Pins() 8 px pitch', () => {
// The one defect no solver can absorb: 1.6 px drift per pin compounds
// to 4.8 px across the package. Needs the artwork fixed against the
// 74HC595 template above.
const DIP14_W = 80;
const pins = Array.from({ length: 14 }, (_, i) => ({
name: `p${i + 1}`,
dx: i < 7 ? 0 : DIP14_W,
dy: i < 7 ? 12 + i * 8 : 12 + (13 - i) * 8,
}));
expect(seats(pins)).toBeNull();
});
});
describe('solvePlacement — never shorts a part to itself', () => {
const bb = { id: 'bb1', metadataId: 'breadboard', x: 100, y: 200 };
const ox = 100 + WRAPPER_INSET;
const oy = 200 + WRAPPER_INSET;
const hole = (name: string) => BREADBOARD_PINS.find((h) => h.name === name)!;
const at = (name: string) => ({ x: ox + hole(name).x, y: oy + hole(name).y });
const groupOf = (h: string) => {
const m = /^(\d+)([tb])\.[a-j]$/.exec(h);
if (m) return `col${m[1]}${m[2]}`;
const r = /^([tb][pn])\.\d+$/.exec(h);
return r ? `rail${r[1]}` : h;
};
it('refuses a footprint whose pins would share one column strip', () => {
// Two pins 9.6 px apart vertically inside a bank land in the same 5-hole
// column, which is a single net.
const pins = [
{ name: 'a', dx: 0, dy: 0 },
{ name: 'b', dx: 0, dy: 9.6 },
];
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size).toBe(gs.length);
}
});
it('never lays a multi-pin part across a power rail', () => {
// A rail is one net for the WHOLE board — the worst possible short.
const pins = Array.from({ length: 5 }, (_, i) => ({ name: `p${i}`, dx: i * 9.6, dy: 0 }));
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (got) {
const rails = got.holes.filter((h) => /^[tb][pn]\./.test(h.holeName));
expect(rails.length).toBeLessThanOrEqual(1);
}
});
it('every seated real footprint uses one distinct strip per pin', () => {
const cases: Record<string, { name: string; dx: number; dy: number }[]> = {
diode: [
{ name: 'A', dx: 0, dy: 16 },
{ name: 'C', dx: 72, dy: 16 },
],
to92: [
{ name: 'C', dx: 60, dy: 0 },
{ name: 'B', dx: 0, dy: 36 },
{ name: 'E', dx: 60, dy: 72 },
],
// neopixel: 20 x 10.5 px rectangle — the audit found this one seats
// only by shorting itself, at every anchor and rotation.
neopixel: [
{ name: 'VDD', dx: 0, dy: 0 },
{ name: 'DIN', dx: 0, dy: 10.5 },
{ name: 'DOUT', dx: 20, dy: 0 },
{ name: 'VSS', dx: 20, dy: 10.5 },
],
};
for (const [label, pins] of Object.entries(cases)) {
const want = at('20t.a');
const got = solvePlacement(pins, bb, new Set(), want.x, want.y);
if (!got) continue; // refusing is a valid answer
const gs = got.holes.map((h) => groupOf(h.holeName));
expect(new Set(gs).size, `${label} shorts itself`).toBe(gs.length);
}
});
});