velxio/frontend/src/utils/pinPositionCalculator.ts

252 lines
10 KiB
TypeScript

/**
* Pin Position Calculator
*
* Converts pin coordinates from element space to canvas space (pixels).
* This is the CRITICAL piece for wire system - without accurate pin positions,
* wires cannot connect properly to components.
*
* Coordinate Systems:
* 1. Element Space: Pin positions in pinInfo are in CSS pixels relative to element origin
* 2. Canvas Space: Absolute positioning in pixels on the canvas
*
* Note: wokwi-elements pinInfo x/y are already in CSS pixels.
*/
/**
* Calculates the absolute canvas position of a specific pin.
*
* @param componentId - The DOM ID of the component element
* @param pinName - The name of the pin (e.g., 'A', 'C', 'GND.1', '13')
* @param componentX - Component's X position on canvas (pixels). For a
* rotated component this is still the UNROTATED inner-element top-left
* (callers already add the wrapper offset of 4 horizontal / 6 vertical).
* @param componentY - Component's Y position on canvas (pixels)
* @param rotation - Optional CSS rotation in degrees applied to the
* component's wrapper (0 / 90 / 180 / 270). When non-zero the pin
* position is rotated around the wrapper's center so wire endpoints
* land on the visually-rotated pin instead of the old layout-space pin.
* @returns Absolute canvas coordinates { x, y } or null if pin not found
*/
export function calculatePinPosition(
componentId: string,
pinName: string,
componentX: number,
componentY: number,
rotation: number = 0,
): { x: number; y: number } | null {
// Get the DOM element
const element = document.getElementById(componentId);
if (!element) {
// Don't spam the vitest log: in node-side tests there's no real
// DOM and this function gets called per-wire on every render
// (each one logs "Component foo not found in DOM"). In a browser
// the warning is actionable — a wire references a component that
// failed to mount.
if (import.meta.env.MODE !== 'test') {
console.warn(`[pinPositionCalculator] Component ${componentId} not found in DOM`);
}
return null;
}
// Access the pinInfo property (all wokwi-elements expose this)
const pinInfo = (element as any).pinInfo;
if (!pinInfo || !Array.isArray(pinInfo)) {
if (import.meta.env.MODE !== 'test') {
console.warn(`[pinPositionCalculator] Component ${componentId} does not have pinInfo`);
}
return null;
}
// Find the specific pin
let pin = pinInfo.find((p: any) => p.name === pinName);
// Fallback: try numbered variant (e.g. GND → GND.1) for pins that have suffix variants
if (!pin && !pinName.includes('.')) {
pin = pinInfo.find((p: any) => p.name === `${pinName}.1`);
}
// Fallback: GP-prefix → match description field (e.g. 'GP15' → description 'GPIO15')
// Needed for Nano RP2040 Connect which uses D-prefix pin names but GPIO descriptions
if (!pin && pinName.startsWith('GP')) {
const gpioNum = parseInt(pinName.substring(2), 10);
if (!isNaN(gpioNum)) {
pin = pinInfo.find((p: any) => p.description === `GPIO${gpioNum}`);
}
}
// Generic alias fallbacks — every unresolved endpoint used to silently
// anchor at the board CORNER (0,0 of the element), which users read as a
// broken circuit. The names below are electrically identical spellings
// that boards/examples/saved projects disagree on (a 2026-08 audit found
// 15 gallery examples and 28 saved projects wired to a spelling their
// board element doesn't expose). Only consulted after exact + the two
// legacy fallbacks above miss, so no existing resolution changes.
if (!pin) {
const candidates: string[] = [];
const dot = pinName.match(/^(.+)\.\d+$/);
if (dot) candidates.push(dot[1]); // GND.1 -> GND (esp32 classic)
if (pinName === '3.3V') candidates.push('3V3', '3V3.1', '3V');
if (pinName === '3V3') candidates.push('3V3.1', '3.3V', '3V');
if (pinName === '5V') candidates.push('5V.1', 'VBUS', 'VIN');
if (pinName === 'GND2') candidates.push('GND.2');
const dNum = pinName.match(/^D(\d+)$/);
if (dNum) candidates.push(dNum[1], `GP${dNum[1]}`); // D13 -> 13 (esp32) / GP13 (pico)
if (/^\d+$/.test(pinName)) candidates.push(`D${pinName}`, `GP${pinName}`);
if (pinName === '36') candidates.push('VP'); // ESP32 devkit silk for GPIO36
if (pinName === '39') candidates.push('VN'); // ESP32 devkit silk for GPIO39
for (const c of candidates) {
pin = pinInfo.find((p: any) => p.name === c);
if (pin) break;
}
}
if (!pin) {
console.warn(`[pinPositionCalculator] Pin ${pinName} not found on component ${componentId}`);
console.warn(
`Available pins:`,
pinInfo.map((p: any) => p.name),
);
return null;
}
// Unrotated pin position in canvas space.
let pinX = componentX + pin.x;
let pinY = componentY + pin.y;
// Rotation: the DynamicComponent wrapper applies
// transform: rotate(<deg>deg); transform-origin: center center;
// around its OWN center (the wrapper, not the inner web component). So
// when the user rotates 90° the pin moves on an arc centered on the
// wrapper center, not on the component origin or the pin's own axis.
//
// We compute the wrapper center in canvas space from `offsetWidth /
// offsetHeight` of the wrapper — those reflect the layout box and are
// UNAFFECTED by CSS transforms, so reading them right after a state
// change but before React commits the new transform is safe.
//
// Wrapper top-left ≈ inner-element top-left minus the wrapper padding
// + border. The DynamicComponent wrapper has padding:4px + border:2px
// on EVERY side → the inner element sits 6 px in from the wrapper on
// both axes. updateWirePositions / recalculateAllWirePositions add
// (+6, +6) to component.x / component.y to land on the inner-element
// top-left, so the wrapper top-left is (componentX - 6, componentY - 6).
// The earlier code used (+4, +6) — a 2 px X bias that was invisible
// unrotated (a wire endpoint two pixels off looks fine) but rotated
// with the component, surfacing as an obvious "wires disconnected
// from the pin" once the user pressed R.
const angle = ((rotation % 360) + 360) % 360;
if (angle !== 0) {
const wrapper = element.closest('.dynamic-component-wrapper') as HTMLElement | null;
if (wrapper) {
const wrapperW = wrapper.offsetWidth;
const wrapperH = wrapper.offsetHeight;
const wrapperLeft = componentX - 6;
const wrapperTop = componentY - 6;
const pivotX = wrapperLeft + wrapperW / 2;
const pivotY = wrapperTop + wrapperH / 2;
const theta = (angle * Math.PI) / 180;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
const dx = pinX - pivotX;
const dy = pinY - pivotY;
pinX = pivotX + (dx * cos - dy * sin);
pinY = pivotY + (dx * sin + dy * cos);
}
}
return { x: pinX, y: pinY };
}
/**
* Rotate a pin's element-space (x, y) around the DynamicComponent wrapper
* centre, returning coordinates LOCAL to the pin-overlay container (whose
* origin is the inner element top-left, i.e. component pos + wrapper inset).
*
* Shared by every always-/hover-rendered pin layer (wire-target boxes,
* seated-pin markers) so they can never drift apart: the wrapper is rotated
* by CSS `transform: rotate()` about `center center`, but the overlay layers
* live OUTSIDE that wrapper and must reproduce the rotation manually.
* `wrapperBox` is the wrapper's UNROTATED layout box (offsetWidth/Height),
* which is what CSS rotates around; pass null (or angle 0) to skip rotation.
*/
export function rotatePinLocal(
x: number,
y: number,
rotation: number,
wrapperBox: { w: number; h: number } | null,
wrapperOffsetX: number,
wrapperOffsetY: number,
): { x: number; y: number } {
const angle = ((rotation % 360) + 360) % 360;
if (angle === 0 || !wrapperBox) return { x, y };
const pivotX = -wrapperOffsetX + wrapperBox.w / 2;
const pivotY = -wrapperOffsetY + wrapperBox.h / 2;
const theta = (angle * Math.PI) / 180;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
const dx = x - pivotX;
const dy = y - pivotY;
return { x: pivotX + dx * cos - dy * sin, y: pivotY + dx * sin + dy * cos };
}
/**
* Gets all pins for a component with their absolute canvas positions.
* Useful for rendering pin overlays and finding nearby pins.
*
* @param componentId - The DOM ID of the component element
* @param componentX - Component's X position on canvas
* @param componentY - Component's Y position on canvas
* @returns Array of pins with absolute positions and signal info
*/
export function getAllPinPositions(
componentId: string,
componentX: number,
componentY: number,
): Array<{ name: string; x: number; y: number; signals: any[] }> {
const element = document.getElementById(componentId);
if (!element) return [];
const pinInfo = (element as any).pinInfo;
if (!pinInfo || !Array.isArray(pinInfo)) return [];
return pinInfo.map((pin: any) => ({
name: pin.name,
x: componentX + pin.x,
y: componentY + pin.y,
signals: pin.signals || [],
}));
}
/**
* Finds the closest pin to a given canvas position.
* Useful for snapping wire endpoints to nearby pins.
*
* @param componentId - The component to search
* @param componentX - Component's X position
* @param componentY - Component's Y position
* @param targetX - Target X coordinate to find nearest pin
* @param targetY - Target Y coordinate to find nearest pin
* @param maxDistance - Maximum distance in pixels to consider (default 20)
* @returns Closest pin info or null if none within maxDistance
*/
export function findClosestPin(
componentId: string,
componentX: number,
componentY: number,
targetX: number,
targetY: number,
maxDistance: number = 20,
): { name: string; x: number; y: number; signals: any[] } | null {
const pins = getAllPinPositions(componentId, componentX, componentY);
let closestPin: { name: string; x: number; y: number; signals: any[] } | null = null;
let minDistance = maxDistance;
for (const pin of pins) {
const distance = Math.sqrt(Math.pow(pin.x - targetX, 2) + Math.pow(pin.y - targetY, 2));
if (distance < minDistance) {
minDistance = distance;
closestPin = pin;
}
}
return closestPin;
}