javascripthtml5-canvas

setTransform visually incorrect for a reflected image (translated/rotated/scaled), but matrixTransform applied to the original coordinates works


My vanilla JS program has two canvases. The user draws arbitrary straight lines on an image on the first canvas, and pieces taken from splitting the canvas by the line are drawn on the second canvas, some of them reflected around the line (using reflectPiece). I draw the reflected points of the piece directly on the second canvas, then apply a reflection matrix transform (derived with getReflectionMatrix) so that I can rotate/scale the image appropriately, then draw the image clipped to the outline.

This works perfectly for most combinations that I have tried, but I have found a line that it will not work for and cannot work out why. It is as if the reflection is not applying (or doubling) or something, so the image is rotated to the wrong angle and its angle doesn't align with the unmodified outline of the reflected points.

  1. In my example code, the unmodified outline after reflection is drawn in black and blue using two approaches - point for point from reflectPiece, and using DOMPoint.matrixTransform to transform the original points with the reflection matrix in order to show the matrix doesn't work the same both times - and filled in with both yellow and blue (green). These are the intended points.
  2. Then I draw the reflected points with the reflection matrix applied as a transform in red, to show the result of doubling the reflection.
  3. Then I draw the image with the transform applied like I do in my main code, and the angle aligns with the double-rotated red piece, when it should align with the one that's coloured in green.

I'm sure it's something really basic and I feel silly asking, but I've been stuck for days somehow :( Geometry is not my forte.

Broken:

Screenshot of broken shape

Working:

Screenshot of working shape

Here is my debug code:

<canvas id="canvas" width="800" height="800"></canvas>
<img src="debug.png" id="image" style="display: none">
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const img = document.getElementById("image");

const intersects = [new DOMPoint(143.07549812764975, 58.35350167526056),
new DOMPoint(198.3825597334491, 8.86823602796649)];
const points = [
    new DOMPoint(198.38255973344914, 8.868236027966528),
    new DOMPoint(162.65825355141538, 359.09787638799855),
    new DOMPoint(112.9163540869709, 354.0240772523315),
    new DOMPoint(143.0754981276499, 58.3535016752607),
];
const line = { startX: 250.4616879421679, startY: -37.7288786850976, endX: 103.53831205783209, endY: 93.7288786850976 };

const reflected = reflectPiece(points, line);
const transform = getReflectionMatrix(line);
const mapped = mapTransform(points, transform);

const canvasTransform = new DOMMatrix().translateSelf(canvas.width/2, canvas.height/2);
const drawTransform = canvasTransform.multiply(transform);
ctx.setTransform(canvasTransform);

img.onload = () => {
    debug();
}

function debug() {
    drawMatrixTransformedPoints();
    drawReflectedWithTransform();
    drawReflectedPoints();
    drawImage();
}

function drawMatrixTransformedPoints() {
    ctx.strokeStyle = "black";
    tracePiecePath(mapped);
    ctx.stroke();
    ctx.fillStyle = "yellow";
    ctx.fill();
}

function drawReflectedPoints() {
    ctx.strokeStyle = "blue";
    tracePiecePath(reflected);
    ctx.stroke();
    ctx.globalAlpha = 0.25;
    ctx.fillStyle = "cyan";
    ctx.fill();
    ctx.globalAlpha = 1;
}

function drawReflectedWithTransform() {
    ctx.save();
    ctx.strokeStyle = "red";
    ctx.setTransform(drawTransform);
    tracePiecePath(reflected);
    ctx.stroke();
    ctx.restore();
}

function drawImage() {
    ctx.globalAlpha = 0.5;
    ctx.save();
    ctx.setTransform(drawTransform);
    ctx.drawImage(img, 0, 0);
    ctx.restore();
    ctx.globalAlpha = 1;
}

function tracePiecePath(points) {
    ctx.beginPath();
    const firstPoint = points[0];
    ctx.moveTo(firstPoint.x, firstPoint.y);
    points.slice(1).forEach(point => {
        ctx.lineTo(point.x, point.y);
    });
    ctx.closePath();
}

function getReflectionMatrix(line) {
    const matrix = new DOMMatrix();
    const origin = getMidlinePoint(...intersects);
    const angle = getAngleFromOrigin(line);
    const angleInDegrees = angle * 180 / Math.PI;
    matrix.translateSelf(origin.x, origin.y);
    matrix.rotateSelf(angleInDegrees);
    matrix.scaleSelf(1, -1);    
    matrix.rotateSelf(-angleInDegrees);
    matrix.translateSelf(-origin.x, -origin.y);
    return matrix;
}

