velxio/frontend/src/utils/breadboardSnap.ts

223 lines
7.3 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 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;
}
/** Axial 2-pin parts that read better vertical on a breadboard. */
export const AUTO_VERTICAL_PARTS = new Set(['resistor', 'wokwi-resistor']);