576 lines
21 KiB
TypeScript
576 lines
21 KiB
TypeScript
/**
|
|
* 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 { breadboardGroupKey, 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: 'resistor'
|
|
* plus every preconfigured 'resistor-<value>' variant. */
|
|
export function isAutoVerticalPart(metadataId: string): boolean {
|
|
return metadataId.startsWith('resistor') || metadataId.startsWith('wokwi-resistor');
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/**
|
|
* Correct an agent-placed component's position so its anchor pin lands where
|
|
* the server's solver decided it should. This is the seating the agent already
|
|
* solved server-side; the browser only re-does the final positioning.
|
|
*
|
|
* Why the browser has to redo it: the backend computes the exact anchor target
|
|
* in BREADBOARD-ELEMENT space (pivot-free, so rotation-safe) but only an
|
|
* APPROXIMATE canvas x/y, because the true rotation pivot is the DOM wrapper
|
|
* centre — and the wrapper includes a text label whose width the server cannot
|
|
* measure. Under rotation that leaves the part off by enough that
|
|
* `computeSeating` finds no holes and the part sits visibly disconnected.
|
|
*
|
|
* `anchorX/anchorY` are the anchor pin's position in the breadboard's element
|
|
* space, straight from the solver — and they already carry the sub-pitch
|
|
* "fine translation" the solver applies to off-lattice footprints (a diode
|
|
* spans 7.5 pitches, so its anchor is deliberately ~2.4 px off a hole centre
|
|
* to split the error). Targeting the hole CENTRE instead would leave the far
|
|
* pin 4.8 px out and half-seat the part — verified against real DOM geometry.
|
|
*
|
|
* The fix is a pure TRANSLATION, never a re-solve: read where the anchor pin
|
|
* actually is from the live DOM (real pivot), read where the solver put it,
|
|
* and shift the whole part by the difference. Every other pin follows, because
|
|
* pin-to-pin offsets do not depend on the pivot. It cannot slide the part to
|
|
* different holes, so the netlist the agent validated is preserved.
|
|
*
|
|
* Returns the corrected {x, y}, or null when the geometry cannot be measured
|
|
* yet (element not mounted) — callers keep the backend's hint and retry.
|
|
*/
|
|
export function resolveSeatPosition(
|
|
comp: ComponentLike,
|
|
bbId: string,
|
|
anchorPin: string,
|
|
anchorX: number,
|
|
anchorY: number,
|
|
components: ComponentLike[],
|
|
): Pt | null {
|
|
const bb = components.find((c) => c.id === bbId);
|
|
if (!bb || !isBreadboard(bb.metadataId)) return null;
|
|
const rotation = Number(comp.properties?.rotation) || 0;
|
|
const anchor = calculatePinPosition(
|
|
comp.id,
|
|
anchorPin,
|
|
comp.x + WRAPPER_INSET,
|
|
comp.y + WRAPPER_INSET,
|
|
rotation,
|
|
);
|
|
if (!anchor) return null; // DOM not ready — caller retries on the next frame
|
|
const targetX = bb.x + WRAPPER_INSET + anchorX;
|
|
const targetY = bb.y + WRAPPER_INSET + anchorY;
|
|
return { x: comp.x + (targetX - anchor.x), y: comp.y + (targetY - anchor.y) };
|
|
}
|