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';
|
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 { breadboardGroupKey, isBreadboard } from './breadboardNets';
|
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 { 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');
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// ── Full seating solver ──────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// `snapPositionToBreadboard` above is the drag-time magnet: it aligns the
|
|
|
|
|
// ANCHOR pin only and assumes the rest follow. That is fine while dragging
|
|
|
|
|
// but it is what produces HALF-SEATED parts — one pin finds a hole, the
|
|
|
|
|
// others hang off the board and are electrically dead (a 7-segment dropped
|
|
|
|
|
// slightly high seats its top pin row in bank-a and its bottom row on
|
|
|
|
|
// nothing). The solver below runs on DROP and answers a stricter question:
|
|
|
|
|
// where is the nearest position at which EVERY pin lands in a free hole?
|
|
|
|
|
//
|
|
|
|
|
// It is deliberately geometry-only — pin offsets come from the caller, which
|
|
|
|
|
// reads them off the live DOM `pinInfo`. So it works for ANY component that
|
|
|
|
|
// can be wired at all, with no per-part whitelist.
|
|
|
|
|
|
|
|
|
|
/** How far the solver may slide a part from where it was dropped, in holes.
|
|
|
|
|
* 6 pitches ≈ 58 px — enough to skip past an occupied neighbour without the
|
|
|
|
|
* part appearing to teleport across the board. */
|
|
|
|
|
const SEARCH_RADIUS_HOLES = 6;
|
|
|
|
|
const HOLE_PITCH = 9.6;
|
|
|
|
|
/**
|
|
|
|
|
* Radius used to ASSIGN pins to holes before the fine translation — half a
|
|
|
|
|
* pitch, so every pin sitting over the grid gets exactly one candidate hole.
|
|
|
|
|
* Deliberately looser than SEAT_TOLERANCE: assignment answers "which hole",
|
|
|
|
|
* the post-translation check answers "does it fit".
|
|
|
|
|
*/
|
|
|
|
|
const ASSIGN_RADIUS = HOLE_PITCH / 2;
|
|
|
|
|
/** Spatial-hash cell size. One pitch keeps ~1 hole per cell, so a 3x3 cell
|
|
|
|
|
* probe is a cheap exact-enough nearest lookup. */
|
|
|
|
|
const CELL = HOLE_PITCH;
|
|
|
|
|
|
|
|
|
|
/** A pin's position relative to the component's x/y (rotation applied). */
|
|
|
|
|
export interface PinOffset {
|
|
|
|
|
name: string;
|
|
|
|
|
dx: number;
|
|
|
|
|
dy: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Placement {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
/** hole name per pin, in the same order as the input offsets. */
|
|
|
|
|
holes: { pinName: string; holeName: string }[];
|
|
|
|
|
/** Distance from the requested position — 0 when it was already correct. */
|
|
|
|
|
moved: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface IndexedHoles {
|
|
|
|
|
cells: Map<string, Hole[]>;
|
|
|
|
|
ox: number;
|
|
|
|
|
oy: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cellKey(x: number, y: number): string {
|
|
|
|
|
return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Bucket a breadboard's holes by world position for O(1) nearest lookup. */
|
|
|
|
|
function indexHoles(bb: ComponentLike): IndexedHoles | null {
|
|
|
|
|
const holes = breadboardHoles(bb.metadataId);
|
|
|
|
|
if (!holes) return null;
|
|
|
|
|
const ox = bb.x + WRAPPER_INSET;
|
|
|
|
|
const oy = bb.y + WRAPPER_INSET;
|
|
|
|
|
const cells = new Map<string, Hole[]>();
|
|
|
|
|
for (const h of holes) {
|
|
|
|
|
const key = cellKey(ox + h.x, oy + h.y);
|
|
|
|
|
const bucket = cells.get(key);
|
|
|
|
|
if (bucket) bucket.push(h);
|
|
|
|
|
else cells.set(key, [h]);
|
|
|
|
|
}
|
|
|
|
|
return { cells, ox, oy };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Nearest hole to a world point via the spatial hash, or null past `max`. */
|
|
|
|
|
function lookupHole(idx: IndexedHoles, x: number, y: number, max: number): Hole | null {
|
|
|
|
|
const cx = Math.floor(x / CELL);
|
|
|
|
|
const cy = Math.floor(y / CELL);
|
|
|
|
|
let best: Hole | null = null;
|
|
|
|
|
let bestDist = max;
|
|
|
|
|
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
|
|
|
|
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
|
|
|
|
const bucket = idx.cells.get(`${gx},${gy}`);
|
|
|
|
|
if (!bucket) continue;
|
|
|
|
|
for (const h of bucket) {
|
|
|
|
|
const d = Math.hypot(idx.ox + h.x - x, idx.oy + h.y - y);
|
|
|
|
|
if (d <= bestDist) {
|
|
|
|
|
bestDist = d;
|
|
|
|
|
best = h;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Nearest position at which every pin sits in a free hole of ONE breadboard.
|
|
|
|
|
*
|
|
|
|
|
* Completeness note: candidates are generated by moving the FIRST pin onto
|
|
|
|
|
* each nearby hole. That loses nothing — in any fully-seated placement every
|
|
|
|
|
* pin is on a hole, the first one included — so enumerating the first pin's
|
|
|
|
|
* possible holes enumerates every valid placement, at 1/N the cost of
|
|
|
|
|
* trying all pins.
|
|
|
|
|
*
|
|
|
|
|
* @param pins pin offsets from the component origin, rotation already applied
|
|
|
|
|
* @param bb the target breadboard
|
|
|
|
|
* @param occupied hole names already taken on this board (by other parts)
|
|
|
|
|
* @param wantX/wantY where the user dropped it
|
|
|
|
|
* @returns the closest valid placement, or null when the part does not fit
|
|
|
|
|
* anywhere within the search radius.
|
|
|
|
|
*/
|
|
|
|
|
export function solvePlacement(
|
|
|
|
|
pins: PinOffset[],
|
|
|
|
|
bb: ComponentLike,
|
|
|
|
|
occupied: ReadonlySet<string>,
|
|
|
|
|
wantX: number,
|
|
|
|
|
wantY: number,
|
|
|
|
|
): Placement | null {
|
|
|
|
|
if (pins.length === 0) return null;
|
|
|
|
|
const idx = indexHoles(bb);
|
|
|
|
|
if (!idx) return null;
|
|
|
|
|
|
|
|
|
|
const holes = breadboardHoles(bb.metadataId)!;
|
|
|
|
|
const anchor = pins[0];
|
|
|
|
|
const anchorX = wantX + anchor.dx;
|
|
|
|
|
const anchorY = wantY + anchor.dy;
|
|
|
|
|
const radius = SEARCH_RADIUS_HOLES * HOLE_PITCH;
|
|
|
|
|
|
|
|
|
|
// Each hole near the anchor is one HYPOTHESIS about which hole the anchor
|
|
|
|
|
// belongs to. It is not the final position: see the fine translation below.
|
|
|
|
|
const candidates: { x: number; y: number }[] = [];
|
|
|
|
|
for (const h of holes) {
|
|
|
|
|
const hx = idx.ox + h.x;
|
|
|
|
|
const hy = idx.oy + h.y;
|
|
|
|
|
if (Math.hypot(hx - anchorX, hy - anchorY) > radius) continue;
|
|
|
|
|
candidates.push({ x: hx - anchor.dx, y: hy - anchor.dy });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let best: Placement | null = null;
|
|
|
|
|
for (const cand of candidates) {
|
|
|
|
|
const assigned: { pinName: string; holeName: string; hx: number; hy: number }[] = [];
|
|
|
|
|
const usedHere = new Set<string>();
|
|
|
|
|
const groupsHere = new Set<string>();
|
|
|
|
|
let ok = true;
|
|
|
|
|
let sumDx = 0;
|
|
|
|
|
let sumDy = 0;
|
|
|
|
|
for (const pin of pins) {
|
|
|
|
|
const px = cand.x + pin.dx;
|
|
|
|
|
const py = cand.y + pin.dy;
|
|
|
|
|
// ASSIGN_RADIUS, not SEAT_TOLERANCE: this pass only decides WHICH hole
|
|
|
|
|
// each pin belongs to. Judging fit here would reject any footprint the
|
|
|
|
|
// anchor hypothesis cannot satisfy exactly.
|
|
|
|
|
const hole = lookupHole(idx, px, py, ASSIGN_RADIUS);
|
|
|
|
|
// Every pin must find a hole, and no two pins may share one — a part
|
|
|
|
|
// whose own pins collide is a geometry bug, not a valid seating.
|
|
|
|
|
if (!hole || occupied.has(hole.name) || usedHere.has(hole.name)) {
|
|
|
|
|
ok = false;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
// Nor may two pins land in the same STRIP: a column strip (and worse, a
|
|
|
|
|
// power rail) is a single net, so that silently shorts the part to
|
|
|
|
|
// itself. Geometrically legal, electrically ruinous — a 7-segment will
|
|
|
|
|
// happily lay its pins across a rail without this.
|
|
|
|
|
const group = breadboardGroupKey(bb.metadataId, hole.name);
|
|
|
|
|
if (group !== null) {
|
|
|
|
|
if (groupsHere.has(group)) {
|
|
|
|
|
ok = false;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
groupsHere.add(group);
|
|
|
|
|
}
|
|
|
|
|
usedHere.add(hole.name);
|
|
|
|
|
const hx = idx.ox + hole.x;
|
|
|
|
|
const hy = idx.oy + hole.y;
|
|
|
|
|
assigned.push({ pinName: pin.name, holeName: hole.name, hx, hy });
|
|
|
|
|
sumDx += hx - px;
|
|
|
|
|
sumDy += hy - py;
|
|
|
|
|
}
|
|
|
|
|
if (!ok) continue;
|
|
|
|
|
|
|
|
|
|
// Fine translation: the centroid of the pin-to-hole residuals, which
|
|
|
|
|
// minimises the sum of squared distances for this assignment.
|
|
|
|
|
//
|
|
|
|
|
// This is what makes off-pitch footprints seatable at all. A diode spans
|
|
|
|
|
// 7.5 pitches, so pinning one leg dead-centre leaves the other 4.8 px
|
|
|
|
|
// out — beyond tolerance, rejected. Shift the whole part by 2.4 px and
|
|
|
|
|
// BOTH legs sit 2.4 px off centre, comfortably inside tolerance. That is
|
|
|
|
|
// what bending the leads does on a real board.
|
|
|
|
|
const tx = sumDx / pins.length;
|
|
|
|
|
const ty = sumDy / pins.length;
|
|
|
|
|
const fx = cand.x + tx;
|
|
|
|
|
const fy = cand.y + ty;
|
|
|
|
|
|
|
|
|
|
// Now judge fit, strictly, against the assignment we just committed to.
|
|
|
|
|
// Staying under SEAT_TOLERANCE (< half pitch) keeps every pin's nearest
|
|
|
|
|
// hole unambiguous, so computeSeating later resolves the same holes and
|
|
|
|
|
// the netlist is unaffected by the offset.
|
|
|
|
|
for (let i = 0; i < pins.length; i++) {
|
|
|
|
|
const a = assigned[i];
|
|
|
|
|
if (Math.hypot(a.hx - (fx + pins[i].dx), a.hy - (fy + pins[i].dy)) > SEAT_TOLERANCE) {
|
|
|
|
|
ok = false;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!ok) continue;
|
|
|
|
|
|
|
|
|
|
// Evaluate every candidate rather than taking the first: the translation
|
|
|
|
|
// reorders things, so the nearest hypothesis need not yield the nearest
|
|
|
|
|
// final position. Ties keep the earlier candidate (grid emission order).
|
|
|
|
|
const moved = Math.hypot(fx - wantX, fy - wantY);
|
|
|
|
|
if (!best || moved < best.moved) {
|
|
|
|
|
best = {
|
|
|
|
|
x: fx,
|
|
|
|
|
y: fy,
|
|
|
|
|
holes: assigned.map((a) => ({ pinName: a.pinName, holeName: a.holeName })),
|
|
|
|
|
moved,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Pin offsets of a mounted component, read off the live DOM. Generic: any
|
|
|
|
|
* element with a `pinInfo` getter works, which is every wireable part. */
|
|
|
|
|
function pinOffsets(comp: ComponentLike, atX: number, atY: number): PinOffset[] | null {
|
|
|
|
|
const raw = pinNames(comp.id);
|
|
|
|
|
if (!raw) return null;
|
|
|
|
|
// Deduplicate: some parts declare repeated pin names (STM32 boards carry
|
|
|
|
|
// GND x5, 3V3 x4). calculatePinPosition resolves by name and always finds
|
|
|
|
|
// the FIRST match, so duplicates would all report the same coordinates and
|
|
|
|
|
// then collide on one hole, failing the part outright. Solving the unique
|
|
|
|
|
// names seats the part; the repeats ride along on their own strips.
|
|
|
|
|
const names = [...new Set(raw)];
|
|
|
|
|
const rotation = Number(comp.properties?.rotation) || 0;
|
|
|
|
|
const offsets: PinOffset[] = [];
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
const p = calculatePinPosition(comp.id, name, atX + WRAPPER_INSET, atY + WRAPPER_INSET, rotation);
|
|
|
|
|
if (!p) return null; // partial geometry would seat the part wrong
|
|
|
|
|
offsets.push({ name, dx: p.x - atX, dy: p.y - atY });
|
|
|
|
|
}
|
|
|
|
|
return offsets.length > 0 ? offsets : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Holes already taken on `bb`, by every component except `exceptId`. */
|
|
|
|
|
function occupiedHoles(
|
|
|
|
|
bb: ComponentLike,
|
|
|
|
|
components: ComponentLike[],
|
|
|
|
|
exceptId: string,
|
|
|
|
|
): Set<string> {
|
|
|
|
|
const taken = new Set<string>();
|
|
|
|
|
for (const other of components) {
|
|
|
|
|
if (other.id === exceptId || other.id === bb.id) continue;
|
|
|
|
|
if (isBreadboard(other.metadataId)) continue;
|
|
|
|
|
const seats = computeSeating(other, components);
|
|
|
|
|
if (!seats) continue;
|
|
|
|
|
for (const s of seats) {
|
|
|
|
|
if (s.bbId === bb.id) taken.add(s.holeName);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return taken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Drop-time seating: given where the user let go of a part, return the
|
|
|
|
|
* nearest position where it is FULLY seated on a breadboard and collides
|
|
|
|
|
* with nothing. Returns null when it does not belong on a board at all, or
|
|
|
|
|
* when no free spot exists nearby — callers then leave it where it was
|
|
|
|
|
* dropped rather than forcing a wrong seating.
|
|
|
|
|
*
|
|
|
|
|
* Works for every component with `pinInfo`; there is no part whitelist.
|
|
|
|
|
*/
|
|
|
|
|
export function seatOnDrop(
|
|
|
|
|
comp: ComponentLike,
|
|
|
|
|
droppedX: number,
|
|
|
|
|
droppedY: number,
|
|
|
|
|
components: ComponentLike[],
|
|
|
|
|
): Placement | null {
|
|
|
|
|
if (isBreadboard(comp.metadataId)) return null;
|
|
|
|
|
const bbs = breadboardsOf(components);
|
|
|
|
|
if (bbs.length === 0) return null;
|
|
|
|
|
const pins = pinOffsets(comp, droppedX, droppedY);
|
|
|
|
|
if (!pins) return null;
|
|
|
|
|
|
|
|
|
|
// Only consider boards the part is actually near — dropping a part on the
|
|
|
|
|
// left board must not fling it onto one across the canvas.
|
|
|
|
|
let best: Placement | null = null;
|
|
|
|
|
for (const bb of bbs) {
|
|
|
|
|
const placement = solvePlacement(
|
|
|
|
|
pins,
|
|
|
|
|
bb,
|
|
|
|
|
occupiedHoles(bb, components, comp.id),
|
|
|
|
|
droppedX,
|
|
|
|
|
droppedY,
|
|
|
|
|
);
|
|
|
|
|
if (placement && (!best || placement.moved < best.moved)) best = placement;
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|