Repository: microsoft/pxt-common-packages
Pull request #1510: add rotation property to sprites
Author: login: riknoll
Created: 2025-03-26T23:37:51Z | Merged: 2025-03-31T16:46:37Z

this PR adds a new rotation property to sprites. this property works similarly to the two scale properties (sx and sy) but instead of stretching the sprite it rotates it using the "three shears" algorithm. this adds two new properties to the sprite dropdown:


![image](https://github.com/user-attachments/assets/9e764bbb-0c2c-4459-b2fb-880ec76b372c)

they both map to the same thing but obviously use different units. radians is what you want if you're going to be using this with trigonometric functions like sin and cos, but degrees is easier to understand/think about. 

the drawing for the rotation is implemented in c++ with fixed point math and it runs pretty well on hardware, though it's still much slower than non-rotated sprites. however, because we're talking about rotating pixel art here, the results don't always look amazing especially for smaller sprites. for example, here is our friend the duck rotated about 45 degrees:

![image](https://github.com/user-attachments/assets/da21eeb7-e838-49e4-8ce3-ea4d2786ed4f)

however it looks much better for larger sprites:

![image](https://github.com/user-attachments/assets/ce5fc6cd-5ee4-4f44-b47d-39b49a5fb295)

and even better when in motion, which is what i mainly think this will be used for:

![rotating-house](https://github.com/user-attachments/assets/d259568a-9205-4cc9-bd33-10efd56a14a0)

this implementation is also compatible with scaling. scaling is applied *before* rotation, like so:                   

![stretchy-house-final](https://github.com/user-attachments/assets/5762d7fe-9f0d-4c21-98fc-d7b2feee54a7)


now for some caveats:

1. ~in order to make this performant, **sprite overlaps for rotated sprites are *not* pixel-perfect**. instead, they are calculated based on the rotated rectangle of the sprite. this is really only an issue for sprites that have a lot of transparency in their images~
2. **the tilemap hitbox of the rotated sprite is the axis aligned bounding box for the whole rotated image**. for long sprites, this can look a little weird but it's exactly the same as it would be if you were to draw the rotated image for the sprite. see below for an image of what this looks like

and here is the promised picture of the hitbox for a rotated and scaled sprite. the red box indicates the hitbox:

![image](https://github.com/user-attachments/assets/0f9c36c1-71d4-45f8-be02-39e0d997a74e)




Diff:
diff --git a/libs/game/hitbox.ts b/libs/game/hitbox.ts
index 50b78fa60..21d8287f1 100644
--- a/libs/game/hitbox.ts
+++ b/libs/game/hitbox.ts
@@ -88,15 +88,15 @@ namespace game {
         overlapsWith(other: Hitbox): boolean {
             this.updateIfInvalid();
             other.updateIfInvalid();
-            if (this.contains(other.left, other.top)) return true;
-            if (this.contains(other.left, other.bottom)) return true;
-            if (this.contains(other.right, other.top)) return true;
-            if (this.contains(other.right, other.bottom)) return true;
-            if (other.contains(this.left, this.top)) return true;
-            if (other.contains(this.left, this.bottom)) return true;
-            if (other.contains(this.right, this.top)) return true;
-            if (other.contains(this.right, this.bottom)) return true;
-            return false;
+            if (
+                this.left > other.right ||
+                this.top > other.bottom ||
+                this.right < other.left ||
+                this.bottom < other.top
+            ) {
+                return false;
+            }
+            return true;
         }
     }
 
@@ -105,6 +105,10 @@ namespace game {
         if (s._hitbox && s._hitbox.isValid())
             return s._hitbox;
 
+        if (s._rotatedBBox) {
+            return new Hitbox(s, Fx8(s._rotatedBBox.width), Fx8(s._rotatedBBox.height), Fx.zeroFx8, Fx.zeroFx8);
+        }
+
         const i = s.image;
         let minX = Fx8(i.width);
         let minY = Fx8(i.height);
diff --git a/libs/game/pxt.json b/libs/game/pxt.json
index acb27cef2..919114311 100644
--- a/libs/game/pxt.json
+++ b/libs/game/pxt.json
@@ -14,6 +14,7 @@
         "hitbox.ts",
         "renderText.ts",
         "spritesay.ts",
+        "rotation.ts",
         "sprites.ts",
         "sprite.ts",
         "extendableSprite.ts",
diff --git a/libs/game/rotation.ts b/libs/game/rotation.ts
new file mode 100644
index 000000000..dee085e2c
--- /dev/null
+++ b/libs/game/rotation.ts
@@ -0,0 +1,194 @@
+namespace sprites {
+    let aabbPoints: number[];
+
+    export class RotatedBoundingBox {
+        protected _rotation: number;
+        protected _width: number;
+        protected _height: number;
+
+        protected points: number[];
+        protected cornerDistance: number;
+        protected cornerAngle: number;
+
+        public get x0(): number {
+            return this.points[0];
+        }
+
+        public get y0(): number {
+            return this.points[1];
+        }
+
+        public get x1(): number {
+            return this.points[2];
+        }
+
+        public get y1(): number {
+            return this.points[3];
+        }
+
+        public get x2(): number {
+            return this.points[4];
+        }
+
+        public get y2(): number {
+            return this.points[5];
+        }
+
+        public get x3(): number {
+            return this.points[6];
+        }
+
+        public get y3(): number {
+            return this.points[7];
+        }
+
+        public get rotation() {
+            return this._rotation;
+        }
+
+        public set rotation(value: number) {
+            this.setRotation(value);
+        }
+
+        public get width() {
+            return this._width;
+        }
+
+        public get height() {
+            return this._height;
+        }
+
+        constructor(
+            public anchor: Sprite,
+            width: number,
+            height: number
+        ) {
+            this.points = [];
+            this._rotation = 0;
+            this.setDimensions(width, height);
+        }
+
+        setDimensions(width: number, height: number) {
+            width /= 2;
+            height /= 2;
+
+            this.cornerDistance = Math.sqrt(
+                width * width + height * height
+            );
+            this.cornerAngle = Math.atan2(height, width);
+            this.setRotation(this._rotation);
+        }
+
+        setRotation(angle: number) {
+            this._rotation = angle;
+            this.points[0] = Math.cos(this.cornerAngle + angle) * this.cornerDistance;
+            this.points[1] = Math.sin(this.cornerAngle + angle) * this.cornerDistance;
+            this.points[2] = Math.cos(Math.PI - this.cornerAngle + angle) * this.cornerDistance;
+            this.points[3] = Math.sin(Math.PI - this.cornerAngle + angle) * this.cornerDistance;
+            this.points[4] = Math.cos(Math.PI + this.cornerAngle + angle) * this.cornerDistance;
+            this.points[5] = Math.sin(Math.PI + this.cornerAngle + angle) * this.cornerDistance;
+            this.points[6] = Math.cos(angle - this.cornerAngle) * this.cornerDistance;
+            this.points[7] = Math.sin(angle - this.cornerAngle) * this.cornerDistance;
+            this.updateWidthHeight();
+        }
+
+        overlaps(other: RotatedBoundingBox): boolean {
+            return doRectanglesIntersect(
+                this.points,
+                this.anchor.x,
+                this.anchor.y,
+                other.points,
+                other.anchor.x,
+                other.anchor.y
+            );
+        }
+
+        overlapsAABB(left: number, top: number, right: number, bottom: number) {
+            if (!aabbPoints) {
+                aabbPoints = [];
+            }
+
+            aabbPoints[0] = left;
+            aabbPoints[1] = top;
+            aabbPoints[2] = right;
+            aabbPoints[3] = top;
+            aabbPoints[4] = right;
+            aabbPoints[5] = bottom;
+            aabbPoints[6] = left;
+            aabbPoints[7] = bottom;
+            return doRectanglesIntersect(
+                this.points,
+                this.anchor.x,
+                this.anchor.y,
+                aabbPoints,
+                0,
+                0
+            );
+        }
+
+        protected updateWidthHeight() {
+            let minX = this.points[0];
+            let maxX = minX;
+            let minY = this.points[1];
+            let maxY = minY;
+
+            for (let i = 2; i < 8; i += 2) {
+                minX = Math.min(minX, this.points[i]);
+                maxX = Math.max(maxX, this.points[i]);
+                minY = Math.min(minY, this.points[i + 1]);
+                maxY = Math.max(maxY, this.points[i + 1]);
+            }
+
+            this._width = (maxX - minX) | 0;
+            this._height = (maxY - minY) | 0;
+        }
+    }
+
+    // adapted from https://stackoverflow.com/questions/10962379/how-to-check-intersection-between-2-rotated-rectangles
+    // but optimized for rectangles
+    function doRectanglesIntersect(a: number[], ax: number, ay: number, b: number[], bx: number, by: number) {
+        return !(checkForNonIntersection(a, ax, ay, b, bx, by) || checkForNonIntersection(b, bx, by, a, ax, ay));
+    }
+
+    function checkForNonIntersection(a: number[], ax: number, ay: number, b: number[], bx: number, by: number) {
+        // we only need to check the first two sides because the
+        // normals are the same for the other two
+        for (let pointIndex = 0; pointIndex < 4; pointIndex += 2) {
+            const normalX = a[pointIndex + 3] - a[pointIndex + 1];
+            const normalY = a[pointIndex] - a[pointIndex + 2];
+
+            let minA: number = undefined;
+            let maxA: number = undefined;
+            let minB: number = undefined;
+            let maxB: number = undefined;
+
+            for (let i = 0; i < 8; i += 2) {
+                const projected = normalX * (a[i] + ax) + normalY * (a[i + 1] + ay);
+
+                if (minA === undefined || projected < minA) {
+                    minA = projected;
+                }
+                if (maxA == undefined || projected > maxA) {
+                    maxA = projected;
+                }
+            }
+
+            for (let i = 0; i < 8; i += 2) {
+                const projected = normalX * (b[i] + bx) + normalY * (b[i + 1] + by);
+
+                if (minB === undefined || projected < minB) {
+                    minB = projected;
+                }
+                if (maxB == undefined || projected > maxB) {
+                    maxB = projected;
+                }
+            }
+
+            if (maxA < minB || maxB < minA) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/libs/game/sprite.ts b/libs/game/sprite.ts
index c25a0d01a..86af4ddf3 100644
--- a/libs/game/sprite.ts
+++ b/libs/game/sprite.ts
@@ -105,6 +105,7 @@ class Sprite extends sprites.BaseSprite {
     _sy: Fx8 // scale
     _width: Fx8 // scaled width
     _height: Fx8 // scaled height
+    _rotatedBBox: sprites.RotatedBoundingBox;
 
     //% group="Physics" blockSetVariable="mySprite"
     //% blockCombine block="x" callInDebugger
@@ -200,10 +201,12 @@ class Sprite extends sprites.BaseSprite {
     //% group="Physics" blockSetVariable="mySprite"
     //% blockCombine block="sx (scale x)"
     set sx(v: number) {
+        const y = this.y;
         const x = this.x;
         this._sx = Fx8(Math.max(0, v));
         this.recalcSize();
-        this.left = x - this.width / 2;
+        this.y = y;
+        this.x = x;
     }
     //% group="Physics" blockSetVariable="mySprite"
     //% blockCombine block="sy (scale y)" callInDebugger
@@ -214,10 +217,13 @@ class Sprite extends sprites.BaseSprite {
     //% blockCombine block="sy (scale y)"
     set sy(v: number) {
         const y = this.y;
+        const x = this.x;
         this._sy = Fx8(Math.max(0, v));
         this.recalcSize();
-        this.top = y - this.height / 2;
+        this.y = y;
+        this.x = x;
     }
+
     //% group="Physics" blockSetVariable="mySprite"
     //% blockCombine block="scale" callInDebugger
     get scale(): number {
@@ -229,6 +235,36 @@ class Sprite extends sprites.BaseSprite {
         this.sx = this.sy = v;
     }
 
+    //% group="Physics" blockSetVariable="mySprite"
+    //% blockCombine block="rotation (radians)" callInDebugger
+    get rotation(): number {
+        return this._rotatedBBox ? this._rotatedBBox.rotation : 0;
+    }
+    //% group="Physics" blockSetVariable="mySprite"
+    //% blockCombine block="rotation (radians)"
+    set rotation(v: number) {
+        const x = this.x;
+        const y = this.y;
+        if (!this._rotatedBBox) {
+            this._rotatedBBox = new sprites.RotatedBoundingBox(this, this.width, this.height);
+        }
+        this._rotatedBBox.setRotation(v);
+        this.recalcSize();
+        this.x = x;
+        this.y = y;
+    }
+
+    //% group="Physics" blockSetVariable="mySprite"
+    //% blockCombine block="rotation (degrees)" callInDebugger
+    get rotationDegrees(): number {
+        return this.rotation * 180 / Math.PI;
+    }
+    //% group="Physics" blockSetVariable="mySprite"
+    //% blockCombine block="rotation (degrees)"
+    set rotationDegrees(v: number) {
+        this.rotation = v * Math.PI / 180;
+    }
+
     private _data: any;
     /**
      * Custom data
@@ -338,7 +374,7 @@ class Sprite extends sprites.BaseSprite {
     }
 
     calcDimensionalHash() {
-        return this._image.revision() + Fx.toIntShifted(this._width, 8) + Fx.toIntShifted(this._height, 16);
+        return this._image.revision() + Fx.toIntShifted(this._width, 8) + Fx.toIntShifted(this._height, 16) + this.rotation;
     }
 
     resetHitbox() {
@@ -363,8 +399,15 @@ class Sprite extends sprites.BaseSprite {
     }
 
     protected recalcSize(): void {
-        this._width = Fx8(this._image.width * this.sx);
-        this._height = Fx8(this._image.height * this.sy);
+        if (this._rotatedBBox) {
+            this._rotatedBBox.setDimensions(this._image.width * this.sx, this._image.height * this.sy);
+            this._width = Fx8(this._rotatedBBox.width);
+            this._height = Fx8(this._rotatedBBox.height);
+        }
+        else {
+            this._width = Fx8(this._image.width * this.sx);
+            this._height = Fx8(this._image.height * this.sy);
+        }
         this.resetHitbox();
     }
 
@@ -688,7 +731,7 @@ class Sprite extends sprites.BaseSprite {
     //% blockId=spriteoverlapswith block="%sprite(mySprite) overlaps with %other=variables_get(otherSprite)"
     //% help=sprites/sprite/overlaps-with
     //% weight=90
-    overlapsWith(other: Sprite) {
+    overlapsWith(other: Sprite): boolean {
         control.enablePerfCounter("overlapsCPP")
         if (other == this) return false;
         if (this.flags & SPRITE_NO_SPRITE_OVERLAPS)
@@ -699,7 +742,47 @@ class Sprite extends sprites.BaseSprite {
             return other._hitbox.overlapsWith(this._hitbox);
         if (!other._hitbox.overlapsWith(this._hitbox))
             return false;
-        if (!this.isScaled() && !other.isScaled()) {
+        else if (this._rotatedBBox) {
+            if (other._rotatedBBox) {
+                if (this._rotatedBBox.overlaps(other._rotatedBBox)) {
+                    return helpers.checkOverlapsTwoScaledRotatedImages(
+                        other.image,
+                        this.left - other.left,
+                        this.top - other.top,
+                        other.sx,
+                        other.sy,
+                        other.rotation,
+                        this.image,
+                        this.sx,
+                        this.sy,
+                        this.rotation
+                    );
+                }
+                else {
+                    return false;
+                }
+            }
+            else {
+                if (this._rotatedBBox.overlapsAABB(other.left, other.top, other.right, other.bottom)) {
+                    return helpers.checkOverlapsScaledRotatedImage(
+                        other.image,
+                        this.left - other.left,
+                        this.top - other.top,
+                        this.image,
+                        this.sx,
+                        this.sy,
+                        this.rotation
+                    );
+                }
+                else {
+                    return false;
+                }
+            }
+        }
+        else if (other._rotatedBBox) {
+            return other.overlapsWith(this);
+        }
+        else if (!this.isScaled() && !other.isScaled()) {
             return other._image.overlapsWith(
                 this._image,
                 this.left - other.left,
@@ -1117,7 +1200,18 @@ class Sprite extends sprites.BaseSprite {
     }
 
     protected drawSprite(drawLeft: number, drawTop: number) {
-        if (!this.isScaled())
+        if (this._rotatedBBox) {
+            helpers.imageDrawScaledRotated(
+                screen,
+                drawLeft,
+                drawTop,
+                this._image,
+                this.sx,
+                this.sy,
+                this.rotation
+            );
+        }
+        else if (!this.isScaled())
             screen.drawTransparentImage(this._image, drawLeft, drawTop);
         else
             screen.blit(
diff --git a/libs/screen/image.cpp b/libs/screen/image.cpp
index 782ab8185..a06e1622f 100644
--- a/libs/screen/image.cpp
+++ b/libs/screen/image.cpp
@@ -1,5 +1,6 @@
 #include "pxt.h"
 
+
 #if IMAGE_BITS == 1
 // OK
 #elif IMAGE_BITS == 4
@@ -1125,6 +1126,379 @@ bool blit(Image_ dst, Image_ src, pxt::RefCollection *args) {
     return false;
 }
 
+#define FX_SHIFT 16
+#define FX_ONE (1 << FX_SHIFT)
+
+inline int fxMul(int a, int b) {
+    return (int)(((int64_t)a * b) >> FX_SHIFT);
+}
+
+inline int fxDiv(int a, int b) {
+    return (int)(((int64_t)a << FX_SHIFT) / b);
+}
+
+inline int fxToInt(int v) {
+    return v >> FX_SHIFT;
+}
+
+inline int fxFloor(int v) {
+    return v & 0xffff0000;
+}
+
+#define TWO_PI 6.28318530718
+#define PI 3.14159265359
+#define HALF_PI 1.57079632679
+#define THREE_HALF_PI 4.71238898038
+
+#define SHEAR(x, y, xShear, yShear) \
+shearedX = fxFloor(x + fxMul(y, xShear)); \
+shearedY = fxFloor(y + fxMul(shearedX, yShear)); \
+shearedX = fxFloor(shearedX + fxMul(shearedY, xShear));
+
+#define REVERSE_SHEAR(x, y, xShear, yShear) \
+unshearedX = fxFloor(x - fxMul(y, xShear)); \
+unshearedY = fxFloor(y - fxMul(unshearedX, yShear)); \
+unshearedX = fxFloor(unshearedX - fxMul(unshearedY, xShear));
+
+
+typedef struct {
+    int sx;
+    int sy;
+    int xShear;
+    int yShear;
+    int minX;
+    int minY;
+    int maxX;
+    int maxY;
+    int scaledWidth;
+    int scaledHeight;
+    bool flip;
+} ParsedShearArgs;
+
+ParsedShearArgs parseShearArgs(Image_ src, pxt::RefCollection *args, int argIndex) {
+    ParsedShearArgs parsed;
+    int sx = pxt::toDouble(args->getAt(argIndex)) * FX_ONE;
+    int sy = pxt::toDouble(args->getAt(argIndex + 1)) * FX_ONE;
+    double angle = pxt::toDouble(args->getAt(argIndex + 2));
+
+    parsed.sx = sx;
+    parsed.sy = sy;
+
+    if (sx <= 0 || sy <= 0) {
+        return parsed;
+    }
+
+    parsed.flip = false;
+
+    angle = fmod(angle, TWO_PI);
+
+    if (angle < 0) {
+        angle = angle + TWO_PI;
+    }
+
+    if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+        parsed.flip = true;
+        angle = fmod(angle + PI, TWO_PI);
+    }
+
+    int xShear = (-1.0 * tan(angle / 2.0)) * FX_ONE;
+    int yShear = (sin(angle)) * FX_ONE;
+
+    int scaledWidth = sx * src->width();
+    int scaledHeight = sy * src->height();
+
+    int shearedX = 0;
+    int shearedY = 0;
+
+    SHEAR(0, 0, xShear, yShear);
+    int minX = shearedX;
+    int minY = shearedY;
+    int maxX = shearedX;
+    int maxY = shearedY;
+
+    SHEAR(scaledWidth - FX_ONE, 0, xShear, yShear);
+    minX = min(minX, shearedX);
+    minY = min(minY, shearedY);
+    maxX = max(maxX, shearedX);
+    maxY = max(maxY, shearedY);
+
+    SHEAR(scaledWidth - FX_ONE, scaledHeight - FX_ONE, xShear, yShear);
+    minX = min(minX, shearedX);
+    minY = min(minY, shearedY);
+    maxX = max(maxX, shearedX);
+    maxY = max(maxY, shearedY);
+
+    SHEAR(0, scaledHeight - FX_ONE, xShear, yShear);
+    minX = min(minX, shearedX);
+    minY = min(minY, shearedY);
+    maxX = max(maxX, shearedX);
+    maxY = max(maxY, shearedY);
+
+    parsed.minX = minX;
+    parsed.minY = minY;
+    parsed.maxX = maxX;
+    parsed.maxY = maxY;
+    parsed.scaledWidth = scaledWidth;
+    parsed.scaledHeight = scaledHeight;
+    parsed.xShear = xShear;
+    parsed.yShear = yShear;
+
+    return parsed;
+}
+
+//%
+void _drawScaledRotatedImage(Image_ dst, Image_ src, pxt::RefCollection *args) {
+    int xDst = pxt::toInt(args->getAt(0));
+    int yDst = pxt::toInt(args->getAt(1));
+    if (xDst >= dst->width() || yDst >= dst->height()) {
+        return;
+    }
+
+    ParsedShearArgs shearArgs = parseShearArgs(src, args, 2);
+
+    if (
+        shearArgs.sx <= 0 ||
+        shearArgs.sy <= 0 ||
+        xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+        yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+    ) {
+        return;
+    }
+
+    int shearedX = 0;
+    int shearedY = 0;
+
+    dst->makeWritable();
+
+    if (shearArgs.flip) {
+        for (int y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv((shearArgs.scaledWidth - x - FX_ONE), shearArgs.sx)),
+                    fxToInt(fxDiv((shearArgs.scaledHeight - y - FX_ONE), shearArgs.sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, shearArgs.xShear, shearArgs.yShear);
+                setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+            }
+        }
+    }
+    else {
+        for (int y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv(x, shearArgs.sx)),
+                    fxToInt(fxDiv(y,shearArgs. sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, shearArgs.xShear, shearArgs.yShear);
+                setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+            }
+        }
+    }
+}
+
+//%
+bool _checkOverlapsScaledRotatedImage(Image_ dst, Image_ src, pxt::RefCollection *args) {
+    int xDst = pxt::toInt(args->getAt(0));
+    int yDst = pxt::toInt(args->getAt(1));
+    if (xDst >= dst->width() || yDst >= dst->height()) {
+        return false;
+    }
+
+    ParsedShearArgs shearArgs = parseShearArgs(src, args, 2);
+
+    if (
+        shearArgs.sx <= 0 ||
+        shearArgs.sy <= 0 ||
+        xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+        yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+    ) {
+        return false;
+    }
+
+    int shearedX = 0;
+    int shearedY = 0;
+
+    if (shearArgs.flip) {
+        for (int y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv((shearArgs.scaledWidth - x - FX_ONE), shearArgs.sx)),
+                    fxToInt(fxDiv((shearArgs.scaledHeight - y - FX_ONE), shearArgs.sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, shearArgs.xShear, shearArgs.yShear);
+                if (getPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY))) {
+                    return true;
+                }
+            }
+        }
+    }
+    else {
+        for (int y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv(x, shearArgs.sx)),
+                    fxToInt(fxDiv(y,shearArgs. sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, shearArgs.xShear, shearArgs.yShear);
+                if (getPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY))) {
+                    return true;
+                }
+            }
+        }
+    }
+    return false;
+}
+
+//%
+bool _checkOverlapsTwoScaledRotatedImages(Image_ dst, Image_ src, pxt::RefCollection *args) {
+    int xDst = pxt::toInt(args->getAt(0)) * FX_ONE;
+    int yDst = pxt::toInt(args->getAt(1)) * FX_ONE;
+    ParsedShearArgs dstArgs = parseShearArgs(dst, args, 2);
+
+    if (
+        dstArgs.sx <= 0 ||
+        dstArgs.sy <= 0 ||
+        xDst >= dstArgs.maxX - dstArgs.minX ||
+        yDst >= dstArgs.maxY - dstArgs.minY
+    ) {
+        return false;
+    }
+
+    ParsedShearArgs srcArgs = parseShearArgs(src, args, 5);
+
+    if (
+        srcArgs.sx <= 0 ||
+        srcArgs.sy <= 0 ||
+        xDst + srcArgs.maxX - srcArgs.minX < 0 ||
+        yDst + srcArgs.maxY - srcArgs.minY < 0
+    ) {
+        return false;
+    }
+
+    int shearedX = 0;
+    int shearedY = 0;
+    int unshearedX = 0;
+    int unshearedY = 0;
+
+    if (srcArgs.flip) {
+        for (int y = 0; y < srcArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < srcArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv((srcArgs.scaledWidth - x - FX_ONE), srcArgs.sx)),
+                    fxToInt(fxDiv((srcArgs.scaledHeight - y - FX_ONE), srcArgs.sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, srcArgs.xShear, srcArgs.yShear);
+
+                int screenX = xDst + shearedX - srcArgs.minX;
+                int screenY = yDst + shearedY - srcArgs.minY;
+
+                if (
+                    screenX < 0 ||
+                    screenY < 0 ||
+                    screenX >= dstArgs.maxX - dstArgs.minX ||
+                    screenY >= dstArgs.maxY - dstArgs.minY
+                ) {
+                    continue;
+                }
+
+                REVERSE_SHEAR(screenX + dstArgs.minX, screenY + dstArgs.minY, dstArgs.xShear, dstArgs.yShear);
+
+                if (dstArgs.flip) {
+                    if (
+                        getPixel(
+                            dst,
+                            fxToInt(fxDiv(dstArgs.scaledWidth - unshearedX - FX_ONE, dstArgs.sx)),
+                            fxToInt(fxDiv(dstArgs.scaledHeight - unshearedY - FX_ONE, dstArgs.sy))
+                        )
+                    ) {
+                        return true;
+                    }
+                }
+                else if (
+                    getPixel(
+                        dst,
+                        fxToInt(fxDiv(unshearedX, dstArgs.sx)),
+                        fxToInt(fxDiv(unshearedY, dstArgs.sy))
+                    )
+                ) {
+                    return true;
+                }
+            }
+        }
+    }
+    else {
+        for (int y = 0; y < srcArgs.scaledHeight; y += FX_ONE) {
+            for (int x = 0; x < srcArgs.scaledWidth; x += FX_ONE) {
+                int color = getPixel(
+                    src,
+                    fxToInt(fxDiv(x, srcArgs.sx)),
+                    fxToInt(fxDiv(y, srcArgs.sy))
+                );
+
+                if (!color) continue;
+
+                SHEAR(x, y, srcArgs.xShear, srcArgs.yShear);
+
+                int screenX = xDst + shearedX - srcArgs.minX;
+                int screenY = yDst + shearedY - srcArgs.minY;
+
+                if (
+                    screenX < 0 ||
+                    screenY < 0 ||
+                    screenX >= dstArgs.maxX - dstArgs.minX ||
+                    screenY >= dstArgs.maxY - dstArgs.minY
+                ) {
+                    continue;
+                }
+
+                REVERSE_SHEAR(screenX + dstArgs.minX, screenY + dstArgs.minY, dstArgs.xShear, dstArgs.yShear);
+
+                if (dstArgs.flip) {
+                    if (
+                        getPixel(
+                            dst,
+                            fxToInt(fxDiv(dstArgs.scaledWidth - unshearedX - FX_ONE, dstArgs.sx)),
+                            fxToInt(fxDiv(dstArgs.scaledHeight - unshearedY - FX_ONE, dstArgs.sy))
+                        )
+                    ) {
+                        return true;
+                    }
+                }
+                else if (
+                    getPixel(
+                        dst,
+                        fxToInt(fxDiv(unshearedX, dstArgs.sx)),
+                        fxToInt(fxDiv(unshearedY, dstArgs.sy))
+                    )
+                ) {
+                    return true;
+                }
+            }
+        }
+    }
+    return false;
+}
+
 //%
 bool _blit(Image_ img, Image_ src, pxt::RefCollection *args) {
     return blit(img, src, args);
diff --git a/libs/screen/image.ts b/libs/screen/image.ts
index 96a46ca5e..5bec4e1ef 100644
--- a/libs/screen/image.ts
+++ b/libs/screen/image.ts
@@ -165,6 +165,15 @@ namespace helpers {
     //% shim=ImageMethods::_blit
     declare function _blit(img: Image, src: Image, args: number[]): boolean;
 
+    //% shim=ImageMethods::_drawScaledRotatedImage
+    declare function _drawScaledRotatedImage(img: Image, src: Image, args: number[]): void;
+
+    //% shim=ImageMethods::_checkOverlapsScaledRotatedImage
+    declare function _checkOverlapsScaledRotatedImage(img: Image, src: Image, args: number[]): boolean;
+
+    //% shim=ImageMethods::_checkOverlapsTwoScaledRotatedImages
+    declare function _checkOverlapsTwoScaledRotatedImages(img: Image, src: Image, args: number[]): boolean;
+
     //% shim=ImageMethods::_fillTriangle
     declare function _fillTriangle(img: Image, args: number[]): void;
 
@@ -280,6 +289,39 @@ namespace helpers {
         _fillPolygon4(img, _blitArgs);
     }
 
+    export function imageDrawScaledRotated(dest: Image, destX: number, destY: number, src: Image, sx: number, sy: number, angle: number) {
+        _blitArgs = _blitArgs || [];
+        _blitArgs[0] = destX | 0;
+        _blitArgs[1] = destY | 0;
+        _blitArgs[2] = sx;
+        _blitArgs[3] = sy;
+        _blitArgs[4] = angle;
+        _drawScaledRotatedImage(dest, src, _blitArgs);
+    }
+
+    export function checkOverlapsScaledRotatedImage(dest: Image, destX: number, destY: number, src: Image, sx: number, sy: number, angle: number) {
+        _blitArgs = _blitArgs || [];
+        _blitArgs[0] = destX | 0;
+        _blitArgs[1] = destY | 0;
+        _blitArgs[2] = sx;
+        _blitArgs[3] = sy;
+        _blitArgs[4] = angle;
+        return _checkOverlapsScaledRotatedImage(dest, src, _blitArgs);
+    }
+
+    export function checkOverlapsTwoScaledRotatedImages(dest: Image, destX: number, destY: number, destSx: number, destSy: number, destAngle: number, src: Image, sx: number, sy: number, angle: number) {
+        _blitArgs = _blitArgs || [];
+        _blitArgs[0] = destX | 0;
+        _blitArgs[1] = destY | 0;
+        _blitArgs[2] = destSx;
+        _blitArgs[3] = destSy;
+        _blitArgs[4] = destAngle;
+        _blitArgs[5] = sx;
+        _blitArgs[6] = sy;
+        _blitArgs[7] = angle;
+        return _checkOverlapsTwoScaledRotatedImages(dest, src, _blitArgs);
+    }
+
     /**
      * Returns an image rotated by 90, 180, 270 deg clockwise
      */
diff --git a/libs/screen/sim/image.ts b/libs/screen/sim/image.ts
index ece299893..561dbb395 100644
--- a/libs/screen/sim/image.ts
+++ b/libs/screen/sim/image.ts
@@ -933,6 +933,412 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    interface ParsedShearArgs {
+        sx: number;
+        sy: number;
+        scaledWidth: number;
+        scaledHeight: number;
+        minX: number;
+        minY: number;
+        maxX: number;
+        maxY: number;
+        xShear: number;
+        yShear: number;
+        flip: boolean;
+    }
+
+    function parseShearArgs(src: RefImage, args: RefCollection, argIndex: number): ParsedShearArgs {
+        const parsed: ParsedShearArgs = {
+            sx: 0,
+            sy: 0,
+            scaledWidth: 0,
+            scaledHeight: 0,
+            minX: 0,
+            minY: 0,
+            maxX: 0,
+            maxY: 0,
+            xShear: 0,
+            yShear: 0,
+            flip: false
+        };
+
+        const sx = ((args.getAt(argIndex) as number) * FX_ONE);
+        const sy = ((args.getAt(argIndex + 1) as number) * FX_ONE);
+        let angle = args.getAt(argIndex + 2) as number;
+
+        parsed.sx = sx;
+        parsed.sy = sy;
+
+        if (sx <= 0 || sy <= 0) {
+            return parsed;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += TWO_PI;
+        }
+
+        let flip = false;
+        if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+            flip = true
+            angle = (angle + Math.PI) % TWO_PI
+        }
+
+        const xShear = (-Math.tan(angle / 2) * FX_ONE);
+        const yShear = (Math.sin(angle) * FX_ONE);
+
+        const scaledWidth = src._width * sx;
+        const scaledHeight = src._height * sy;
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, xShear));
+        }
+
+        SHEAR(0, 0);
+        let minX = shearedX;
+        let minY = shearedY;
+        let maxX = shearedX;
+        let maxY = shearedY;
+
+        SHEAR(scaledWidth - FX_ONE, 0);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(scaledWidth - FX_ONE, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(0, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        parsed.minX = minX;
+        parsed.minY = minY;
+        parsed.maxX = maxX;
+        parsed.maxY = maxY;
+        parsed.scaledWidth = scaledWidth;
+        parsed.scaledHeight = scaledHeight;
+        parsed.xShear = xShear;
+        parsed.yShear = yShear;
+        parsed.flip = flip;
+
+        return parsed;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+        dst.makeWritable();
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - FX_ONE), shearArgs.sx)),
+                        fxToInt(fxDiv((shearArgs.scaledHeight - y - FX_ONE), shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+        else {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv(x, shearArgs.sx)),
+                        fxToInt(fxDiv(y, shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+    }
+
+    export function _checkOverlapsScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection): boolean {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return false;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return false;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - FX_ONE), shearArgs.sx)),
+                        fxToInt(fxDiv((shearArgs.scaledHeight - y - FX_ONE), shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    if (getPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY))) {
+                        return true;
+                    }
+                }
+            }
+        }
+        else {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv(x, shearArgs.sx)),
+                        fxToInt(fxDiv(y, shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    if (getPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY))) {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
+
+    export function _checkOverlapsTwoScaledRotatedImages(dst: RefImage, src: RefImage, args: RefCollection): boolean {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        const dstArgs = parseShearArgs(dst, args, 2);
+
+        if (
+            dstArgs.sx <= 0 ||
+            dstArgs.sy <= 0 ||
+            xDst >= dstArgs.maxX - dstArgs.minX ||
+            yDst >= dstArgs.maxY - dstArgs.minY
+        ) {
+            return false;
+        }
+
+        const srcArgs = parseShearArgs(src, args, 5);
+
+        if (
+            srcArgs.sx <= 0 ||
+            srcArgs.sy <= 0 ||
+            xDst + srcArgs.maxX - srcArgs.minX < 0 ||
+            yDst + srcArgs.maxY - srcArgs.minY < 0
+        ) {
+            return false;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+        let unshearedX = 0;
+        let unshearedY = 0;
+
+        const SHEAR = (x: number, y: number, xShear: number, yShear: number) => {
+            shearedX = fxFloor(x + fxMul(y, xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, xShear));
+        }
+
+        const REVERSE_SHEAR = (x: number, y: number, xShear: number, yShear: number) => {
+            unshearedX = fxFloor(x - fxMul(y, xShear));
+            unshearedY = fxFloor(y - fxMul(unshearedX, yShear));
+            unshearedX = fxFloor(unshearedX - fxMul(unshearedY, xShear));
+        }
+
+        if (srcArgs.flip) {
+            for (let y = 0; y < srcArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < srcArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((srcArgs.scaledWidth - x - FX_ONE), srcArgs.sx)),
+                        fxToInt(fxDiv((srcArgs.scaledHeight - y - FX_ONE), srcArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y, srcArgs.xShear, srcArgs.yShear);
+
+                    const screenX = xDst + shearedX - srcArgs.minX;
+                    const screenY = yDst + shearedY - srcArgs.minY;
+
+                    if (
+                        screenX < 0 ||
+                        screenY < 0 ||
+                        screenX >= dstArgs.maxX - dstArgs.minX ||
+                        screenY >= dstArgs.maxY - dstArgs.minY
+                    ) {
+                        continue;
+                    }
+
+                    REVERSE_SHEAR(screenX + dstArgs.minX, screenY + dstArgs.minY, dstArgs.xShear, dstArgs.yShear);
+
+                    if (dstArgs.flip) {
+                        if (
+                            getPixel(
+                                dst,
+                                fxToInt(fxDiv(dstArgs.scaledWidth - unshearedX - FX_ONE, dstArgs.sx)),
+                                fxToInt(fxDiv(dstArgs.scaledHeight - unshearedY - FX_ONE, dstArgs.sy))
+                            )
+                        ) {
+                            return true;
+                        }
+                    }
+                    else if (
+                        getPixel(
+                            dst,
+                            fxToInt(fxDiv(unshearedX, dstArgs.sx)),
+                            fxToInt(fxDiv(unshearedY, dstArgs.sy))
+                        )
+                    ) {
+                        return true;
+                    }
+                }
+            }
+        }
+        else {
+            for (let y = 0; y < srcArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < srcArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv(x, srcArgs.sx)),
+                        fxToInt(fxDiv(y, srcArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y, srcArgs.xShear, srcArgs.yShear);
+
+                    const screenX = xDst + shearedX - srcArgs.minX;
+                    const screenY = yDst + shearedY - srcArgs.minY;
+
+                    if (
+                        screenX < 0 ||
+                        screenY < 0 ||
+                        screenX >= dstArgs.maxX - dstArgs.minX ||
+                        screenY >= dstArgs.maxY - dstArgs.minY
+                    ) {
+                        continue;
+                    }
+
+                    REVERSE_SHEAR(screenX + dstArgs.minX, screenY + dstArgs.minY, dstArgs.xShear, dstArgs.yShear);
+
+                    if (dstArgs.flip) {
+                        if (
+                            getPixel(
+                                dst,
+                                fxToInt(fxDiv(dstArgs.scaledWidth - unshearedX - FX_ONE, dstArgs.sx)),
+                                fxToInt(fxDiv(dstArgs.scaledHeight - unshearedY - FX_ONE, dstArgs.sy))
+                            )
+                        ) {
+                            return true;
+                        }
+                    }
+                    else if (
+                        getPixel(
+                            dst,
+                            fxToInt(fxDiv(unshearedX, dstArgs.sx)),
+                            fxToInt(fxDiv(unshearedY, dstArgs.sy))
+                        )
+                    ) {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        return false;
+    }
 }
 
 


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds a rotation property to sprites using a fixed-point “three shears” algorithm to rotate sprites before drawing. Key changes include:
- New fixed-point math functions and constants in the simulation image module.
- Integration of rotation into sprite property getters/setters, dimensional hash calculation, and hitbox computation.
- A new rotation bounding box class with collision detection logic.

### Reviewed Changes

Copilot reviewed 5 out of 7 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                           | Description                                                              |
| ------------------------------ | ------------------------------------------------------------------------ |
| libs/screen/sim/image.ts       | Added fixed-point math utilities and the drawScaledRotatedImage routine. |
| libs/screen/image.ts           | Exposed a new helper function for drawing scaled and rotated images.     |
| libs/game/sprite.ts            | Integrated rotation properties into sprites and updated size & hitbox.   |
| libs/game/rotation.ts          | New implementation for rotated bounding box calculations.              |
| libs/game/hitbox.ts            | Updated hitbox logic to account for rotated sprites.                     |
</details>


<details>
<summary>Files not reviewed (2)</summary>

* **libs/game/pxt.json**: Language not supported
* **libs/screen/image.cpp**: Language not supported
</details>


**Tip:** If you use Visual Studio Code, you can request a review from Copilot before you push from the "Source Control" tab.  [Learn more](https://gh.io/copilot-code-reviews-docs)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T00:00:51Z
user: login: eanders-ms
body: Left a few comments. I think I noticed some mixing of fixed and float math. I'd recommend switching to actual fixed point in the TS implementation, to ensure it's consistent. It might make maintaining parity with the cpp implementation easier too (more 1 to 1).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:50:38Z
user: login: riknoll
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T17:43:55Z
user: login: riknoll
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T17:44:55Z
user: login: riknoll
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T17:45:22Z
user: login: eanders-ms
body: Looks good!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T21:37:49Z

Review comments:
diff_hunk: @@ -933,6 +933,136 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        const sx = ((args.getAt(2) as number) * FX_ONE);
+        const sy = ((args.getAt(3) as number) * FX_ONE);
+        let angle = args.getAt(4) as number;
+
+        if (sx <= 0 || sy <= 0 || xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
path: libs/screen/sim/image.ts
user: login: Copilot
body: The negative angle normalization adds Math.PI instead of TWO_PI which may cause incorrect angle mapping. Consider using TWO_PI to correctly normalize negative angles.
```suggestion
            angle += TWO_PI;
```
created_at: 2025-03-27T00:00:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -338,7 +374,7 @@ class Sprite extends sprites.BaseSprite {
     }
 
     calcDimensionalHash() {
-        return this._image.revision() + Fx.toIntShifted(this._width, 8) + Fx.toIntShifted(this._height, 16);
+        return this._image.revision() + Fx.toIntShifted(this._width, 8) + Fx.toIntShifted(this._height, 16) + this.rotation;
path: libs/game/sprite.ts
user: login: Copilot
body: Mixing a floating-point rotation value with integer components in the dimensional hash may lead to precision issues. Consider converting the rotation value to an integer (using a fixed-point conversion) before adding it to the hash.
```suggestion
        const fixedPointRotation = Math.round(this.rotation * 1000);
        return this._image.revision() + Fx.toIntShifted(this._width, 8) + Fx.toIntShifted(this._height, 16) + fixedPointRotation;
```
created_at: 2025-03-27T00:00:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -933,6 +933,136 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        const sx = ((args.getAt(2) as number) * FX_ONE);
+        const sy = ((args.getAt(3) as number) * FX_ONE);
+        let angle = args.getAt(4) as number;
+
+        if (sx <= 0 || sy <= 0 || xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
path: libs/screen/sim/image.ts
user: login: eanders-ms
body: I wondered about this too. Was copilot wrong here?
created_at: 2025-03-28T16:36:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -933,6 +933,412 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    interface ParsedShearArgs {
+        sx: number;
+        sy: number;
+        scaledWidth: number;
+        scaledHeight: number;
+        minX: number;
+        minY: number;
+        maxX: number;
+        maxY: number;
+        xShear: number;
+        yShear: number;
+        flip: boolean;
+    }
+
+    function parseShearArgs(src: RefImage, args: RefCollection, argIndex: number): ParsedShearArgs {
+        const parsed: ParsedShearArgs = {
+            sx: 0,
+            sy: 0,
+            scaledWidth: 0,
+            scaledHeight: 0,
+            minX: 0,
+            minY: 0,
+            maxX: 0,
+            maxY: 0,
+            xShear: 0,
+            yShear: 0,
+            flip: false
+        };
+
+        const sx = ((args.getAt(argIndex) as number) * FX_ONE);
+        const sy = ((args.getAt(argIndex + 1) as number) * FX_ONE);
+        let angle = args.getAt(argIndex + 2) as number;
+
+        parsed.sx = sx;
+        parsed.sy = sy;
+
+        if (sx <= 0 || sy <= 0) {
+            return parsed;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
+        }
+
+        let flip = false;
+        if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+            flip = true
+            angle = (angle + Math.PI) % TWO_PI
+        }
+
+        const xShear = (-Math.tan(angle / 2) * FX_ONE);
+        const yShear = (Math.sin(angle) * FX_ONE);
+
+        const scaledWidth = src._width * sx;
path: libs/screen/sim/image.ts
user: login: eanders-ms
body: It seems the TS implementation isn't actually using fixed point, but if it were, this line would be mixing fixed and floating point math.
created_at: 2025-03-28T16:41:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -933,6 +933,412 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    interface ParsedShearArgs {
+        sx: number;
+        sy: number;
+        scaledWidth: number;
+        scaledHeight: number;
+        minX: number;
+        minY: number;
+        maxX: number;
+        maxY: number;
+        xShear: number;
+        yShear: number;
+        flip: boolean;
+    }
+
+    function parseShearArgs(src: RefImage, args: RefCollection, argIndex: number): ParsedShearArgs {
+        const parsed: ParsedShearArgs = {
+            sx: 0,
+            sy: 0,
+            scaledWidth: 0,
+            scaledHeight: 0,
+            minX: 0,
+            minY: 0,
+            maxX: 0,
+            maxY: 0,
+            xShear: 0,
+            yShear: 0,
+            flip: false
+        };
+
+        const sx = ((args.getAt(argIndex) as number) * FX_ONE);
+        const sy = ((args.getAt(argIndex + 1) as number) * FX_ONE);
+        let angle = args.getAt(argIndex + 2) as number;
+
+        parsed.sx = sx;
+        parsed.sy = sy;
+
+        if (sx <= 0 || sy <= 0) {
+            return parsed;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
+        }
+
+        let flip = false;
+        if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+            flip = true
+            angle = (angle + Math.PI) % TWO_PI
+        }
+
+        const xShear = (-Math.tan(angle / 2) * FX_ONE);
+        const yShear = (Math.sin(angle) * FX_ONE);
+
+        const scaledWidth = src._width * sx;
+        const scaledHeight = src._height * sy;
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, xShear));
+        }
+
+        SHEAR(0, 0);
+        let minX = shearedX;
+        let minY = shearedY;
+        let maxX = shearedX;
+        let maxY = shearedY;
+
+        SHEAR(scaledWidth - FX_ONE, 0);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(scaledWidth - FX_ONE, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(0, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        parsed.minX = minX;
+        parsed.minY = minY;
+        parsed.maxX = maxX;
+        parsed.maxY = maxY;
+        parsed.scaledWidth = scaledWidth;
+        parsed.scaledHeight = scaledHeight;
+        parsed.xShear = xShear;
+        parsed.yShear = yShear;
+        parsed.flip = flip;
+
+        return parsed;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+        dst.makeWritable();
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - 1), shearArgs.sx)),
+                        fxToInt(fxDiv((shearArgs.scaledHeight - y - 1), shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+        else {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv(x, shearArgs.sx)),
+                        fxToInt(fxDiv(y, shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+    }
+
+    export function _checkOverlapsScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection): boolean {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return false;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return false;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - 1), shearArgs.sx)),
path: libs/screen/sim/image.ts
user: login: eanders-ms
body: Mixing fixed and float here? Should this use FX_ONE instead of the literal `1`?
created_at: 2025-03-28T16:47:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -933,6 +933,136 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        const sx = ((args.getAt(2) as number) * FX_ONE);
+        const sy = ((args.getAt(3) as number) * FX_ONE);
+        let angle = args.getAt(4) as number;
+
+        if (sx <= 0 || sy <= 0 || xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
path: libs/screen/sim/image.ts
user: login: riknoll
body: yeah, you're right; not sure why i skipped this. i'll fix it
created_at: 2025-03-28T17:43:55Z
author_association: MEMBER
diff_hunk: @@ -933,6 +933,412 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    interface ParsedShearArgs {
+        sx: number;
+        sy: number;
+        scaledWidth: number;
+        scaledHeight: number;
+        minX: number;
+        minY: number;
+        maxX: number;
+        maxY: number;
+        xShear: number;
+        yShear: number;
+        flip: boolean;
+    }
+
+    function parseShearArgs(src: RefImage, args: RefCollection, argIndex: number): ParsedShearArgs {
+        const parsed: ParsedShearArgs = {
+            sx: 0,
+            sy: 0,
+            scaledWidth: 0,
+            scaledHeight: 0,
+            minX: 0,
+            minY: 0,
+            maxX: 0,
+            maxY: 0,
+            xShear: 0,
+            yShear: 0,
+            flip: false
+        };
+
+        const sx = ((args.getAt(argIndex) as number) * FX_ONE);
+        const sy = ((args.getAt(argIndex + 1) as number) * FX_ONE);
+        let angle = args.getAt(argIndex + 2) as number;
+
+        parsed.sx = sx;
+        parsed.sy = sy;
+
+        if (sx <= 0 || sy <= 0) {
+            return parsed;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
+        }
+
+        let flip = false;
+        if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+            flip = true
+            angle = (angle + Math.PI) % TWO_PI
+        }
+
+        const xShear = (-Math.tan(angle / 2) * FX_ONE);
+        const yShear = (Math.sin(angle) * FX_ONE);
+
+        const scaledWidth = src._width * sx;
path: libs/screen/sim/image.ts
user: login: riknoll
body: this is fine, though, since we're dealing with integers and this would just require unnecessary bit shifting to convert into FX and immediately convert back
created_at: 2025-03-28T17:44:55Z
author_association: MEMBER
diff_hunk: @@ -933,6 +933,412 @@ namespace pxsim.ImageMethods {
         }
         return false;
     }
+
+    const TWO_PI = 2 * Math.PI;
+    const HALF_PI = Math.PI / 2;
+    const THREE_HALF_PI = 3 * Math.PI / 2
+
+    const FX_ONE = 1;
+
+    function fxMul(a: number, b: number) {
+        return (a * b)
+    }
+
+    function fxDiv(a: number, b: number) {
+        return a / b;
+    }
+
+    function fxToInt(v: number) {
+        return v
+    }
+
+    function fxFloor(v: number) {
+        return v | 0;
+    }
+
+    interface ParsedShearArgs {
+        sx: number;
+        sy: number;
+        scaledWidth: number;
+        scaledHeight: number;
+        minX: number;
+        minY: number;
+        maxX: number;
+        maxY: number;
+        xShear: number;
+        yShear: number;
+        flip: boolean;
+    }
+
+    function parseShearArgs(src: RefImage, args: RefCollection, argIndex: number): ParsedShearArgs {
+        const parsed: ParsedShearArgs = {
+            sx: 0,
+            sy: 0,
+            scaledWidth: 0,
+            scaledHeight: 0,
+            minX: 0,
+            minY: 0,
+            maxX: 0,
+            maxY: 0,
+            xShear: 0,
+            yShear: 0,
+            flip: false
+        };
+
+        const sx = ((args.getAt(argIndex) as number) * FX_ONE);
+        const sy = ((args.getAt(argIndex + 1) as number) * FX_ONE);
+        let angle = args.getAt(argIndex + 2) as number;
+
+        parsed.sx = sx;
+        parsed.sy = sy;
+
+        if (sx <= 0 || sy <= 0) {
+            return parsed;
+        }
+
+        angle %= TWO_PI;
+        if (angle < 0) {
+            angle += Math.PI;
+        }
+
+        let flip = false;
+        if (angle > HALF_PI && angle <= THREE_HALF_PI) {
+            flip = true
+            angle = (angle + Math.PI) % TWO_PI
+        }
+
+        const xShear = (-Math.tan(angle / 2) * FX_ONE);
+        const yShear = (Math.sin(angle) * FX_ONE);
+
+        const scaledWidth = src._width * sx;
+        const scaledHeight = src._height * sy;
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, xShear));
+        }
+
+        SHEAR(0, 0);
+        let minX = shearedX;
+        let minY = shearedY;
+        let maxX = shearedX;
+        let maxY = shearedY;
+
+        SHEAR(scaledWidth - FX_ONE, 0);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(scaledWidth - FX_ONE, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        SHEAR(0, scaledHeight - FX_ONE);
+        minX = Math.min(minX, shearedX);
+        minY = Math.min(minY, shearedY);
+        maxX = Math.max(maxX, shearedX);
+        maxY = Math.max(maxY, shearedY);
+
+        parsed.minX = minX;
+        parsed.minY = minY;
+        parsed.maxX = maxX;
+        parsed.maxY = maxY;
+        parsed.scaledWidth = scaledWidth;
+        parsed.scaledHeight = scaledHeight;
+        parsed.xShear = xShear;
+        parsed.yShear = yShear;
+        parsed.flip = flip;
+
+        return parsed;
+    }
+
+    export function _drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        drawScaledRotatedImage(dst, src, args);
+    }
+
+    export function drawScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection) {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+        dst.makeWritable();
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - 1), shearArgs.sx)),
+                        fxToInt(fxDiv((shearArgs.scaledHeight - y - 1), shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+        else {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv(x, shearArgs.sx)),
+                        fxToInt(fxDiv(y, shearArgs.sy))
+                    );
+
+                    if (!color) continue;
+
+                    SHEAR(x, y);
+                    setPixel(dst, xDst + fxToInt(shearedX - shearArgs.minX), yDst + fxToInt(shearedY - shearArgs.minY), color);
+                }
+            }
+        }
+    }
+
+    export function _checkOverlapsScaledRotatedImage(dst: RefImage, src: RefImage, args: RefCollection): boolean {
+        const xDst = args.getAt(0) as number;
+        const yDst = args.getAt(1) as number;
+        if (xDst >= dst._width || yDst >= dst._height) {
+            return false;
+        }
+
+        const shearArgs = parseShearArgs(src, args, 2);
+
+        if (
+            shearArgs.sx <= 0 ||
+            shearArgs.sy <= 0 ||
+            xDst + fxToInt(shearArgs.maxX - shearArgs.minX) < 0 ||
+            yDst + fxToInt(shearArgs.maxY - shearArgs.minY) < 0
+        ) {
+            return false;
+        }
+
+        let shearedX = 0;
+        let shearedY = 0;
+
+        const SHEAR = (x: number, y: number) => {
+            shearedX = fxFloor(x + fxMul(y, shearArgs.xShear));
+            shearedY = fxFloor(y + fxMul(shearedX, shearArgs.yShear));
+            shearedX = fxFloor(shearedX + fxMul(shearedY, shearArgs.xShear));
+        }
+
+
+        if (shearArgs.flip) {
+            for (let y = 0; y < shearArgs.scaledHeight; y += FX_ONE) {
+                for (let x = 0; x < shearArgs.scaledWidth; x += FX_ONE) {
+                    let color = getPixel(
+                        src,
+                        fxToInt(fxDiv((shearArgs.scaledWidth - x - 1), shearArgs.sx)),
path: libs/screen/sim/image.ts
user: login: riknoll
body: oh yeah, thought i fixed all of these when i fixed that in the C++...
created_at: 2025-03-28T17:45:22Z
author_association: MEMBER

PR comments:
user: login: eanders-ms
created_at: 2025-03-27T16:24:24Z
body: I'm concerned that if we don't support per-pixel collision on rotated sprites, it will feel broken. Maybe we could expose an engine setting, allowing users to set bounding-box or per-pixel mode, but the behavior should be consistent across all sprite orientations.
author_association: CONTRIBUTOR
user: login: riknoll
created_at: 2025-03-27T20:02:14Z
body: @eanders-ms fair enough, i added pixel perfect overlaps back in.

there are two overlaps implementations: one checks a rotated image against an unrotated image, the other checks two rotated images against each other. i figure the first scenario is going to be much more common than the second so it makes sense to split the two.
author_association: MEMBER
user: login: riknoll
created_at: 2025-03-28T17:55:10Z
body: @eanders-ms not sure if there is much to gain by switching to fixed point in the browser since you can't accurately replicate the division/multiplication of the hardware because of overflow issues... i guess i could try using BigInt but i'll need to update the tsconfig lib our simulator targets
author_association: MEMBER
user: login: eanders-ms
created_at: 2025-03-28T18:29:11Z
body: Ya, it may not be worth it. Calling fxMul/fxDiv on the TS side in order to keep the two implementations as close as possible is the more valuable aspect. If you did want to use fixed in both, casting to `int64_t` on the c++ side helps a lot:

```cpp
inline int fxMul(int a, int b) {
    return (int)(((int64_t)a * b) >> FX_SHIFT);
}

inline int fxDiv(int a, int b) {
    return (int)(((int64_t)a << FX_SHIFT) / b);
}
```

Then on the TS side, for precision, `| 0` the results to mimic c++ int32 wrapping.
author_association: CONTRIBUTOR
user: login: riknoll
created_at: 2025-03-28T19:50:05Z
body: yeah i copied your C++ fx functions so that cast is already there! (thanks for those by the way :smile:)
author_association: MEMBER
user: login: riknoll
created_at: 2025-03-28T22:12:48Z
body: here's the upload target! https://arcade.makecode.com/app/ceb234fed84bd2ecec33607dd7ec392d039dbfe8-5a91efdc18
author_association: MEMBER