velxio/frontend/src/utils/pinPositionCalculator.ts

227 lines
8.7 KiB
TypeScript
Raw Normal View History

/**
* 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')
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
* @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)
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
* @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,
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
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}`);
}
}
if (!pin) {
console.warn(`[pinPositionCalculator] Pin ${pinName} not found on component ${componentId}`);
console.warn(
`Available pins:`,
pinInfo.map((p: any) => p.name),
);
return null;
}
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
// 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
fix(canvas): wires off pins after rotation — wrapper offset was (4,6) instead of (6,6) User report: "rotating components messes up their connections" — pressing R on a placed component visibly slid every wire endpoint off its pin tip. Root cause: the DynamicComponent wrapper has padding:4px + border:2px on EVERY side, so the inner web-component element sits 6 px in from the wrapper top-left on BOTH axes. The wire layer assumed an asymmetric (4, 6) offset, baked into: * useSimulatorStore.updateWirePositions — store.x + 4, store.y + 6 * useSimulatorStore.recalculateAllWirePositions — start (startComp.x + 4, startComp.y + 6) — end (endComp.x + 4, endComp.y + 6) * pinPositionCalculator.calculatePinPosition — inverse: (componentX - 4, componentY - 6) Unrotated the 2 px X bias was visible only as a very-slightly-off wire, which nobody filed. When the user rotated the component, the bias rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX read as "wires disconnected". Verified the real CSS box via chrome-devtools-mcp against several live components on velxio.dev (RGB LED + 3 resistors + analog joystick): all report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6 on both axes. Fix: use (+6, +6) at every site, single source of truth in a comment explaining padding+border arithmetic. Updated the rotation regression test to match the corrected math (numbers shift by 2 px on every expectation that referenced the old offset). Pin position math, pivot derivation and the rotate-N×90° round trip unchanged — only the offset constant moved.
2026-05-26 20:15:50 +07:00
// + 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.
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
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;
fix(canvas): wires off pins after rotation — wrapper offset was (4,6) instead of (6,6) User report: "rotating components messes up their connections" — pressing R on a placed component visibly slid every wire endpoint off its pin tip. Root cause: the DynamicComponent wrapper has padding:4px + border:2px on EVERY side, so the inner web-component element sits 6 px in from the wrapper top-left on BOTH axes. The wire layer assumed an asymmetric (4, 6) offset, baked into: * useSimulatorStore.updateWirePositions — store.x + 4, store.y + 6 * useSimulatorStore.recalculateAllWirePositions — start (startComp.x + 4, startComp.y + 6) — end (endComp.x + 4, endComp.y + 6) * pinPositionCalculator.calculatePinPosition — inverse: (componentX - 4, componentY - 6) Unrotated the 2 px X bias was visible only as a very-slightly-off wire, which nobody filed. When the user rotated the component, the bias rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX read as "wires disconnected". Verified the real CSS box via chrome-devtools-mcp against several live components on velxio.dev (RGB LED + 3 resistors + analog joystick): all report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6 on both axes. Fix: use (+6, +6) at every site, single source of truth in a comment explaining padding+border arithmetic. Updated the rotation regression test to match the corrected math (numbers shift by 2 px on every expectation that referenced the old offset). Pin position math, pivot derivation and the rotate-N×90° round trip unchanged — only the offset constant moved.
2026-05-26 20:15:50 +07:00
const wrapperLeft = componentX - 6;
fix(canvas): wires follow components on rotation Rotating a part with the 90° button used to leave every wire pinned to the pre-rotation pixel coordinates — the component visually unhooked from its cables. Two paths were missing: 1. useSimulatorStore.updateComponent only triggered updateWirePositions for x/y changes. A rotation went through properties.rotation, so wires never recomputed. 2. calculatePinPosition didn't know about rotation. Even when called, it returned the unrotated offset, so the new endpoints would still have been wrong. 3. recordRotate (undo/redo) skipped updateWirePositions on both legs, so Ctrl+Z after a rotate left the canvas inconsistent. Fix: - calculatePinPosition gets a 5th `rotation` argument. When non-zero, it finds the .dynamic-component-wrapper ancestor in the DOM, reads its offsetWidth/Height (layout-only, immune to CSS transforms) to locate the wrapper centre, and applies a 2D rotation matrix around that pivot. The wrapper top-left is recovered as (componentX - 4, componentY - 6) to match the offset convention updateWirePositions already uses. - updateWirePositions and recalculateAllWirePositions read the per- component rotation and thread it through. - updateComponent recomputes wires whenever properties.rotation changes, mirroring the existing x/y path. - recordRotate.execute and .undo both call updateWirePositions so Ctrl+Z keeps the canvas coherent. Tests (pin-position-rotation.test.ts, 6 cases): unrotated identity, 90° (left edge → bottom), 180° (point reflection), 360° round-trip, negative angles, and a store-level integration that rotates a fake component and asserts wires[0].start moves to the rotated coordinate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 04:03:32 +07:00
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;
}