diff --git a/lib/getCanvasObjectLabelAtPoint.ts b/lib/getCanvasObjectLabelAtPoint.ts index a4f7463..01e0cdd 100644 --- a/lib/getCanvasObjectLabelAtPoint.ts +++ b/lib/getCanvasObjectLabelAtPoint.ts @@ -1,5 +1,5 @@ import { getArrowGeometry } from "./arrowHelpers" -import { getRectCorners } from "./rectGeometry" +import { getProjectedRectGeometry } from "./rectGeometry" import type { Arrow, Circle, @@ -186,9 +186,7 @@ const isRectHit = ( matrix: Matrix, hitSlop: number, ) => { - const corners = getRectCorners(rect).map((point) => - projectPoint(matrix, point), - ) + const corners = getProjectedRectGeometry(rect, matrix).corners return ( isPointInPolygon(screenPoint, corners) || diff --git a/lib/rectGeometry.ts b/lib/rectGeometry.ts index 4a0e668..27308fb 100644 --- a/lib/rectGeometry.ts +++ b/lib/rectGeometry.ts @@ -13,6 +13,19 @@ export const getRectRotationRadians = ( return ((rect.ccwRotationDegrees ?? 0) * Math.PI) / 180 } +const getScreenRotationMultiplier = (matrix: Matrix) => { + // Screen coordinates have +Y downward. Orientation-preserving transforms + // therefore need the math angle inverted to look visually CCW. + const determinant = matrix.a * matrix.d - matrix.b * matrix.c + return determinant < 0 ? 1 : -1 +} + +export const getRectRotationRadiansForMatrix = ( + rect: Pick, + matrix: Matrix, +) => { + return getRectRotationRadians(rect) * getScreenRotationMultiplier(matrix) +} const rotatePoint = (point: XYPoint, angleRadians: number): XYPoint => { const cos = Math.cos(angleRadians) const sin = Math.sin(angleRadians) @@ -25,10 +38,26 @@ const rotatePoint = (point: XYPoint, angleRadians: number): XYPoint => { export const getRectCorners = ( rect: Pick, +): Point[] => { + return getRectCornersWithAngle(rect, getRectRotationRadians(rect)) +} + +export const getRectCornersForMatrix = ( + rect: Pick, + matrix: Matrix, +): Point[] => { + return getRectCornersWithAngle( + rect, + getRectRotationRadiansForMatrix(rect, matrix), + ) +} + +const getRectCornersWithAngle = ( + rect: Pick, + angleRadians: number, ): Point[] => { const halfWidth = rect.width / 2 const halfHeight = rect.height / 2 - const angleRadians = getRectRotationRadians(rect) return [ { x: -halfWidth, y: -halfHeight }, @@ -69,7 +98,7 @@ export const getProjectedRectGeometry = ( rect: Pick, matrix: Matrix, ) => { - const corners = getRectCorners(rect).map((point) => + const corners = getRectCornersForMatrix(rect, matrix).map((point) => applyToPoint(matrix, point), ) const center = applyToPoint(matrix, rect.center) diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx index b4c76cc..3db85b5 100644 --- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx +++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx @@ -387,6 +387,7 @@ export const InteractiveGraphics = ({ }) const filterRects = useFilterRects({ + realToScreen, isPointOnScreen, doesLineIntersectViewport, filterLayerAndStep, diff --git a/site/components/InteractiveGraphics/hooks/useFilterRects.ts b/site/components/InteractiveGraphics/hooks/useFilterRects.ts index 457cd9c..d1de3a3 100644 --- a/site/components/InteractiveGraphics/hooks/useFilterRects.ts +++ b/site/components/InteractiveGraphics/hooks/useFilterRects.ts @@ -1,5 +1,6 @@ +import { getRectCornersForMatrix } from "lib/rectGeometry" import { useMemo } from "react" -import { getRectCorners } from "lib/rectGeometry" +import type { Matrix } from "transformation-matrix" type Rect = { center: { x: number; y: number } @@ -11,6 +12,7 @@ type Rect = { } type UseFilterRectsParams = { + realToScreen: Matrix isPointOnScreen: (point: { x: number; y: number }) => boolean doesLineIntersectViewport: ( p1: { x: number; y: number }, @@ -20,6 +22,7 @@ type UseFilterRectsParams = { } export const useFilterRects = ({ + realToScreen, isPointOnScreen, doesLineIntersectViewport, filterLayerAndStep, @@ -31,7 +34,8 @@ export const useFilterRects = ({ // For rectangles, check if any corner or the center is visible const { center } = rect - const [topLeft, topRight, bottomRight, bottomLeft] = getRectCorners(rect) + const [topLeft, topRight, bottomRight, bottomLeft] = + getRectCornersForMatrix(rect, realToScreen) // Check if any corner or center is visible if ( @@ -52,5 +56,10 @@ export const useFilterRects = ({ doesLineIntersectViewport(bottomLeft, topLeft) ) } - }, [isPointOnScreen, doesLineIntersectViewport, filterLayerAndStep]) + }, [ + realToScreen, + isPointOnScreen, + doesLineIntersectViewport, + filterLayerAndStep, + ]) } diff --git a/tests/SVGRenderer.test.tsx b/tests/SVGRenderer.test.tsx index 5216b15..2e7d39e 100644 --- a/tests/SVGRenderer.test.tsx +++ b/tests/SVGRenderer.test.tsx @@ -1,10 +1,15 @@ import { describe, expect, test, beforeAll } from "bun:test" +import { act } from "react" import { createRoot } from "react-dom/client" import SVGRenderer from "../site/components/SVGRenderer" import "bun-match-svg" import * as jsdom from "jsdom" import { getSvgsFromLogString } from "../lib" +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean +} + // Setup global DOM environment beforeAll(() => { const { JSDOM } = jsdom @@ -13,10 +18,11 @@ beforeAll(() => { global.document = dom.window.document global.window = dom.window as unknown as Window & typeof globalThis global.navigator = dom.window.navigator + globalThis.IS_REACT_ACT_ENVIRONMENT = true }) describe("SVGRenderer", () => { - test("snapshot matches expected SVG output", () => { + test("snapshot matches expected SVG output", async () => { const container = document.createElement("div") document.body.appendChild(container) @@ -25,38 +31,33 @@ describe("SVGRenderer", () => { const root = createRoot(container) - return new Promise((resolve) => { - // Render the SVGs - root.render( - graphics.length > 0 ? ( -
- {graphics.map(({ title, svg }, index) => ( - - ))} -
- ) : null, - ) + try { + await act(async () => { + // Render the SVGs + root.render( + graphics.length > 0 ? ( +
+ {graphics.map(({ title, svg }, index) => ( + + ))} +
+ ) : null, + ) + }) - // Use setTimeout to allow rendering to complete - setTimeout(() => { - try { - const renderedSvgs = container.querySelectorAll("svg") - expect(renderedSvgs.length).toBeGreaterThan(0) - const firstRenderedSvg = renderedSvgs[0]?.outerHTML - // Perform snapshot test - expect(firstRenderedSvg).toMatchSvgSnapshot( - import.meta.path, - "renderer-graphics", - ) - root.unmount() - document.body.removeChild(container) - resolve() - } catch (error) { - root.unmount() - document.body.removeChild(container) - throw error - } - }, 50) - }) + const renderedSvgs = container.querySelectorAll("svg") + expect(renderedSvgs.length).toBeGreaterThan(0) + const firstRenderedSvg = renderedSvgs[0]?.outerHTML + // Perform snapshot test + expect(firstRenderedSvg).toMatchSvgSnapshot( + import.meta.path, + "renderer-graphics", + ) + } finally { + await act(async () => { + root.unmount() + }) + document.body.removeChild(container) + } }) }) diff --git a/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png b/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png index 649912d..af8ca56 100644 Binary files a/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png and b/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png differ diff --git a/tests/getCanvasObjectLabelAtPoint.test.ts b/tests/getCanvasObjectLabelAtPoint.test.ts index 3d76894..92fb30b 100644 --- a/tests/getCanvasObjectLabelAtPoint.test.ts +++ b/tests/getCanvasObjectLabelAtPoint.test.ts @@ -109,7 +109,7 @@ describe("getCanvasObjectLabelAtPoint", () => { getCanvasObjectLabelAtPoint(graphics, identityMatrix, { x: 5, y: 5 }), ).toBe("Rotated rect") expect( - getCanvasObjectLabelAtPoint(graphics, identityMatrix, { x: 8, y: -8 }), + getCanvasObjectLabelAtPoint(graphics, identityMatrix, { x: 8, y: 8 }), ).toBeNull() }) diff --git a/tests/rectGeometry.test.ts b/tests/rectGeometry.test.ts new file mode 100644 index 0000000..f34540c --- /dev/null +++ b/tests/rectGeometry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import type { Matrix } from "transformation-matrix" +import { getProjectedRectGeometry } from "../lib/rectGeometry" +import type { Rect } from "../lib/types" + +const screenMatrix = { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, +} as Matrix + +const cartesianMatrix = { + a: 1, + b: 0, + c: 0, + d: -1, + e: 0, + f: 0, +} as Matrix + +describe("rectGeometry", () => { + test("renders positive ccwRotationDegrees as visually counterclockwise on screen transforms", () => { + const rect: Rect = { + center: { x: 0, y: 0 }, + width: 10, + height: 4, + ccwRotationDegrees: 30, + } + + const projected = getProjectedRectGeometry(rect, screenMatrix) + expect(projected.angleDegrees).toBeCloseTo(-30) + expect(projected.corners[1]!.y).toBeLessThan(projected.corners[0]!.y) + }) + + test("keeps positive ccwRotationDegrees visually counterclockwise with cartesian y-flip transforms", () => { + const rect: Rect = { + center: { x: 0, y: 0 }, + width: 10, + height: 4, + ccwRotationDegrees: 30, + } + + const projected = getProjectedRectGeometry(rect, cartesianMatrix) + expect(projected.angleDegrees).toBeCloseTo(-30) + expect(projected.corners[1]!.y).toBeLessThan(projected.corners[0]!.y) + }) +})