From 5ca7e293b74b716111c9f7f38770809490063f59 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 26 May 2026 20:42:27 +0200 Subject: [PATCH] fix(canvas): use wrapper top-left for rotation pivot (#205 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix rotated overlay hotspots but the pivot was off by +6px in each axis, which manifested as a 12px X-offset for a 90° rotation (because (I - R) maps (6,6) to (12, 0) for R = 90° CW). The wrapper top-left in container-local coords is -wrapperOffsetX, not -(6 - wrapperOffsetX). The container origin already sits INSIDE the wrapper's padding+border by exactly wrapperOffsetX/Y; we have to back out by that same amount, not by 6 - that amount. Visual verification on https://velxio.dev/example/esp32-pwm-led-rgb: overlay centers of rotated resistor now match the rotated pin tips exactly (was 12px off in X). --- .../src/components/simulator/PinOverlay.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index 1a2b4dc0..7b40d138 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -114,29 +114,18 @@ export const PinOverlay: React.FC = ({ }} > {pins.map((pin, index) => { - // Container origin in CANVAS space is (componentX + wrapperOffsetX, - // componentY + wrapperOffsetY). The wrapper top-left in canvas - // space is (componentX - (6 - wrapperOffsetX), componentY - (6 - - // wrapperOffsetY)) for the DynamicComponent path, but more - // simply: padding+border on the wrapper is 6px on each side, so - // the wrapper-top-left equals containerOrigin - (6 - offset). - // Pivot for CSS rotation (transform-origin: center center) is - // wrapperTopLeft + (wrapperW/2, wrapperH/2). Express it in - // container-local coords by subtracting containerOrigin. + // Container origin in CANVAS = (componentX + wrapperOffsetX, + // componentY + wrapperOffsetY) — i.e. shifted INTO the wrapper + // by the wrapper's padding+border so pin.x can be added directly. + // The wrapper itself sits at (componentX, componentY), so its + // top-left in container-local coords is (-wrapperOffsetX, + // -wrapperOffsetY). CSS rotates around the wrapper's centre. let pinX = pin.x; let pinY = pin.y; const angle = ((rotation % 360) + 360) % 360; if (angle !== 0 && wrapperBox) { - // Wrapper interior padding+border is 6 px on each side - // (DynamicComponent: padding:4 + border:2). For boards rendered - // directly the caller passes wrapperOffsetX/Y = 0, so the - // 6 - offset arithmetic still resolves correctly (6 - 0 = 6 - // would be wrong for boards which don't have a wrapper at - // all — but boards also pass rotation = 0 so we never enter - // this branch for them). - const wrapperInner = 6; - const wrapperLeftLocal = -(wrapperInner - wrapperOffsetX); - const wrapperTopLocal = -(wrapperInner - wrapperOffsetY); + const wrapperLeftLocal = -wrapperOffsetX; + const wrapperTopLocal = -wrapperOffsetY; const pivotX = wrapperLeftLocal + wrapperBox.w / 2; const pivotY = wrapperTopLocal + wrapperBox.h / 2; const theta = (angle * Math.PI) / 180;