velxio/frontend/src/utils/pinPositionCalculator.ts

182 lines
6.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) {
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)) {
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
// + border. updateWirePositions / recalculateAllWirePositions add
// (+4, +6) to component.x / component.y to land on the inner-element
// top-left, so the wrapper top-left is (componentX - 4, componentY - 6).
// This convention is hardcoded in the store; we honour it here so the
// math stays consistent across the rotation boundary.
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 - 4;
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 };
}
/**
* 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;
}