function getMidlinePoint(pt1, pt2) {
    const x = (pt1.x + pt2.x)/2;
    const y = (pt1.y + pt2.y)/2;
    return new DOMPoint(x, y);
}

function getAngleFromOrigin(line) {
    const { startX, endX, startY, endY } = line;
    const dx = endX - startX;
    const dy = endY - startY;
    return Math.atan2(dy, dx);
}

function reflectPiece(points, line) {
    const normal = this.getNormalVector(line);
    const newPoints = [];
    for (let i = 0; i < points.length; i++) {
        newPoints.push(this.reflectPoint(line, points[i], normal));
    };
    return newPoints;
}

function getNormalVector(line) {
    const { startX, endX, startY, endY } = line;
    const dx = endX - startX;
    const dy = endY - startY;
    const len = Math.hypot(dx, dy);

    const nx = -dy / len;
    const ny = dx / len;
    return { nx, ny };
}
function reflectPoint(line, point, normal) {
    const { x, y } = point;
    const { startX, startY } = line;
    const { nx, ny } = normal;

    const vx = x - startX;
    const vy = y - startY;
    const dot = vx * nx + vy * ny;
    const rx = startX + vx - 2 * dot * nx;
    const ry = startY + vy - 2 * dot * ny;
    return new DOMPoint(rx, ry);
}

function mapTransform(points, m) {
    return points.map(point => point.matrixTransform(m));
}
</script>

Here is an example of a working shape (aligns with the green shape, not the red one):

const intersects = [new DOMPoint(256.8378378378378, 50),
new DOMPoint(258.18918918918916)];
const points = [new DOMPoint(369.29268292682923, 50),
new DOMPoint(256.83783783783787, 50),
new DOMPoint(258.1891891891892, 0),
new DOMPoint(316.8536585365853, 0)];
const line = { startX: 267.0952908723996, startY: -329.5257622787839, endX: 247.90470912760043, endY: 380.5257622787839 };

debug.png: debug.png

Thanks!

edit: Thinking about it, it makes sense that I get this result, because the points are already created by the line, so rotating and scaling to match the line reflects them visually, but the image was never aligned with the line, so it just rotates to match the line (I don't know how to articulate this very well). I tried rotating to match the angle between the origin and the bottom edge of the reflected piece, but that broke on pieces that stood at about 90 degrees etc.


Solution

  • Okay, I solved it by taking the longest edge and rotating the matrix to the angle of that edge after reflecting:

    function getReflectionMatrix() {
        const matrix = new DOMMatrix();
        const origin = getMidlinePoint(...intersects);
        const angle = getAngleFromOrigin(line) * 180 / Math.PI;
        const longestEdgeAngle = getLongestEdgeAngle(points) * 180 / Math.PI;
        matrix.translateSelf(origin.x, origin.y);
        matrix.rotateSelf(angle);
        matrix.scaleSelf(1, -1);    
        matrix.rotateSelf(-angle);
        matrix.rotateSelf(longestEdgeAngle);
        matrix.translateSelf(-origin.x, -origin.y);
        return matrix;
    }
    
    function getBoundingBox(points) {
        const xs = points.map(p => p.x);
        const ys = points.map(p => p.y);
        const minX = Math.min(...xs);
        const minY = Math.min(...ys);
        return { minX, minY };
    }
    
    function getLongestEdgeAngle(points) {
        let maxLength = 0;
        let bestAngle = 0;
    
        for (let i = 0; i < points.length; i++) {
            const pt1 = points[i];
            const pt2 = points[(i + 1) % points.length];
    
            const dx = pt2.x - pt1.x;
            const dy = pt2.y - pt1.y;
            const length = Math.hypot(dx, dy);
    
            if (length > maxLength) {
                maxLength = length;
                bestAngle = Math.atan2(dy, dx);
            }
        }
      
        return bestAngle;
    }
    
    function drawImage() {
        ctx.globalAlpha = 0.5;
        ctx.save();
        ctx.setTransform(drawTransform);
        const { minX, minY } = getBoundingBox(reflected);
        ctx.drawImage(img, minX, minY);
        ctx.restore();
        ctx.globalAlpha = 1;
    }