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 support (Wokwi-style).
|
|
|
|
|
*
|
|
|
|
|
* Model (mirrors what Wokwi persists as `["r1:1","bb1:6t.b","",["$bb"]]`):
|
|
|
|
|
* a part seated on a breadboard is connected by one INVISIBLE zero-length
|
|
|
|
|
* wire per pin, from the part pin to the hole under it. Those wires are
|
|
|
|
|
* ordinary `Wire` objects flagged `bb: true`, so the netlist builder, the
|
|
|
|
|
* digital trace and SPICE treat them exactly like hand-drawn wires — the
|
|
|
|
|
* only special-casing is cosmetic (not rendered, not hit-testable).
|
|
|
|
|
*
|
|
|
|
|
* Two building blocks live here:
|
|
|
|
|
* - pure geometry over the exported hole grids (testable without DOM), and
|
|
|
|
|
* - a DOM adapter that reads the dragged part's pinInfo to compute the
|
|
|
|
|
* anchor-pin snap and the per-pin seating.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
BREADBOARD_PINS,
|
|
|
|
|
} from '../velxio-elements/breadboard-element';
|
|
|
|
|
import {
|
|
|
|
|
BREADBOARD_MINI_PINS,
|
|
|
|
|
} from '../velxio-elements/breadboard-mini-element';
|
|
|
|
|
import { isBreadboard } from './breadboardNets';
|
|
|
|
|
import { calculatePinPosition } from './pinPositionCalculator';
|
|
|
|
|
|
|
|
|
|
export interface Hole {
|
|
|
|
|
name: string;
|
|
|
|
|
x: number; // element-space CSS px (relative to the breadboard inner element)
|
|
|
|
|
y: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Pt {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ComponentLike {
|
|
|
|
|
id: string;
|
|
|
|
|
metadataId: string;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
properties?: Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Magnet range for the anchor pin while dragging (px, world units). */
|
|
|
|
|
export const SNAP_TOLERANCE = 9;
|
|
|
|
|
/**
|
|
|
|
|
* A pin within this of a hole center counts as plugged in. Must absorb the
|
|
|
|
|
* worst element-geometry residual (~1.6 px — pin spacings are not exact
|
|
|
|
|
* 9.6 multiples) while staying below half the hole pitch (4.8 px) so a
|
|
|
|
|
* pin can never be ambiguous between two holes.
|
|
|
|
|
*/
|
|
|
|
|
export const SEAT_TOLERANCE = 4;
|
|
|
|
|
|
|
|
|
|
/** DynamicComponent wrapper inset: border 2 + padding 4 on every side. */
|
|
|
|
|
const WRAPPER_INSET = 6;
|
|
|
|
|
|
|
|
|
|
export function breadboardHoles(metadataId: string): readonly Hole[] | null {
|
|
|
|
|
if (metadataId === 'breadboard') return BREADBOARD_PINS;
|
|
|
|
|
if (metadataId === 'breadboard-mini') return BREADBOARD_MINI_PINS;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Nearest hole of one breadboard to a world point. Pure — hole world
|
|
|
|
|
* position is bb top-left + wrapper inset + element-space hole coords.
|
|
|
|
|
* Rotated breadboards are not supported (returns null).
|
|
|
|
|
*/
|
|
|
|
|
export function nearestHole(
|
|
|
|
|
bb: ComponentLike,
|
|
|
|
|
world: Pt,
|
|
|
|
|
maxDistance: number,
|
|
|
|
|
): { name: string; x: number; y: number; dist: number } | null {
|
|
|
|
|
if (Number(bb.properties?.rotation) || 0) return null;
|
|
|
|
|
const holes = breadboardHoles(bb.metadataId);
|
|
|
|
|
if (!holes) return null;
|
|
|
|
|
const ox = bb.x + WRAPPER_INSET;
|
|
|
|
|
const oy = bb.y + WRAPPER_INSET;
|
|
|
|
|
let best: { name: string; x: number; y: number; dist: number } | null = null;
|
|
|
|
|
for (const h of holes) {
|
|
|
|
|
const hx = ox + h.x;
|
|
|
|
|
const hy = oy + h.y;
|
|
|
|
|
const dist = Math.hypot(hx - world.x, hy - world.y);
|
|
|
|
|
if (dist <= maxDistance && (!best || dist < best.dist)) {
|
|
|
|
|
best = { name: h.name, x: hx, y: hy, dist };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function breadboardsOf(components: ComponentLike[]): ComponentLike[] {
|
|
|
|
|
return components.filter((c) => isBreadboard(c.metadataId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Pin names of a mounted component, from its DOM element's pinInfo. */
|
|
|
|
|
function pinNames(componentId: string): string[] | null {
|
|
|
|
|
const el = document.getElementById(componentId);
|
|
|
|
|
const pinInfo = el && (el as { pinInfo?: Array<{ name: string }> }).pinInfo;
|
|
|
|
|
if (!pinInfo || !Array.isArray(pinInfo) || pinInfo.length === 0) return null;
|
|
|
|
|
return pinInfo.map((p) => p.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Snap a dragged component's tentative position so its ANCHOR pin (first
|
|
|
|
|
* pinInfo entry) lands exactly on the nearest hole center. Returns the
|
|
|
|
|
* adjusted {x, y} or null when no hole is within SNAP_TOLERANCE (or the
|
|
|
|
|
* geometry cannot be measured).
|
|
|
|
|
*/
|
|
|
|
|
export function snapPositionToBreadboard(
|
|
|
|
|
comp: ComponentLike,
|
|
|
|
|
tentativeX: number,
|
|
|
|
|
tentativeY: number,
|
|
|
|
|
components: ComponentLike[],
|
|
|
|
|
): Pt | null {
|
|
|
|
|
if (isBreadboard(comp.metadataId)) return null;
|
|
|
|
|
const bbs = breadboardsOf(components);
|
|
|
|
|
if (bbs.length === 0) return null;
|
|
|
|
|
const names = pinNames(comp.id);
|
|
|
|
|
if (!names) return null;
|
|
|
|
|
const rotation = Number(comp.properties?.rotation) || 0;
|
|
|
|
|
const anchor = calculatePinPosition(
|
|
|
|
|
comp.id, names[0], tentativeX + WRAPPER_INSET, tentativeY + WRAPPER_INSET, rotation,
|
|
|
|
|
);
|
|
|
|
|
if (!anchor) return null;
|
|
|
|
|
let best: { hole: { x: number; y: number; dist: number }; } | null = null;
|
|
|
|
|
for (const bb of bbs) {
|
|
|
|
|
const hole = nearestHole(bb, anchor, SNAP_TOLERANCE);
|
|
|
|
|
if (hole && (!best || hole.dist < best.hole.dist)) best = { hole };
|
|
|
|
|
}
|
|
|
|
|
if (!best) return null;
|
|
|
|
|
return {
|
|
|
|
|
x: tentativeX + (best.hole.x - anchor.x),
|
|
|
|
|
y: tentativeY + (best.hole.y - anchor.y),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Seat {
|
|
|
|
|
pinName: string;
|
|
|
|
|
pinX: number;
|
|
|
|
|
pinY: number;
|
|
|
|
|
bbId: string;
|
|
|
|
|
holeName: string;
|
|
|
|
|
holeX: number;
|
|
|
|
|
holeY: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute the per-pin seating of a component at its CURRENT position:
|
|
|
|
|
* every pin within SEAT_TOLERANCE of a hole. Returns null when geometry
|
|
|
|
|
* cannot be measured (unmounted DOM) — callers must then leave any
|
|
|
|
|
* existing seating untouched; an empty array means "measured, not seated".
|
|
|
|
|
*/
|
|
|
|
|
export function computeSeating(
|
|
|
|
|
comp: ComponentLike,
|
|
|
|
|
components: ComponentLike[],
|
|
|
|
|
): Seat[] | null {
|
|
|
|
|
if (isBreadboard(comp.metadataId)) return [];
|
|
|
|
|
const bbs = breadboardsOf(components);
|
|
|
|
|
if (bbs.length === 0) return [];
|
|
|
|
|
const names = pinNames(comp.id);
|
|
|
|
|
if (!names) return null;
|
|
|
|
|
const rotation = Number(comp.properties?.rotation) || 0;
|
|
|
|
|
const seats: Seat[] = [];
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
const pin = calculatePinPosition(
|
|
|
|
|
comp.id, name, comp.x + WRAPPER_INSET, comp.y + WRAPPER_INSET, rotation,
|
|
|
|
|
);
|
|
|
|
|
if (!pin) continue;
|
|
|
|
|
let best: { bbId: string; hole: { name: string; x: number; y: number; dist: number } } | null =
|
|
|
|
|
null;
|
|
|
|
|
for (const bb of bbs) {
|
|
|
|
|
const hole = nearestHole(bb, pin, SEAT_TOLERANCE);
|
|
|
|
|
if (hole && (!best || hole.dist < best.hole.dist)) best = { bbId: bb.id, hole };
|
|
|
|
|
}
|
|
|
|
|
if (best) {
|
|
|
|
|
seats.push({
|
|
|
|
|
pinName: name,
|
|
|
|
|
pinX: pin.x,
|
|
|
|
|
pinY: pin.y,
|
|
|
|
|
bbId: best.bbId,
|
|
|
|
|
holeName: best.hole.name,
|
|
|
|
|
holeX: best.hole.x,
|
|
|
|
|
holeY: best.hole.y,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return seats;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True when the component's bounding-box center is over a breadboard's
|
|
|
|
|
* body — used to auto-rotate axial parts (resistors) to vertical so they
|
|
|
|
|
* bridge the center trench like on a real board.
|
|
|
|
|
*/
|
|
|
|
|
export function isOverBreadboard(
|
|
|
|
|
comp: ComponentLike,
|
|
|
|
|
tentativeX: number,
|
|
|
|
|
tentativeY: number,
|
|
|
|
|
components: ComponentLike[],
|
|
|
|
|
): boolean {
|
|
|
|
|
const el = document.getElementById(comp.id);
|
|
|
|
|
const w = el ? (el as HTMLElement).offsetWidth : 40;
|
|
|
|
|
const h = el ? (el as HTMLElement).offsetHeight : 40;
|
|
|
|
|
const cx = tentativeX + WRAPPER_INSET + w / 2;
|
|
|
|
|
const cy = tentativeY + WRAPPER_INSET + h / 2;
|
|
|
|
|
for (const bb of breadboardsOf(components)) {
|
|
|
|
|
const bbEl = document.getElementById(bb.id);
|
|
|
|
|
if (!bbEl) continue;
|
|
|
|
|
const bw = (bbEl as HTMLElement).offsetWidth;
|
|
|
|
|
const bh = (bbEl as HTMLElement).offsetHeight;
|
|
|
|
|
if (
|
|
|
|
|
cx >= bb.x + WRAPPER_INSET && cx <= bb.x + WRAPPER_INSET + bw &&
|
|
|
|
|
cy >= bb.y + WRAPPER_INSET && cy <= bb.y + WRAPPER_INSET + bh
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 23:02:41 +07:00
|
|
|
/** Axial 2-pin parts that read better vertical on a breadboard: 'resistor'
|
|
|
|
|
* plus every preconfigured 'resistor-<value>' variant. */
|
|
|
|
|
export function isAutoVerticalPart(metadataId: string): boolean {
|
|
|
|
|
return metadataId.startsWith('resistor') || metadataId.startsWith('wokwi-resistor');
|
|
|
|
|
}
|