From 7432236467e02104835f4204f2d6cfe2db4f3ea0 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sun, 19 Apr 2026 17:23:34 -0700 Subject: [PATCH 1/4] Add rotated rect rendering support --- README.md | 1 + lib/drawGraphicsToCanvas.ts | 41 +++---- lib/getCanvasObjectLabelAtPoint.ts | 12 +- lib/getPngBufferFromGraphicsObject.ts | 48 +++++--- lib/getSvgFromGraphicsObject.ts | 47 ++++---- lib/rectGeometry.ts | 108 ++++++++++++++++++ lib/types.ts | 1 + site/components/InteractiveGraphics/Rect.tsx | 60 ++++++---- .../hooks/useFilterRects.ts | 16 +-- site/utils/getGraphicsBounds.ts | 13 +-- ...rFromGraphicsObject-rotated-rects.snap.png | Bin 0 -> 2211 bytes ...GraphicsObject-rotated-rectangles.snap.svg | 44 +++++++ tests/getBounds.test.ts | 21 ++++ tests/getCanvasObjectLabelAtPoint.test.ts | 21 ++++ tests/getPngBufferFromGraphicsObject.test.ts | 27 +++++ tests/getSvgFromGraphicsObject.test.ts | 21 ++++ 16 files changed, 360 insertions(+), 121 deletions(-) create mode 100644 lib/rectGeometry.ts create mode 100644 tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png create mode 100644 tests/__snapshots__/getSvgFromGraphicsObject-rotated-rectangles.snap.svg diff --git a/README.md b/README.md index 36e3435..3ec3602 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ interface Rect { center: { x: number; y: number } width: number height: number + ccwRotationDegrees?: number fill?: string stroke?: string color?: string diff --git a/lib/drawGraphicsToCanvas.ts b/lib/drawGraphicsToCanvas.ts index 31835fe..997f8bb 100644 --- a/lib/drawGraphicsToCanvas.ts +++ b/lib/drawGraphicsToCanvas.ts @@ -16,6 +16,7 @@ import { clipInfiniteLineToBounds, getViewportBoundsFromMatrix, } from "./infiniteLineHelpers" +import { getProjectedRectGeometry, getRectCorners } from "./rectGeometry" import type { CenterViewbox, GraphicsObject, @@ -75,14 +76,7 @@ export function getBounds(graphics: GraphicsObject): Viewbox { ...(graphics.lines || []).flatMap((line) => line.points), ...(graphics.polygons || []).flatMap((polygon) => polygon.points), ...(graphics.rects || []).flatMap((rect) => { - const halfWidth = rect.width / 2 - const halfHeight = rect.height / 2 - return [ - { x: rect.center.x - halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x - halfWidth, y: rect.center.y + halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y + halfHeight }, - ] + return getRectCorners(rect) }), ...(graphics.circles || []).flatMap((circle) => [ { x: circle.center.x - circle.radius, y: circle.center.y }, // left @@ -202,28 +196,19 @@ export function drawGraphicsToCanvas( // Draw rectangles if (graphics.rects && graphics.rects.length > 0) { graphics.rects.forEach((rect) => { - const halfWidth = rect.width / 2 - const halfHeight = rect.height / 2 - - const topLeft = applyToPoint(matrix, { - x: rect.center.x - halfWidth, - y: rect.center.y - halfHeight, - }) - - const bottomRight = applyToPoint(matrix, { - x: rect.center.x + halfWidth, - y: rect.center.y + halfHeight, - }) - - const width = Math.abs(bottomRight.x - topLeft.x) - const height = Math.abs(bottomRight.y - topLeft.y) + const projectedRect = getProjectedRectGeometry(rect, matrix) + ctx.save() + ctx.translate(projectedRect.center.x, projectedRect.center.y) + if (Math.abs(projectedRect.angleRadians) > 1e-6) { + ctx.rotate(projectedRect.angleRadians) + } ctx.beginPath() ctx.rect( - Math.min(topLeft.x, bottomRight.x), - Math.min(topLeft.y, bottomRight.y), - width, - height, + -projectedRect.width / 2, + -projectedRect.height / 2, + projectedRect.width, + projectedRect.height, ) if (rect.fill) { @@ -235,6 +220,8 @@ export function drawGraphicsToCanvas( ctx.strokeStyle = rect.stroke ctx.stroke() } + + ctx.restore() }) } diff --git a/lib/getCanvasObjectLabelAtPoint.ts b/lib/getCanvasObjectLabelAtPoint.ts index fb42b15..a4f7463 100644 --- a/lib/getCanvasObjectLabelAtPoint.ts +++ b/lib/getCanvasObjectLabelAtPoint.ts @@ -1,4 +1,5 @@ import { getArrowGeometry } from "./arrowHelpers" +import { getRectCorners } from "./rectGeometry" import type { Arrow, Circle, @@ -185,14 +186,9 @@ const isRectHit = ( matrix: Matrix, hitSlop: number, ) => { - const halfWidth = rect.width / 2 - const halfHeight = rect.height / 2 - const corners = [ - { x: rect.center.x - halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y + halfHeight }, - { x: rect.center.x - halfWidth, y: rect.center.y + halfHeight }, - ].map((point) => projectPoint(matrix, point)) + const corners = getRectCorners(rect).map((point) => + projectPoint(matrix, point), + ) return ( isPointInPolygon(screenPoint, corners) || diff --git a/lib/getPngBufferFromGraphicsObject.ts b/lib/getPngBufferFromGraphicsObject.ts index c7c20fd..9997e8b 100644 --- a/lib/getPngBufferFromGraphicsObject.ts +++ b/lib/getPngBufferFromGraphicsObject.ts @@ -9,6 +9,7 @@ import { createSoftwareRasterSurface, type RasterContext, } from "./softwareRasterSurface" +import { getProjectedRectGeometry } from "./rectGeometry" import { strokeAlphabetText } from "./strokeAlphabetText" import type { GraphicsObject, @@ -298,30 +299,43 @@ export async function getPngBufferFromGraphicsObject( } for (const rect of graphics.rects || []) { - const topLeft = applyToPoint(matrix, { - x: rect.center.x - rect.width / 2, - y: rect.center.y - rect.height / 2, - }) - const bottomRight = applyToPoint(matrix, { - x: rect.center.x + rect.width / 2, - y: rect.center.y + rect.height / 2, - }) - const x = Math.min(topLeft.x, bottomRight.x) - const y = Math.min(topLeft.y, bottomRight.y) - const width = Math.abs(bottomRight.x - topLeft.x) - const height = Math.abs(bottomRight.y - topLeft.y) + const projectedRect = getProjectedRectGeometry(rect, matrix) + const x = projectedRect.center.x - projectedRect.width / 2 + const y = projectedRect.center.y - projectedRect.height / 2 + + ctx.save() + ctx.translate(projectedRect.center.x, projectedRect.center.y) + if (Math.abs(projectedRect.angleRadians) > 1e-6) { + ctx.rotate(projectedRect.angleRadians) + } if (hasVisiblePaint(rect.fill)) { + ctx.beginPath() + ctx.rect( + -projectedRect.width / 2, + -projectedRect.height / 2, + projectedRect.width, + projectedRect.height, + ) ctx.fillStyle = rect.fill! - ctx.fillRect(x, y, width, height) + ctx.fill() } if (hasVisiblePaint(rect.stroke)) { + ctx.beginPath() + ctx.rect( + -projectedRect.width / 2, + -projectedRect.height / 2, + projectedRect.width, + projectedRect.height, + ) ctx.strokeStyle = rect.stroke! ctx.lineWidth = 1 - ctx.strokeRect(x, y, width, height) + ctx.stroke() } + ctx.restore() + if ( shouldRenderLabel( includeTextLabels, @@ -332,9 +346,9 @@ export async function getPngBufferFromGraphicsObject( ) { renderText(ctx, { text: rect.label, - x: x + 5, - y, - fontSize: ((width + height) / 2) * 0.06, + x: projectedRect.bounds.minX + 5, + y: projectedRect.bounds.minY, + fontSize: ((projectedRect.width + projectedRect.height) / 2) * 0.06, color: rect.stroke || "black", }) } diff --git a/lib/getSvgFromGraphicsObject.ts b/lib/getSvgFromGraphicsObject.ts index 5945a64..d625306 100644 --- a/lib/getSvgFromGraphicsObject.ts +++ b/lib/getSvgFromGraphicsObject.ts @@ -15,6 +15,7 @@ import { } from "./arrowHelpers" import { FONT_SIZE_HEIGHT_RATIO, FONT_SIZE_WIDTH_RATIO } from "./constants" import { clipInfiniteLineToBounds } from "./infiniteLineHelpers" +import { getProjectedRectGeometry, getRectCorners } from "./rectGeometry" import type { GraphicsObject, Point } from "./types" const DEFAULT_SVG_SIZE = 640 @@ -33,14 +34,7 @@ function getBounds(graphics: GraphicsObject): Bounds { ...(graphics.lines || []).flatMap((line) => line.points), ...(graphics.polygons || []).flatMap((polygon) => polygon.points), ...(graphics.rects || []).flatMap((rect) => { - const halfWidth = rect.width / 2 - const halfHeight = rect.height / 2 - return [ - { x: rect.center.x - halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y - halfHeight }, - { x: rect.center.x - halfWidth, y: rect.center.y + halfHeight }, - { x: rect.center.x + halfWidth, y: rect.center.y + halfHeight }, - ] + return getRectCorners(rect) }), ...(graphics.circles || []).flatMap((circle) => [ { x: circle.center.x - circle.radius, y: circle.center.y }, // left @@ -324,20 +318,12 @@ export function getSvgFromGraphicsObject( }), // Rectangles ...(graphics.rects || []).map((rect) => { - const corner1 = { - x: rect.center.x - rect.width / 2, - y: rect.center.y - rect.height / 2, - } - const projectedCorner1 = projectPoint(corner1, matrix) - const corner2 = { - x: rect.center.x + rect.width / 2, - y: rect.center.y + rect.height / 2, - } - const projectedCorner2 = projectPoint(corner2, matrix) - const scaledWidth = Math.abs(projectedCorner2.x - projectedCorner1.x) - const scaledHeight = Math.abs(projectedCorner2.y - projectedCorner1.y) - const rectX = Math.min(projectedCorner1.x, projectedCorner2.x) - const rectY = Math.min(projectedCorner1.y, projectedCorner2.y) + const projectedRect = getProjectedRectGeometry(rect, matrix) + const rectX = projectedRect.center.x - projectedRect.width / 2 + const rectY = projectedRect.center.y - projectedRect.height / 2 + const labelX = projectedRect.bounds.minX + 5 + const labelY = projectedRect.bounds.minY + const hasRotation = Math.abs(projectedRect.angleDegrees) > 1e-6 return { name: "g", @@ -352,13 +338,20 @@ export function getSvgFromGraphicsObject( "data-label": rect.label || "", "data-x": rect.center.x.toString(), "data-y": rect.center.y.toString(), + ...(rect.ccwRotationDegrees !== undefined && { + "data-ccw-rotation-degrees": + rect.ccwRotationDegrees.toString(), + }), x: rectX.toString(), y: rectY.toString(), - width: scaledWidth.toString(), - height: scaledHeight.toString(), + width: projectedRect.width.toString(), + height: projectedRect.height.toString(), fill: rect.fill || "none", stroke: rect.stroke || "black", "stroke-width": Math.abs(1 / matrix.a).toString(), // Consider scaling stroke width like lines if needed + ...(hasRotation && { + transform: `rotate(${projectedRect.angleDegrees} ${projectedRect.center.x} ${projectedRect.center.y})`, + }), }, }, ...(shouldRenderLabel("rects") && rect.label @@ -367,12 +360,12 @@ export function getSvgFromGraphicsObject( name: "text", type: "element", attributes: { - x: (rectX + 5).toString(), - y: rectY.toString(), + x: labelX.toString(), + y: labelY.toString(), "font-family": "sans-serif", "dominant-baseline": "text-before-edge", "font-size": ( - ((scaledWidth + scaledHeight) / 2) * + ((projectedRect.width + projectedRect.height) / 2) * 0.06 ).toString(), fill: rect.stroke || "black", // Default to stroke color for label diff --git a/lib/rectGeometry.ts b/lib/rectGeometry.ts new file mode 100644 index 0000000..4a0e668 --- /dev/null +++ b/lib/rectGeometry.ts @@ -0,0 +1,108 @@ +import { applyToPoint, type Matrix } from "transformation-matrix" +import type { Point, Rect, Viewbox } from "./types" + +type XYPoint = { x: number; y: number } + +const getDistanceBetweenPoints = (a: XYPoint, b: XYPoint) => { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +export const getRectRotationRadians = ( + rect: Pick, +) => { + return ((rect.ccwRotationDegrees ?? 0) * Math.PI) / 180 +} + +const rotatePoint = (point: XYPoint, angleRadians: number): XYPoint => { + const cos = Math.cos(angleRadians) + const sin = Math.sin(angleRadians) + + return { + x: point.x * cos - point.y * sin, + y: point.x * sin + point.y * cos, + } +} + +export const getRectCorners = ( + rect: Pick, +): Point[] => { + const halfWidth = rect.width / 2 + const halfHeight = rect.height / 2 + const angleRadians = getRectRotationRadians(rect) + + return [ + { x: -halfWidth, y: -halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: -halfWidth, y: halfHeight }, + ].map((corner) => { + const rotatedCorner = rotatePoint(corner, angleRadians) + return { + x: rect.center.x + rotatedCorner.x, + y: rect.center.y + rotatedCorner.y, + } + }) +} + +export const getRectBounds = ( + rect: Pick, +): Viewbox => { + const corners = getRectCorners(rect) + + return corners.reduce( + (bounds, point) => ({ + minX: Math.min(bounds.minX, point.x), + maxX: Math.max(bounds.maxX, point.x), + minY: Math.min(bounds.minY, point.y), + maxY: Math.max(bounds.maxY, point.y), + }), + { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + }, + ) +} + +export const getProjectedRectGeometry = ( + rect: Pick, + matrix: Matrix, +) => { + const corners = getRectCorners(rect).map((point) => + applyToPoint(matrix, point), + ) + const center = applyToPoint(matrix, rect.center) + const width = getDistanceBetweenPoints(corners[0], corners[1]) + const height = getDistanceBetweenPoints(corners[1], corners[2]) + const angleRadians = + corners.length >= 2 + ? Math.atan2(corners[1].y - corners[0].y, corners[1].x - corners[0].x) + : 0 + const angleDegrees = (angleRadians * 180) / Math.PI + + const bounds = corners.reduce( + (result, point) => ({ + minX: Math.min(result.minX, point.x), + maxX: Math.max(result.maxX, point.x), + minY: Math.min(result.minY, point.y), + maxY: Math.max(result.maxY, point.y), + }), + { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity, + }, + ) + + return { + center, + corners, + width, + height, + angleRadians, + angleDegrees, + bounds, + } +} diff --git a/lib/types.ts b/lib/types.ts index 3fbc451..1ad00af 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -33,6 +33,7 @@ export interface Rect { center: { x: number; y: number } width: number height: number + ccwRotationDegrees?: number fill?: string stroke?: string color?: string diff --git a/site/components/InteractiveGraphics/Rect.tsx b/site/components/InteractiveGraphics/Rect.tsx index ac91055..f19b2a8 100644 --- a/site/components/InteractiveGraphics/Rect.tsx +++ b/site/components/InteractiveGraphics/Rect.tsx @@ -1,7 +1,6 @@ import type * as Types from "lib/types" -import { applyToPoint } from "transformation-matrix" +import { getProjectedRectGeometry } from "lib/rectGeometry" import type { InteractiveState } from "./InteractiveState" -import { lighten } from "polished" import { useState } from "react" import { Tooltip } from "./Tooltip" import { defaultColors } from "./defaultColors" @@ -17,14 +16,12 @@ export const Rect = ({ index: number }) => { const defaultColor = defaultColors[index % defaultColors.length] - let { center, width, height, fill, stroke, layer, step } = rect + let { fill, stroke } = rect const { activeLayers, activeStep, realToScreen, onObjectClicked } = interactiveState const [isHovered, setIsHovered] = useState(false) - const screenCenter = applyToPoint(realToScreen, center) - const screenWidth = width * realToScreen.a - const screenHeight = height * Math.abs(realToScreen.d) + const projectedRect = getProjectedRectGeometry(rect, realToScreen) // Default style when neither fill nor stroke is specified const hasStrokeOrFill = fill !== undefined || stroke !== undefined @@ -39,27 +36,39 @@ export const Rect = ({
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - onClick={() => - onObjectClicked?.({ - type: "rect", - index, - object: rect, - }) - } > +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => + onObjectClicked?.({ + type: "rect", + index, + object: rect, + }) + } + /> {isHovered && rect.label && (
diff --git a/site/components/InteractiveGraphics/hooks/useFilterRects.ts b/site/components/InteractiveGraphics/hooks/useFilterRects.ts index 2c05605..457cd9c 100644 --- a/site/components/InteractiveGraphics/hooks/useFilterRects.ts +++ b/site/components/InteractiveGraphics/hooks/useFilterRects.ts @@ -1,9 +1,11 @@ import { useMemo } from "react" +import { getRectCorners } from "lib/rectGeometry" type Rect = { center: { x: number; y: number } width: number height: number + ccwRotationDegrees?: number layer?: string step?: number } @@ -28,22 +30,16 @@ export const useFilterRects = ({ if (!filterLayerAndStep(rect)) return false // For rectangles, check if any corner or the center is visible - const { center, width, height } = rect - const halfWidth = width / 2 - const halfHeight = height / 2 - - const topLeft = { x: center.x - halfWidth, y: center.y - halfHeight } - const topRight = { x: center.x + halfWidth, y: center.y - halfHeight } - const bottomLeft = { x: center.x - halfWidth, y: center.y + halfHeight } - const bottomRight = { x: center.x + halfWidth, y: center.y + halfHeight } + const { center } = rect + const [topLeft, topRight, bottomRight, bottomLeft] = getRectCorners(rect) // Check if any corner or center is visible if ( isPointOnScreen(center) || isPointOnScreen(topLeft) || isPointOnScreen(topRight) || - isPointOnScreen(bottomLeft) || - isPointOnScreen(bottomRight) + isPointOnScreen(bottomRight) || + isPointOnScreen(bottomLeft) ) { return true } diff --git a/site/utils/getGraphicsBounds.ts b/site/utils/getGraphicsBounds.ts index 712ed41..3fed7f3 100644 --- a/site/utils/getGraphicsBounds.ts +++ b/site/utils/getGraphicsBounds.ts @@ -1,5 +1,6 @@ import { GraphicsObject } from "lib/types" import { getArrowBoundingBox } from "lib/arrowHelpers" +import { getRectBounds } from "lib/rectGeometry" export const getGraphicsBounds = (graphics: GraphicsObject) => { const bounds = { @@ -17,13 +18,11 @@ export const getGraphicsBounds = (graphics: GraphicsObject) => { } } for (const rect of graphics.rects ?? []) { - const { center, width, height } = rect - const halfWidth = width / 2 - const halfHeight = height / 2 - bounds.minX = Math.min(bounds.minX, center.x - halfWidth) - bounds.minY = Math.min(bounds.minY, center.y - halfHeight) - bounds.maxX = Math.max(bounds.maxX, center.x + halfWidth) - bounds.maxY = Math.max(bounds.maxY, center.y + halfHeight) + const rectBounds = getRectBounds(rect) + bounds.minX = Math.min(bounds.minX, rectBounds.minX) + bounds.minY = Math.min(bounds.minY, rectBounds.minY) + bounds.maxX = Math.max(bounds.maxX, rectBounds.maxX) + bounds.maxY = Math.max(bounds.maxY, rectBounds.maxY) } for (const polygon of graphics.polygons ?? []) { for (const point of polygon.points ?? []) { diff --git a/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png b/tests/__snapshots__/getPngBufferFromGraphicsObject-rotated-rects.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..649912d2eed2f776acf930f3b3b4a6648021b3b4 GIT binary patch literal 2211 zcmd6pYg7|w8pq#BLJ2n$U_mZQf?BGQg|xPS2u-*MvM7Rr;t_S+*!05Eq}VO7A<4jc zfdhu_QHu%%?6C+ef^mt)avOR=g9rxUNNAuWTFN~b1p^_3>_rE{;Bq005VSJ+TJ>fG`OG@>|#=BfEwR0Qs|o*r-&&al=TY^!>vgs*{IM zKXMm+{w=CE&B2Mz-oh>*{p8>py@}w!aNQ~RYgg~Oy~x;Q7j#_<;l5}%s9AUY+FV`j z^t5o!cIM{hysTe36q3lrk!;a#B&8c52LS3`K)(vuObCGqpo0YR9RSJwPZ&U>X_BSU zGw>F|oj#g)rFTBpI7b$0auN)2UvbZl}oD z{DS2`O+63-E-gx|tphBH?NZ~aZB*A|IJ|1QE%65a@LxV6_I)@hF;CsE%r%;v*%qO? z-CF6ZBGhg7MXE*YiNptyth0u)R?#Ch%NTr-n!N8J$PiB+B?YngLebLWqIR{UwNlc> zyU2mATy{oXwV~$gJe!#ed63Z^+WWS|qIUUj>1;I#=1x858JZ@@TAhYRT!;l+ZR4y^ zWKpYR$S4^-XS$KwiziS8^;i9{cezGHjPO#wCJ`Gi4R^KlyV?pkqi2JW?S7w>YdUd zu%K8vx;E3m9G_3i%J99rI|F>(Y8nog+O@=G0I7J?Hu*CB-OO0Ks&2V%P)37m zYd~r5y&FVh zmnJvON0pxuV^24%nH49AeFHcZCrav1gkRoQlRK8qJG0e|=DHB69mh8aY*Slkk$!=G z1AK^Y$kBbl@OqSts}VuHz&XYamFr|2Rb)S^H`4O`yTL>|@Cb z@3u$fCn~YD7&Ay;c^xUW69sNW#xNRR|H*?>nD-RJTD|OoXmX!!^q-7>h}H1rY;f{x zJOv_Num%+&x#ihdhtTmb&xp7OcvKj1D7XFq=9TU+5mj5=K~Z~;jHYOO4>Gn$=7uOE z`wOwU*FsE0OQ0*zh{v;M?M(wB&@%ogB2;Q;nfwr=>yf+WXZ^SiEz6MzmS@y$Z-a5H zLrand`lNb>wP@o$BzJ`WY7h5|t0rjm|6!lTn7$2kM-rL|ch@r$>Hm7Gp)@0MBn?+Hv9jhXoI0w?Me2rv=3 zQ^h{{li+su!r>`7yLx@9^ zVpdv%b)2N9n~0qKKXmHehQh8|QNh6}EiVc9DaCMk?Y*VuEr2qXo-BE+5!=~z=nKJ@ zZ1QwcC!Rf7z=^tDD=>t<12($luil!L>&n5G-Lk3|wO`}rcc-(4bg9Bon-8@0Cs|cx zZ7%M13hI|+1B49G%bWz@c$X&VDZYz$Irj@?!>+XB^YZh+85QeS51P-|GD$E7w^){f zbHYsQqfzhCh))xkTUGg1?#;~hosd^K^&e$V@Hidebj)y-xJlkb`a_MS^M!G(rZRT~ zjyAq3ZoykU+u&4@^jed9+aHvlsb8A$USkm?F3aGvYi>l~OWE_YcqRSrPc3;Vm5c3h zK(Xy4>zizR1V+d!G5vRZOX*onR(3x=w{iPsgMGiJq?Cm0tD`yf<3z}@ROhJYc=S%$ zXR7=)G&sj2n)xcTZIBA)swX&6YfIzjv>x-7u3m5afP};>WHat83CjS#CZ2Vu+_+|5 z5Gcj;*Z4w^3*p%;z4pa6NdHVaL{XT86`=2;aLzu%61g8tJ|49|8gZ~Y__0YN!%l&J z64;u1=idm2#+{xDQ}aHs^`m`c#_H$pp!-NRV~@C3<3nH{(L`ehO)*FwT}l7u4av#9 wB_0;ji6r3Ft)jK%_ \ No newline at end of file diff --git a/tests/getBounds.test.ts b/tests/getBounds.test.ts index ea4683d..c6a03bc 100644 --- a/tests/getBounds.test.ts +++ b/tests/getBounds.test.ts @@ -70,4 +70,25 @@ describe("getBounds with text", () => { getBounds(graphicsWithRect), ) }) + + test("includes rotated rect corners in bounds", () => { + const graphics: GraphicsObject = { + rects: [ + { + center: { x: 0, y: 0 }, + width: 4, + height: 2, + ccwRotationDegrees: 45, + }, + ], + } + + const bounds = getBounds(graphics) + const expectedHalfExtent = (4 * Math.SQRT2 + 2 * Math.SQRT2) / 4 + + expect(bounds.minX).toBeCloseTo(-expectedHalfExtent) + expect(bounds.maxX).toBeCloseTo(expectedHalfExtent) + expect(bounds.minY).toBeCloseTo(-expectedHalfExtent) + expect(bounds.maxY).toBeCloseTo(expectedHalfExtent) + }) }) diff --git a/tests/getCanvasObjectLabelAtPoint.test.ts b/tests/getCanvasObjectLabelAtPoint.test.ts index 737feb5..3d76894 100644 --- a/tests/getCanvasObjectLabelAtPoint.test.ts +++ b/tests/getCanvasObjectLabelAtPoint.test.ts @@ -92,6 +92,27 @@ describe("getCanvasObjectLabelAtPoint", () => { ).toBe("Polygon label") }) + test("detects rotated rect hits using the rotated outline", () => { + const graphics: GraphicsObject = { + rects: [ + { + center: { x: 0, y: 0 }, + width: 20, + height: 6, + ccwRotationDegrees: 45, + label: "Rotated rect", + }, + ], + } + + expect( + getCanvasObjectLabelAtPoint(graphics, identityMatrix, { x: 5, y: 5 }), + ).toBe("Rotated rect") + expect( + getCanvasObjectLabelAtPoint(graphics, identityMatrix, { x: 8, y: -8 }), + ).toBeNull() + }) + test("detects arrow hits and combines multiline arrow labels", () => { const graphics: GraphicsObject = { arrows: [ diff --git a/tests/getPngBufferFromGraphicsObject.test.ts b/tests/getPngBufferFromGraphicsObject.test.ts index 886757d..fb2b40a 100644 --- a/tests/getPngBufferFromGraphicsObject.test.ts +++ b/tests/getPngBufferFromGraphicsObject.test.ts @@ -146,6 +146,33 @@ describe("getPngBufferFromGraphicsObject", () => { ) }) + test("renders rotated rectangles to a png snapshot", async () => { + const graphics: GraphicsObject = { + rects: [ + { + center: { x: 0, y: 0 }, + width: 7, + height: 3, + ccwRotationDegrees: 30, + fill: "#fef08a", + stroke: "#854d0e", + label: "R1", + }, + ], + } + + const png = await getPngBufferFromGraphicsObject(graphics, { + ...TEST_SIZE, + includeTextLabels: ["rects"], + }) + + await expectPngToMatchSnapshot( + png, + import.meta.path, + "getPngBufferFromGraphicsObject-rotated-rects", + ) + }) + test("renders arrows and inline labels to a png snapshot", async () => { const graphics: GraphicsObject = { arrows: [ diff --git a/tests/getSvgFromGraphicsObject.test.ts b/tests/getSvgFromGraphicsObject.test.ts index 3831f25..3458ea5 100644 --- a/tests/getSvgFromGraphicsObject.test.ts +++ b/tests/getSvgFromGraphicsObject.test.ts @@ -143,6 +143,27 @@ describe("getSvgFromGraphicsObject", () => { expect(svg).toMatchSvgSnapshot(import.meta.path, "rectangles") }) + test("should generate SVG with rotated rectangles", () => { + const input: GraphicsObject = { + rects: [ + { + center: { x: 0, y: 0 }, + width: 10, + height: 4, + ccwRotationDegrees: 30, + fill: "yellow", + stroke: "green", + }, + ], + } + + const svg = getSvgFromGraphicsObject(input) + expect(svg).toContain(" { const input: GraphicsObject = { polygons: [ From 3503640b20a1c782c054fd6622fda09d23e00037 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sun, 19 Apr 2026 17:35:57 -0700 Subject: [PATCH 2/4] Add rotated rect examples to demos --- examples/canvas-basic.fixture.tsx | 8 ++++++++ examples/canvas-renderer.fixture.tsx | 8 ++++++++ examples/interactive-graphics-canvas.fixture.tsx | 9 +++++++++ examples/react-canvas-component.fixture.tsx | 8 ++++++++ site/assets/exampleGraphics.json | 8 ++++++++ 5 files changed, 41 insertions(+) diff --git a/examples/canvas-basic.fixture.tsx b/examples/canvas-basic.fixture.tsx index a1c6b9a..1272596 100644 --- a/examples/canvas-basic.fixture.tsx +++ b/examples/canvas-basic.fixture.tsx @@ -44,6 +44,14 @@ const simpleGraphics: GraphicsObject = { fill: "rgba(200, 200, 200, 0.5)", stroke: "black", }, + { + center: { x: 70, y: 30 }, + width: 26, + height: 18, + ccwRotationDegrees: 30, + fill: "rgba(251, 146, 60, 0.35)", + stroke: "#c2410c", + }, ], circles: [ { diff --git a/examples/canvas-renderer.fixture.tsx b/examples/canvas-renderer.fixture.tsx index 9efcd03..3d72eba 100644 --- a/examples/canvas-renderer.fixture.tsx +++ b/examples/canvas-renderer.fixture.tsx @@ -37,6 +37,14 @@ const exampleGraphics: GraphicsObject = { fill: "rgba(255, 0, 0, 0.2)", stroke: "red", }, + { + center: { x: -30, y: -15 }, + width: 28, + height: 16, + ccwRotationDegrees: 28, + fill: "rgba(249, 115, 22, 0.25)", + stroke: "#c2410c", + }, ], circles: [ { diff --git a/examples/interactive-graphics-canvas.fixture.tsx b/examples/interactive-graphics-canvas.fixture.tsx index 1ae7385..47312b3 100644 --- a/examples/interactive-graphics-canvas.fixture.tsx +++ b/examples/interactive-graphics-canvas.fixture.tsx @@ -57,6 +57,15 @@ const steppedGraphics: GraphicsObject = { stroke: "blue", step: 2, }, + { + center: { x: 30, y: -5 }, + width: 32, + height: 18, + ccwRotationDegrees: 35, + fill: "rgba(249, 115, 22, 0.25)", + stroke: "#c2410c", + step: 3, + }, ], circles: [ { diff --git a/examples/react-canvas-component.fixture.tsx b/examples/react-canvas-component.fixture.tsx index 6e95f27..82e161e 100644 --- a/examples/react-canvas-component.fixture.tsx +++ b/examples/react-canvas-component.fixture.tsx @@ -54,6 +54,14 @@ const complexExample: GraphicsObject = { fill: "rgba(0, 0, 255, 0.2)", stroke: "blue", }, + { + center: { x: 45, y: -20 }, + width: 42, + height: 22, + ccwRotationDegrees: 32, + fill: "rgba(249, 115, 22, 0.25)", + stroke: "#c2410c", + }, ], circles: [ { diff --git a/site/assets/exampleGraphics.json b/site/assets/exampleGraphics.json index 8318cc7..92c0d4c 100644 --- a/site/assets/exampleGraphics.json +++ b/site/assets/exampleGraphics.json @@ -5,6 +5,14 @@ "center": { "x": 0, "y": 0 }, "width": 100, "height": 100 + }, + { + "center": { "x": 55, "y": -25 }, + "width": 50, + "height": 24, + "ccwRotationDegrees": 30, + "fill": "rgba(249, 115, 22, 0.25)", + "stroke": "#c2410c" } ], "points": [ From 4a6ac87558570edfca1d146345333809dd469421 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sun, 19 Apr 2026 18:27:19 -0700 Subject: [PATCH 3/4] Fix rect rotation handling for renders --- lib/getCanvasObjectLabelAtPoint.ts | 6 +- lib/rectGeometry.ts | 34 +++++++++- .../InteractiveGraphics.tsx | 1 + .../hooks/useFilterRects.ts | 15 ++++- tests/SVGRenderer.test.tsx | 63 +++++++++--------- ...rFromGraphicsObject-rotated-rects.snap.png | Bin 2211 -> 2184 bytes tests/getCanvasObjectLabelAtPoint.test.ts | 2 +- tests/rectGeometry.test.ts | 50 ++++++++++++++ 8 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 tests/rectGeometry.test.ts 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..0ae1b02 100644 --- a/lib/rectGeometry.ts +++ b/lib/rectGeometry.ts @@ -13,6 +13,20 @@ 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 +39,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 +99,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..d84b37e 100644 --- a/tests/SVGRenderer.test.tsx +++ b/tests/SVGRenderer.test.tsx @@ -1,4 +1,5 @@ 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" @@ -13,10 +14,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 +27,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 649912d2eed2f776acf930f3b3b4a6648021b3b4..af8ca56be23e6618aab1710cf0d3448e6fcac76a 100644 GIT binary patch literal 2184 zcmdUx`BPKp8pq!pIG~9N#Sj+JFjiZlB5|ps)ht>V8n#%0vI(TNYMrR;pa#e>bU(tQ3tbSpch4Za!#e#?a zCKq=f#zKNWa~1QH^Mi$->{ih)MU5$&&jC9Xt&S5G`@~g)6ul9rXO&A{AVIN z5{k1;>kI|Q--PkR&mQMb)fiTc+UB}@_~`V{+Lw$zB9;9d+G;UgP;ax% z-($>ulyXtDZV5{H-q$-0s;de_GKTC^+B8WPyc={Ums_KK?5c5tD;ul4oV}pAI_}A2 zhdcVe^gatI*Cqg6N#Qw}(Mp+?+j`n-ufYPc75m;cL(I_UDnSTGUH-gT>xvgQ$hO6W(2ov6^y+ z`jv-nnGlm=K%ZEbR&&&We&V5pPYwk(fZWG4K7Zm5thjw#C^cj!U=uChs5dZ@>FyXC z(~N|bjFeE2ul~7bwhJ~iP>Mi#hXHM2a@n?9F7^@J0;!Amw%X=oAu;QoY{QD6%9B!+% zl876R-t`b=yOu}>SydQ|mx+V2HsLi&hDGu6-&VEM_QiiaVQNuPQ254SY3%SxJV%g` zdj1F7$*t&>fOIqC8xXg3_Rgw$Yhhn2ycQuS3NF@U82ibkll@m8i20|XV*}dGJF7hV z=ec79%sl+bxJDH0h4km`KlXUlmn0mYMFZGj(Bu(dcBfc$B! z1Cz7*xiF%PS_jt32C>DckHB3XK6ydv_hkDDb$9DVnCi!HpW49;R+-4xVsMLw1>IVq zJ$@W7a~06ELh0#Dm>N%*DdVBJVI;)Onb%9>^Hvi$-g&`X`K1kdL+EqJI|A1}rZKQ5 zpxdOTrCO9;wH}ctrmHiI=H`4{JVB4GsqZ5%nT7{h%i2?x2jXRk>EsRik*8O&fx{2t zpMADM-Fzz&w+QJa48RUJl8D5@0OiBi^!vaNt zesu>Zrb+38a^2_{fz!&}sijxAr-B|~3i&{D|ECn>-d&VABvz7$vZN#leNfwZI0Se3 z_>|dMWqN)I+G=WOujSEsSHOBzmLqjE03nzY2_3q&P`sw>ixGj|^8O-pOwsIc_6$G{ zcI5`X(L|R*)wi<)e^k}`!qo5du7V}>SPJ45(M=x|vvcsA;sJ_GUQd9jJ(hPpzgV-c zf&;!KquS2wYv91#l-8~MWE2rNM+5f=9FqzI`9J1Dce1jCEOkO=81}*dtdOYSmR%gd F{{fjL&H(@b literal 2211 zcmd6pYg7|w8pq#BLJ2n$U_mZQf?BGQg|xPS2u-*MvM7Rr;t_S+*!05Eq}VO7A<4jc zfdhu_QHu%%?6C+ef^mt)avOR=g9rxUNNAuWTFN~b1p^_3>_rE{;Bq005VSJ+TJ>fG`OG@>|#=BfEwR0Qs|o*r-&&al=TY^!>vgs*{IM zKXMm+{w=CE&B2Mz-oh>*{p8>py@}w!aNQ~RYgg~Oy~x;Q7j#_<;l5}%s9AUY+FV`j z^t5o!cIM{hysTe36q3lrk!;a#B&8c52LS3`K)(vuObCGqpo0YR9RSJwPZ&U>X_BSU zGw>F|oj#g)rFTBpI7b$0auN)2UvbZl}oD z{DS2`O+63-E-gx|tphBH?NZ~aZB*A|IJ|1QE%65a@LxV6_I)@hF;CsE%r%;v*%qO? z-CF6ZBGhg7MXE*YiNptyth0u)R?#Ch%NTr-n!N8J$PiB+B?YngLebLWqIR{UwNlc> zyU2mATy{oXwV~$gJe!#ed63Z^+WWS|qIUUj>1;I#=1x858JZ@@TAhYRT!;l+ZR4y^ zWKpYR$S4^-XS$KwiziS8^;i9{cezGHjPO#wCJ`Gi4R^KlyV?pkqi2JW?S7w>YdUd zu%K8vx;E3m9G_3i%J99rI|F>(Y8nog+O@=G0I7J?Hu*CB-OO0Ks&2V%P)37m zYd~r5y&FVh zmnJvON0pxuV^24%nH49AeFHcZCrav1gkRoQlRK8qJG0e|=DHB69mh8aY*Slkk$!=G z1AK^Y$kBbl@OqSts}VuHz&XYamFr|2Rb)S^H`4O`yTL>|@Cb z@3u$fCn~YD7&Ay;c^xUW69sNW#xNRR|H*?>nD-RJTD|OoXmX!!^q-7>h}H1rY;f{x zJOv_Num%+&x#ihdhtTmb&xp7OcvKj1D7XFq=9TU+5mj5=K~Z~;jHYOO4>Gn$=7uOE z`wOwU*FsE0OQ0*zh{v;M?M(wB&@%ogB2;Q;nfwr=>yf+WXZ^SiEz6MzmS@y$Z-a5H zLrand`lNb>wP@o$BzJ`WY7h5|t0rjm|6!lTn7$2kM-rL|ch@r$>Hm7Gp)@0MBn?+Hv9jhXoI0w?Me2rv=3 zQ^h{{li+su!r>`7yLx@9^ zVpdv%b)2N9n~0qKKXmHehQh8|QNh6}EiVc9DaCMk?Y*VuEr2qXo-BE+5!=~z=nKJ@ zZ1QwcC!Rf7z=^tDD=>t<12($luil!L>&n5G-Lk3|wO`}rcc-(4bg9Bon-8@0Cs|cx zZ7%M13hI|+1B49G%bWz@c$X&VDZYz$Irj@?!>+XB^YZh+85QeS51P-|GD$E7w^){f zbHYsQqfzhCh))xkTUGg1?#;~hosd^K^&e$V@Hidebj)y-xJlkb`a_MS^M!G(rZRT~ zjyAq3ZoykU+u&4@^jed9+aHvlsb8A$USkm?F3aGvYi>l~OWE_YcqRSrPc3;Vm5c3h zK(Xy4>zizR1V+d!G5vRZOX*onR(3x=w{iPsgMGiJq?Cm0tD`yf<3z}@ROhJYc=S%$ zXR7=)G&sj2n)xcTZIBA)swX&6YfIzjv>x-7u3m5afP};>WHat83CjS#CZ2Vu+_+|5 z5Gcj;*Z4w^3*p%;z4pa6NdHVaL{XT86`=2;aLzu%61g8tJ|49|8gZ~Y__0YN!%l&J z64;u1=idm2#+{xDQ}aHs^`m`c#_H$pp!-NRV~@C3<3nH{(L`ehO)*FwT}l7u4av#9 wB_0;ji6r3Ft)jK%_ { 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) + }) +}) From 94f986fd39b8539af9470800b45fd4cef080f607 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sun, 19 Apr 2026 21:07:29 -0700 Subject: [PATCH 4/4] Declare React act environment in SVGRenderer test --- tests/SVGRenderer.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/SVGRenderer.test.tsx b/tests/SVGRenderer.test.tsx index d84b37e..2e7d39e 100644 --- a/tests/SVGRenderer.test.tsx +++ b/tests/SVGRenderer.test.tsx @@ -6,6 +6,10 @@ 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