Computer Graphics (rasterisation, ray tracing, WebGPU) Module 4 – 2D transformations, viewing and clipping
2D transformations and composite transforms
Translate, scale, rotate, reflect and shear with 3 × 3 matrices, turn about any pivot, split a matrix into its parts and build an eight-fold pattern.
What you will learn
- Apply translation, scaling, rotation, reflection and shear as 3 × 3 homogeneous matrices
- Compose transformations about an arbitrary pivot and explain why their order matters
- Decompose an affine matrix into translation, rotation, shear and scale
- Build rotationally symmetric patterns from composed rotations and reflections
Before you start
What you’ll learn · Before you start
What you will learn
- Apply translation, scaling, rotation, reflection and shear as 3 × 3 homogeneous matrices
- Compose transformations about an arbitrary pivot and explain why their order matters
- Decompose an affine matrix into translation, rotation, shear and scale
- Build rotationally symmetric patterns from composed rotations and reflections
Before you start
On this page
Turn the lesson’s arrow a quarter turn with the plain rotation matrix, and the middle of its tail, which sat at (1, 2), lands at (−2, 1): the arrow swings round a corner of the drawing instead of turning where it is. Every move a drawing program offers, from dragging and stretching to mirroring and slanting, is a 3 × 3 matrix, and many transform bugs come down to two questions: which point stays fixed, and in which order the matrices act.
You will apply the five basic matrices to that arrow and measure what each does to its area, turn and mirror it about any point and any line, find which pairs of matrices can swap places, read the six numbers of a canvas transform, split any matrix back into its parts, and grow an eight-fold pattern from one petal. Every number comes from a program that runs on this page.
Five matrices move every point of a shape
The arrow of this lesson has seven corners, from (1, 1) round to its tip at (6, 2), and covers an area of 10. A transformation moves every point of it, and in homogeneous coordinates: Coordinates with an extra component w. Points have w = 1, so a 4 × 4 matrix can translate them; directions have w = 0, so translation leaves them alone. More in “Matrices and homogeneous coordinates” each of the five basic ones is a 3 × 3 matrix: a point (x, y) is the column (x, y, 1), and its image is the matrix times that column. The bottom row of every matrix here is (0, 0, 1), so the 1 stays a 1.
The translation adds its offsets to x and y, which the 1 in the column makes possible, and the scale multiplies x and y by its two factors. T(−3, 1) moves the tip (6, 2) to (3, 3); S(2, 1.5) sends it to (12, 3).
The rotation turns every point by θ about the origin, counter-clockwise when y points up: its first column, (cos θ, sin θ), is where the x axis goes. R(90°) sends the tip (6, 2) to (−2, 6).
The reflection: A transformation that mirrors every point in a line and keeps the points of the line itself fixed. Its matrix has determinant −1, and applying it twice gives the identity. F mirrors in the x axis by negating y, so the tip goes to (6, −2). The shear: A transformation that slides each point parallel to one axis by an amount proportional to its other coordinate, as in x′ = x + k·y. It keeps areas and turns rectangles into parallelograms. slides each point along x by k times its height: with k = 0.5 the tip, at height 2, moves 1 to the right, to (7, 2). The figure shows the whole arrow under each of the five:
The lesson's arrow under the five basic matrices
Text description of the diagram
Six small panels in two columns, each with the x and y axes drawn faintly and the origin (0, 0) marked with a dot.
The first panel shows the arrow itself: seven corners from (1, 1) to the tip (6, 2), pointing right, to the right of the y axis, with one corner, (4, 0), on the x axis.
The other five panels each show the arrow dashed and its image solid, with the image's tip marked. Translate by (−3, 1): the same arrow, moved left by 3 and up by 1, so that it now crosses the y axis. Scale by (2, 1.5): the arrow twice as long and one and a half times as tall, reaching x = 12, moved away from the origin because every coordinate was multiplied. Rotate by 90°: the arrow turned a quarter turn about the origin, now pointing up and lying to the left of the y axis. Reflect in the x axis: the arrow flipped below the x axis, still pointing right. Shear x by 0.5 y: the arrow slanted to the right, each point moved along x by half its height: the top corner (4, 4) moved 2 to the right, and the corner on the x axis, (4, 0), stayed put.
The legend under the panels names the dashed outline as the arrow, the solid one as its image with the tip marked, and the dot as the origin.
Four of them keep the origin where it is, which makes them linear transformation: A map that keeps the origin in place and preserves sums and scaling. Rotations, scalings and shears are linear; translations are not. More in “Matrices and homogeneous coordinates”; the translation moves it, so any product that includes one is an affine transformation: A linear transformation followed by a translation. It keeps straight lines straight and parallel lines parallel. More in “Matrices and homogeneous coordinates”. The program applies all five and measures the arrow’s signed area with the shoelace formula: The area of a polygon from its corners: half the sum of x_i y_(i+1) − x_(i+1) y_i round the outline. It is positive when the corners run counter-clockwise. More in “Polygon clipping: Sutherland-Hodgman and beyond”, half the sum of taken round the outline:
// The five basic 2D transformations as 3 x 3 matrices in homogeneous coordinates.
// A point (x, y) is the column (x, y, 1), and p' = M p. Each matrix is applied to
// the lesson's arrow, and the shoelace formula measures the arrow's signed area.
const ARROW = [[1, 1], [4, 1], [4, 0], [6, 2], [4, 4], [4, 3], [1, 3]];
const translate = (tx, ty) => [[1, 0, tx], [0, 1, ty], [0, 0, 1]];
const scale = (sx, sy) => [[sx, 0, 0], [0, sy, 0], [0, 0, 1]];
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
};
const reflectInXAxis = () => [[1, 0, 0], [0, -1, 0], [0, 0, 1]];
const shearX = (k) => [[1, k, 0], [0, 1, 0], [0, 0, 1]]; // x moves by k times y
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const det = (m) => m[0][0] * m[1][1] - m[0][1] * m[1][0]; // of the 2 x 2 part
// Twice the signed area is the sum of x_i * y_(i+1) - x_(i+1) * y_i round the outline.
function signedArea(poly) {
let twice = 0;
for (let i = 0; i < poly.length; i++) {
const [x0, y0] = poly[i];
const [x1, y1] = poly[(i + 1) % poly.length];
twice += x0 * y1 - x1 * y0;
}
return twice / 2;
}
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const point = ([x, y]) => `(${num(x)}, ${num(y)})`;
const row = (r) => `[${r.map(num).join(', ')}]`;
const cases = [
['translate by (-3, 1)', translate(-3, 1)],
['scale by (2, 1.5)', scale(2, 1.5)],
['rotate by 90 degrees', rotate(90)],
['reflect in the x axis', reflectInXAxis()],
['shear x by 0.5 y', shearX(0.5)],
];
console.log(`The arrow: ${ARROW.map(point).join(' ')}`);
console.log(`Signed area ${num(signedArea(ARROW))} (positive: its corners run counter-clockwise)`);
for (const [name, m] of cases) {
const image = ARROW.map((p) => apply(m, p));
console.log('');
console.log(`${name}: rows ${m.map(row).join(' ')}`);
console.log(` image: ${image.map(point).join(' ')}`);
console.log(` determinant ${num(det(m))}, signed area ${num(signedArea(image))}`);
} Output
The arrow: (1, 1) (4, 1) (4, 0) (6, 2) (4, 4) (4, 3) (1, 3) Signed area 10 (positive: its corners run counter-clockwise) translate by (-3, 1): rows [1, 0, -3] [0, 1, 1] [0, 0, 1] image: (-2, 2) (1, 2) (1, 1) (3, 3) (1, 5) (1, 4) (-2, 4) determinant 1, signed area 10 scale by (2, 1.5): rows [2, 0, 0] [0, 1.5, 0] [0, 0, 1] image: (2, 1.5) (8, 1.5) (8, 0) (12, 3) (8, 6) (8, 4.5) (2, 4.5) determinant 3, signed area 30 rotate by 90 degrees: rows [0, -1, 0] [1, 0, 0] [0, 0, 1] image: (-1, 1) (-1, 4) (0, 4) (-2, 6) (-4, 4) (-3, 4) (-3, 1) determinant 1, signed area 10 reflect in the x axis: rows [1, 0, 0] [0, -1, 0] [0, 0, 1] image: (1, -1) (4, -1) (4, 0) (6, -2) (4, -4) (4, -3) (1, -3) determinant -1, signed area -10 shear x by 0.5 y: rows [1, 0.5, 0] [0, 1, 0] [0, 0, 1] image: (1.5, 1) (4.5, 1) (4, 0) (7, 2) (6, 4) (5.5, 3) (2.5, 3) determinant 1, signed area 10
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node transform2d.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The determinant: A number computed from a square matrix: the factor by which it scales areas or volumes. It is negative when the matrix mirrors and 0 when the matrix has no inverse. More in “Matrices and homogeneous coordinates” of each matrix’s 2 × 2 part predicts the new area exactly: 1 for the translation, the rotation and the shear, which all keep the area at 10, and 3 for the scale by (2, 1.5), which makes it 30. The mirror has determinant −1, and the signed area comes out as −10: the corners now run clockwise, the mark of a mirrored shape. The shear is the one to watch. It keeps the area and still changes every angle, turning the arrow’s square corners into slanted ones.
Degrees passed to Math.cos
The ECMAScript standard defines Math.cos and Math.sin with the argument in radians. A rotate(90) that skips the
conversion turns by 90 radians, about 5,157 degrees, which leaves the shape turned by about 117 degrees. Convert
once, with degrees * Math.PI / 180, where the angle enters the program.
Checkpoint
Check: The five basic matrices
Turn, scale or mirror about any point with the same sandwich
R(90°) on its own turned the arrow about the origin, so the middle of its tail, the pivot: The point that a rotation or a scale keeps fixed. Transforming about a pivot p means moving p to the origin, transforming there, and moving it back: T(p) M T(−p). (1, 2), ended at (−2, 1) and the arrow left its place. To turn about a pivot p instead, move p to the origin, turn, and move it back:
Read it from right to left, in the order a point meets the matrices: T(−p) subtracts the pivot, M turns about the origin, which is now where the pivot is, and T(p) adds the pivot back. For the tip (6, 2) and p = (1, 2) the three steps give (5, 0), (0, 5) and (1, 7). Step through both ways of turning the arrow:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Two ways to turn the arrow. Both columns follow the arrow's tip, (6, 2), through a quarter turn. On the left the rotation R(90°) acts alone; on the right it sits between two translations that take the pivot (1, 2) to the origin and back.
- R alone turns about (0, 0). R(90°) sends (x, y) to (−y, x), so the tip lands at (−2, 6). A rotation matrix always turns about the origin, wherever the shape happens to be.
- The pivot moves too. The same matrix sends the pivot (1, 2) to (−2, 1). The tail has left its place, so the arrow did not turn where it was.
- Swung round the origin. Every point of the arrow travelled a quarter circle about (0, 0). This is the bug of a rotate tool that forgets the pivot.
- Pivot to the origin first. On the right, T(−1, −2) subtracts the pivot. The tip becomes (5, 0), its offset from the pivot, and the pivot itself is now at the origin.
- Turn about the moved pivot. R(90°) turns (5, 0) into (0, 5). Turning about the origin is now turning about the pivot, because the pivot is at the origin.
- Move back. T(1, 2) adds the pivot back. The tip ends at (1, 7), five units straight above the pivot, and the pivot (1, 2) is exactly where it started.
- One matrix for the sandwich. Multiplied out, T(1, 2) R(90°) T(−1, −2) is the single matrix with rows [0, −1, 3] and [1, 0, 1]. The left column's arrow orbits the origin; the right column's turns in place.
The same sandwich scales about a point and mirrors in any line, because whatever must stay fixed goes to the origin first and comes back last. A mirror in the line y = 2x + 1 needs one more pair of matrices: move the line’s point (0, 1) to the origin, turn the line onto the x axis with R(−θ), mirror with F, and undo both, where θ = 63.435° is the line’s angle. The program builds all three composite transformation: One matrix made by multiplying several transformation matrices, so that a whole chain of moves is applied at once. With column vectors the rightmost factor acts on a point first. and checks the mirror against its exact matrix:
// Transforming about a point other than the origin: move the point to the origin,
// transform, and move it back. With column vectors the product is written
// T(p) M T(-p), and the rightmost matrix acts first.
const ARROW = [[1, 1], [4, 1], [4, 0], [6, 2], [4, 4], [4, 3], [1, 3]];
const translate = (tx, ty) => [[1, 0, tx], [0, 1, ty], [0, 0, 1]];
const scale = (sx, sy) => [[sx, 0, 0], [0, sy, 0], [0, 0, 1]];
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const rotateRadians = (r) => [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
const reflectInXAxis = () => [[1, 0, 0], [0, -1, 0], [0, 0, 1]];
const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
const compose = (...ms) => ms.reduce(multiply); // compose(A, B, C) = A B C: C acts first
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const maxDiff = (a, b) => Math.max(...a.flatMap((r, i) => r.map((v, j) => Math.abs(v - b[i][j]))));
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const point = ([x, y]) => `(${num(x)}, ${num(y)})`;
const rows = (m) => m.map((r) => `[${r.map(num).join(', ')}]`).join(' ');
const pivot = [1, 2]; // the middle of the arrow's tail
const aboutOrigin = rotate(90);
const aboutPivot = compose(translate(...pivot), rotate(90), translate(-pivot[0], -pivot[1]));
console.log('Rotate the arrow by 90 degrees');
console.log(` about the origin: ${ARROW.map((p) => point(apply(aboutOrigin, p))).join(' ')}`);
console.log(` about (1, 2): ${ARROW.map((p) => point(apply(aboutPivot, p))).join(' ')}`);
console.log(` T(1, 2) R(90) T(-1, -2) = ${rows(aboutPivot)}`);
console.log(` the pivot (1, 2) goes to ${point(apply(aboutPivot, pivot))}; the tip (6, 2) goes to ${point(apply(aboutPivot, [6, 2]))}`);
const tip = [6, 2];
const grow = compose(translate(...tip), scale(2, 2), translate(-tip[0], -tip[1]));
console.log('');
console.log('Scale the arrow by 2 about its tip (6, 2)');
console.log(` image: ${ARROW.map((p) => point(apply(grow, p))).join(' ')}`);
console.log(` T(6, 2) S(2, 2) T(-6, -2) = ${rows(grow)}`);
// Mirror in the line y = 2x + 1: move a point of the line, (0, 1), to the origin,
// turn the line onto the x axis, mirror, and undo both.
const theta = Math.atan2(2, 1); // the line's direction (1, 2)
const mirror = compose(translate(0, 1), rotateRadians(theta), reflectInXAxis(), rotateRadians(-theta), translate(0, -1));
const exact = [[-0.6, 0.8, -0.8], [0.8, 0.6, 0.4], [0, 0, 1]];
console.log('');
console.log(`Reflect in the line y = 2x + 1 (the line turns ${num((theta * 180) / Math.PI)} degrees from the x axis)`);
console.log(` T(0, 1) R(t) F R(-t) T(0, -1) = ${rows(mirror)}`);
console.log(` equal to the exact matrix ${rows(exact)} within 1e-12: ${maxDiff(mirror, exact) < 1e-12 ? 'yes' : 'no'}`);
console.log(` points of the line stay put: (1, 3) -> ${point(apply(mirror, [1, 3]))}, (-1, -1) -> ${point(apply(mirror, [-1, -1]))}`);
console.log(` the origin goes to ${point(apply(mirror, [0, 0]))}; mirroring twice gives the identity: ${maxDiff(multiply(mirror, mirror), IDENTITY) < 1e-12 ? 'yes' : 'no'}`);
console.log(` image of the arrow: ${ARROW.map((p) => point(apply(mirror, p))).join(' ')}`); Output
Rotate the arrow by 90 degrees about the origin: (-1, 1) (-1, 4) (0, 4) (-2, 6) (-4, 4) (-3, 4) (-3, 1) about (1, 2): (2, 2) (2, 5) (3, 5) (1, 7) (-1, 5) (0, 5) (0, 2) T(1, 2) R(90) T(-1, -2) = [0, -1, 3] [1, 0, 1] [0, 0, 1] the pivot (1, 2) goes to (1, 2); the tip (6, 2) goes to (1, 7) Scale the arrow by 2 about its tip (6, 2) image: (-4, 0) (2, 0) (2, -2) (6, 2) (2, 6) (2, 4) (-4, 4) T(6, 2) S(2, 2) T(-6, -2) = [2, 0, -6] [0, 2, -2] [0, 0, 1] Reflect in the line y = 2x + 1 (the line turns 63.435 degrees from the x axis) T(0, 1) R(t) F R(-t) T(0, -1) = [-0.6, 0.8, -0.8] [0.8, 0.6, 0.4] [0, 0, 1] equal to the exact matrix [-0.6, 0.8, -0.8] [0.8, 0.6, 0.4] [0, 0, 1] within 1e-12: yes points of the line stay put: (1, 3) -> (1, 3), (-1, -1) -> (-1, -1) the origin goes to (-0.8, 0.4); mirroring twice gives the identity: yes image of the arrow: (-0.6, 1.8) (-2.4, 4.2) (-3.2, 3.6) (-2.8, 6.4) (0, 6) (-0.8, 5.4) (1, 3)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pivot.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The pivot (1, 2) stays put and the tip lands at (1, 7), from one matrix with rows [0, −1, 3] and [1, 0, 1]. Its last column, (3, 1), is the translation the sandwich adds so that the pivot does not move. The scale by 2 about the tip leaves (6, 2) fixed and doubles every distance from it. The mirror’s five matrices multiply out to rows [−0.6, 0.8, −0.8] and [0.8, 0.6, 0.4], which is exact: a mirror in a line through the origin at angle θ has the 2 × 2 part [cos 2θ, sin 2θ; sin 2θ, −cos 2θ], and with tan θ = 2 those are −0.6 and 0.8. The program’s two checks are the ones to keep for any mirror: points of the line, such as (1, 3), stay where they are, and mirroring twice gives the identity.
Key idea
To transform about a point, put it at the origin first and back last: T(p) M T(−p). A mirror in any line is the same sandwich with a turn on each side, T R(θ) F R(−θ) T⁻¹.
Checkpoint
Check: Transforming about a pivot or a line
Order matters: most pairs do not commute
Rotate the unit square by 30° and stretch it by (2, 1), and the result depends on which comes first. Scale first, R(30°) S(2, 1), and the square becomes a 2 × 1 rectangle turned by 30°. Rotate first, S(2, 1) R(30°), and the stretch pulls along the world’s x axis, which runs diagonally across the turned square, so its corners stop being right angles. The program measures that, and multiplies nine pairs of matrices both ways:
// Which pairs of transformations commute (A B = B A) and which do not? Each pair is
// multiplied both ways and the largest difference between the two products is printed.
// Then a unit square shows what the wrong order does to a shape.
const translate = (tx, ty) => [[1, 0, tx], [0, 1, ty], [0, 0, 1]];
const scale = (sx, sy) => [[sx, 0, 0], [0, sy, 0], [0, 0, 1]];
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const shearX = (k) => [[1, k, 0], [0, 1, 0], [0, 0, 1]];
const shearY = (k) => [[1, 0, 0], [k, 1, 0], [0, 0, 1]];
const reflectInXAxis = () => [[1, 0, 0], [0, -1, 0], [0, 0, 1]];
function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
const maxDiff = (a, b) => Math.max(...a.flatMap((r, i) => r.map((v, j) => Math.abs(v - b[i][j]))));
const num = (v, places = 3) => {
const r = Number(v.toFixed(places));
return Object.is(r, -0) ? '0' : String(r);
};
const pairs = [
['R(30) and R(50)', rotate(30), rotate(50)],
['T(3, 0) and T(0, 5)', translate(3, 0), translate(0, 5)],
['S(2, 1) and S(1, 3)', scale(2, 1), scale(1, 3)],
['R(30) and S(2, 2), a uniform scale', rotate(30), scale(2, 2)],
['R(30) and S(2, 1)', rotate(30), scale(2, 1)],
['R(30) and shear x by 0.5', rotate(30), shearX(0.5)],
['shear x by 0.5 and shear y by 0.5', shearX(0.5), shearY(0.5)],
['T(3, 0) and R(90)', translate(3, 0), rotate(90)],
['F (mirror in x) and R(30)', reflectInXAxis(), rotate(30)],
];
console.log('pair largest |AB - BA| commute?');
for (const [name, a, b] of pairs) {
const d = maxDiff(multiply(a, b), multiply(b, a));
console.log(`${name.padEnd(38)}${num(d).padEnd(19)}${d < 1e-12 ? 'yes' : 'no'}`);
}
// The unit square's sides are the x and y axes. After a matrix they are its first two columns.
function describe(name, m) {
const ex = [m[0][0], m[1][0]];
const ey = [m[0][1], m[1][1]];
const len = ([x, y]) => Math.hypot(x, y);
const angle = (Math.acos((ex[0] * ey[0] + ex[1] * ey[1]) / (len(ex) * len(ey))) * 180) / Math.PI;
console.log(`${name}: sides ${num(len(ex))} and ${num(len(ey))}, corner angle ${num(angle, 1)} degrees`);
}
console.log('');
console.log('The unit square, rotated by 30 degrees and scaled by (2, 1):');
describe(' R(30) S(2, 1), scale first ', multiply(rotate(30), scale(2, 1)));
describe(' S(2, 1) R(30), rotate first', multiply(scale(2, 1), rotate(30))); Output
pair largest |AB - BA| commute? R(30) and R(50) 0 yes T(3, 0) and T(0, 5) 0 yes S(2, 1) and S(1, 3) 0 yes R(30) and S(2, 2), a uniform scale 0 yes R(30) and S(2, 1) 0.5 no R(30) and shear x by 0.5 0.25 no shear x by 0.5 and shear y by 0.5 0.25 no T(3, 0) and R(90) 3 no F (mirror in x) and R(30) 1 no The unit square, rotated by 30 degrees and scaled by (2, 1): R(30) S(2, 1), scale first : sides 2 and 1, corner angle 90 degrees S(2, 1) R(30), rotate first: sides 1.803 and 1.323, corner angle 123 degrees
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node order.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Four pairs commute: two rotations, two translations, two scales, and a rotation with a uniform scale. Five do not, and the size of the difference shows how much: a rotation and a non-uniform scale differ by 0.5, a rotation and a shear by 0.25, the two shears by 0.25, a translation and a rotation by 3, the full length of the move, and the mirror and a rotation by 1. The square makes the cost visible. In the order S(2, 1) R(30°) its sides are 1.803 and 1.323 and its corner is 123 degrees: a parallelogram. A shear has appeared that neither matrix contained on its own, and the decomposition section below takes it apart.
| Pair | Commute? | Why |
|---|---|---|
| Two rotations, two translations, two scales | Yes | Angles add, offsets add, factors multiply, in any order |
| Rotation and uniform scale | Yes | A uniform scale stretches every direction alike |
| Rotation and non-uniform scale | No | The stretch acts along fixed axes, before or after the turn |
| Translation and rotation | No | A rotation turns the offset as well, unless the move comes after it |
| Mirror and rotation | No | F R(θ) = R(−θ) F: the mirror reverses the turn |
Test a composite with numbers
Build both orders and compare their products, or apply both to two or three points, before trusting an argument about which order is right. A test that multiplies a shear, a rotation and a non-uniform scale in every order catches what intuition misses.
Checkpoint
Check: Which transformations commute
On a canvas, y points down, so positive angles turn clockwise
The maths above, glTF files and most plots put y upwards; glTF’s specification defines +Y as up. A canvas does the
opposite: the HTML standard puts the origin of its coordinate space in the top left corner, x increasing rightwards and
y increasing down the screen, and it calls the argument of rotate() “a clockwise rotation angle expressed in
radians”. Nothing
in the matrix changes. The same numbers that turn a point counter-clockwise on paper turn it clockwise on the screen,
because the screen’s y axis is upside down:
One matrix, two directions of y
Text description of the diagram
Two panels side by side apply the same matrix, T(1, 2) R(90°) T(−1, −2), to the lesson's arrow, with the pivot (1, 2) marked by a dot.
In the left panel, titled "y up: maths, glTF", the y axis points up. The dashed arrow points right; the solid arrow, its image, points straight up from the pivot. A curved arrow from the old tip to the new one sweeps counter-clockwise.
In the right panel, titled "y down: canvas, SVG", the y axis points down, as in a canvas or an SVG drawing. The same numbers put the image's tip at (1, 7), which is now below the pivot, so the solid arrow points down the page and the curved arrow sweeps clockwise.
The text under the panels says that the tip (6, 2) goes to (1, 7) in both panels: only the direction of the y axis differs.
A canvas keeps its current matrix as six numbers. setTransform(a, b, c, d, e, f) sets it to the matrix with rows
[a, c, e] and [b, d, f], so the arguments run down the columns, and transform() multiplies the current matrix by a new
one on the right. The standard therefore says the transformations are performed in reverse order: the call made last
acts on the drawing first. The program follows those rules with plain arithmetic, not a browser canvas:
// A model of the current transformation matrix of a canvas 2D context, following the
// HTML standard's rules: setTransform(a, b, c, d, e, f) sets the matrix
// [a c e]
// [b d f]
// [0 0 1]
// and transform(a, b, c, d, e, f) multiplies the current matrix by that one on the
// right, so the call made last acts on the drawing coordinates first. This is plain
// arithmetic, not a browser canvas.
class MatrixState {
constructor() {
this.m = [1, 0, 0, 1, 0, 0]; // a, b, c, d, e, f: the identity
}
setTransform(a, b, c, d, e, f) {
this.m = [a, b, c, d, e, f];
}
transform(a2, b2, c2, d2, e2, f2) {
const [a, b, c, d, e, f] = this.m; // current x new, with the 3 x 3 layout above
this.m = [a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f];
}
translate(x, y) {
this.transform(1, 0, 0, 1, x, y);
}
rotate(angle) {
this.transform(Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0);
}
scale(x, y) {
this.transform(x, 0, 0, y, 0, 0);
}
apply([x, y]) {
const [a, b, c, d, e, f] = this.m;
return [a * x + c * y + e, b * x + d * y + f];
}
}
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const point = ([x, y]) => `(${num(x)}, ${num(y)})`;
// Turn about the pivot (1, 2): the calls read like T(1, 2) R T(-1, -2), left to right.
const ctx = new MatrixState();
ctx.translate(1, 2);
ctx.rotate(Math.PI / 2);
ctx.translate(-1, -2);
console.log(`translate(1, 2); rotate(pi / 2); translate(-1, -2) leaves a, b, c, d, e, f = ${ctx.m.map(num).join(', ')}`);
console.log(`the same six numbers as the matrix T(1, 2) R(90) T(-1, -2): rows [0, -1, 3] and [1, 0, 1]`);
const pivot = [1, 2];
const tip = [6, 2];
const turned = ctx.apply(tip);
console.log(`the tip ${point(tip)} goes to ${point(turned)}; the pivot stays at ${point(ctx.apply(pivot))}`);
console.log(`on a canvas y grows downwards, so ${point(turned)} is ${num(turned[1] - pivot[1])} pixels below the pivot:`);
console.log('the arrow that pointed right now points down the screen, a clockwise quarter turn');
// The six arguments go column by column. Passing the rows as they are printed puts
// numbers in the wrong places.
const byRows = new MatrixState();
byRows.setTransform(0, -1, 3, 1, 0, 1);
console.log(`setTransform(0, -1, 3, 1, 0, 1), the rows in reading order, sends the tip to ${point(byRows.apply(tip))}`);
const byColumns = new MatrixState();
byColumns.setTransform(0, 1, -1, 0, 3, 1);
console.log(`setTransform(0, 1, -1, 0, 3, 1), column by column, sends it to ${point(byColumns.apply(tip))}`);
// setTransform replaces the matrix; transform multiplies into it.
const a = new MatrixState();
a.setTransform(2, 0, 0, 2, 0, 0);
a.setTransform(1, 0, 0, 1, 10, 0);
const b = new MatrixState();
b.transform(2, 0, 0, 2, 0, 0);
b.transform(1, 0, 0, 1, 10, 0);
console.log('');
console.log(`setTransform twice: (1, 1) goes to ${point(a.apply([1, 1]))}, the second call replaced the scale`);
console.log(`transform twice: (1, 1) goes to ${point(b.apply([1, 1]))}, moved by 10, then scaled by 2`); Output
translate(1, 2); rotate(pi / 2); translate(-1, -2) leaves a, b, c, d, e, f = 0, 1, -1, 0, 3, 1 the same six numbers as the matrix T(1, 2) R(90) T(-1, -2): rows [0, -1, 3] and [1, 0, 1] the tip (6, 2) goes to (1, 7); the pivot stays at (1, 2) on a canvas y grows downwards, so (1, 7) is 5 pixels below the pivot: the arrow that pointed right now points down the screen, a clockwise quarter turn setTransform(0, -1, 3, 1, 0, 1), the rows in reading order, sends the tip to (6, -3) setTransform(0, 1, -1, 0, 3, 1), column by column, sends it to (1, 7) setTransform twice: (1, 1) goes to (11, 1), the second call replaced the scale transform twice: (1, 1) goes to (22, 2), moved by 10, then scaled by 2
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node canvas_matrix.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
translate(1, 2), rotate(Math.PI / 2) and translate(-1, -2) read left to right exactly like
T(1, 2) R(90°) T(−1, −2), and they leave the six numbers 0, 1, −1, 0, 3, 1: the pivot rotation of the last section.
On the screen the tip lands 5 pixels below the pivot, a clockwise quarter turn. The last two lines show the reverse
order at work: two transform() calls, a scale by 2 and then a move by 10, send (1, 1) to (22, 2), because the move
acts first.
Passing the rows to setTransform
The six arguments run down the columns: a and b are the first column, c and d the second, e and f the translation.
Typing the rows in reading order, setTransform(0, -1, 3, 1, 0, 1), sends the tip to (6, −3), above the top of the
canvas; setTransform(0, 1, -1, 0, 3, 1) sends it to (1, 7). The standard itself warns that the order of the second
and third arguments varies from one API to another.
Checkpoint
Check: Canvas matrices and y pointing down
Any affine matrix splits into translation, rotation, shear and scale
An editor that shows rotation and scale fields for a selected shape, or a program that reads a matrix back from a file or a canvas, has to run the multiplication backwards. With the convention below, the x shear placed between the rotation and the scale and the first scale kept positive, every matrix whose determinant is not 0 splits in exactly one way:
Each column gives up one part. The last column is the translation. The first column is R H S applied to (1, 0), which is , so its length is the first scale and its direction is the angle. Turning the second column back by θ removes R and leaves H S applied to (0, 1), which is : the second scale, and the shear once you divide. Step through it on S(2, 1) R(30°), the matrix that made the parallelogram:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The matrix. M = S(2, 1) R(30°) rotates the unit square and then stretches it along x. The order section measured its corner at 123 degrees, so it is not a rotated rectangle.
- Length and direction. R H S sends (1, 0) to sx (cos a, sin a), so the first column alone gives sx and the angle. Here it has length 1.803 and points 16.102 degrees above the x axis.
- Undo the rotation. Turning the second column back by 16.102 degrees removes R. What is left is H S applied to (0, 1), which is (shear × sy, sy).
- Shear and second scale. The second number of that column is sy = 1.109, and the first divided by it is the shear, −0.65. The determinant sx × sy is 2, the area factor of S(2, 1).
- The check. Multiplied back as T R H S, the four numbers, unrounded, give M within 1e-12. The shear was there all along, hidden in the order of R and S.
The program splits four matrices and multiplies each one back:
// Splitting an affine matrix into M = T(tx, ty) R(angle) H(shear) S(sx, sy), where H is
// the x shear [1 k; 0 1]. The convention keeps sx > 0, so a mirror shows as sy < 0.
// 1. The last column is the translation.
// 2. The first column is R H S applied to (1, 0), which is sx (cos a, sin a):
// its length is sx and its direction the angle.
// 3. Rotating the second column back by the angle leaves (shear * sy, sy).
const translate = (tx, ty) => [[1, 0, tx], [0, 1, ty], [0, 0, 1]];
const scale = (sx, sy) => [[sx, 0, 0], [0, sy, 0], [0, 0, 1]];
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const shearX = (k) => [[1, k, 0], [0, 1, 0], [0, 0, 1]];
function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
const compose = (...ms) => ms.reduce(multiply);
const maxDiff = (a, b) => Math.max(...a.flatMap((r, i) => r.map((v, j) => Math.abs(v - b[i][j]))));
function decompose(m) {
const det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
if (Math.abs(det) < 1e-12) throw new Error('the matrix flattens the plane (determinant 0): no decomposition');
const sx = Math.hypot(m[0][0], m[1][0]);
const angle = Math.atan2(m[1][0], m[0][0]);
const c = Math.cos(angle);
const s = Math.sin(angle);
const back = [c * m[0][1] + s * m[1][1], -s * m[0][1] + c * m[1][1]]; // R(-angle) times column 2
const sy = back[1];
return { tx: m[0][2], ty: m[1][2], angle: (angle * 180) / Math.PI, shear: back[0] / sy, sx, sy };
}
const recompose = (d) => compose(translate(d.tx, d.ty), rotate(d.angle), shearX(d.shear), scale(d.sx, d.sy));
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const cases = [
['T(4, -1) R(30) H(0.5) S(2, 1.5)', compose(translate(4, -1), rotate(30), shearX(0.5), scale(2, 1.5))],
['S(2, 1) R(30), rotated first', multiply(scale(2, 1), rotate(30))],
['the mirror in y = 2x + 1', [[-0.6, 0.8, -0.8], [0.8, 0.6, 0.4], [0, 0, 1]]],
['S(1, 0), which flattens y', scale(1, 0)],
];
for (const [name, m] of cases) {
console.log(name);
try {
const d = decompose(m);
console.log(` translation (${num(d.tx)}, ${num(d.ty)}), angle ${num(d.angle)} degrees, shear ${num(d.shear)}, scale (${num(d.sx)}, ${num(d.sy)})`);
console.log(` determinant sx * sy = ${num(d.sx * d.sy)}; recomposed within 1e-12: ${maxDiff(recompose(d), m) < 1e-12 ? 'yes' : 'no'}`);
} catch (e) {
console.log(` refused: ${e.message}`);
}
} Output
T(4, -1) R(30) H(0.5) S(2, 1.5) translation (4, -1), angle 30 degrees, shear 0.5, scale (2, 1.5) determinant sx * sy = 3; recomposed within 1e-12: yes S(2, 1) R(30), rotated first translation (0, 0), angle 16.102 degrees, shear -0.65, scale (1.803, 1.109) determinant sx * sy = 2; recomposed within 1e-12: yes the mirror in y = 2x + 1 translation (-0.8, 0.4), angle 126.87 degrees, shear 0, scale (1, -1) determinant sx * sy = -1; recomposed within 1e-12: yes S(1, 0), which flattens y refused: the matrix flattens the plane (determinant 0): no decomposition
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node decompose.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The first comes apart into exactly the numbers it was built from. The second shows the hidden shear: S(2, 1) R(30°) is a turn by 16.102°, a shear of −0.65 and a scale of (1.803, 1.109), and its determinant, 2, is still the area factor of S(2, 1). The mirror in y = 2x + 1 has no shear and a second scale of −1: it is the mirror in the x axis, then a turn by 126.87°, twice the line’s 63.435°, then a move by (−0.8, 0.4). The last matrix flattens the plane, and nothing undoes that, so the program refuses it.
The split is a convention, not a fact about the matrix. CSS Transforms Level 1 decomposes a 2D matrix for animation into two scales, an angle and a leftover 2 × 2 matrix instead of a shear (section 13.2.1), so two libraries can report different numbers for the same matrix and both be right. Compare decompositions by multiplying them back, never number by number.
Checkpoint
Check: Splitting a matrix into its parts
Patterns from composed rotations and mirrors
An eight-fold pattern in the style of a rangoli needs one petal and nothing else: every other petal is that petal under a composed matrix. Turning it by R(45k), for k from 0 to 7, gives eight copies that circle the centre like a pinwheel. The petal leans to one side, so the pinwheel has no mirror lines; adding the mirror F gives eight more copies, R(45k) F, and the pattern becomes symmetric about eight lines:
An eight-fold pattern from one petal and the 16 symmetries
Text description of the diagram
A round pattern of 16 narrow petals radiating from a central dot, drawn over eight faint dashed lines through the centre, one every 22.5 degrees.
One petal, just above the line pointing right, is drawn solid and shaded blue: it leans to one side, so it is not its own mirror image. Just below that line is its mirror image in the x axis, drawn with a dashed blue outline. The other 14 petals are grey. Together they are the images of the one petal under the 16 matrices R(45k) and R(45k) F, for k from 0 to 7: the 8 rotations by multiples of 45 degrees, and the same 8 rotations applied after the mirror F.
Each pair of neighbouring petals is mirrored across one of the dashed lines, which are the 8 mirror lines of the pattern, and turning the whole pattern by 45 degrees puts every petal on another one.
The legend names the solid petal, its dashed mirror, the dashed mirror lines and the 14 grey copies.
The 16 matrices are every symmetry: A transformation that maps a shape or a pattern onto itself, such as a turn by 45 degrees of an eight-fold pattern or a mirror in one of its lines. of the pattern, and the program checks the facts that make that true:
// An eight-fold pattern in the style of a rangoli, built from one petal by composed
// transformations. Rotations by 45 degrees give 8 copies that turn like a pinwheel;
// adding the mirror F in the x axis gives 8 more, and the 16 matrices R(45k) and
// R(45k) F are every symmetry of the pattern.
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const F = [[1, 0, 0], [0, -1, 0], [0, 0, 1]];
const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const det = (m) => m[0][0] * m[1][1] - m[0][1] * m[1][0];
const maxDiff = (a, b) => Math.max(...a.flatMap((r, i) => r.map((v, j) => Math.abs(v - b[i][j]))));
const near = (a, b) => maxDiff(a, b) < 1e-12;
function signedArea(poly) {
let twice = 0;
for (let i = 0; i < poly.length; i++) {
const [x0, y0] = poly[i];
const [x1, y1] = poly[(i + 1) % poly.length];
twice += x0 * y1 - x1 * y0;
}
return twice / 2;
}
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
// One petal, leaning to one side, inside the wedge from 3 to 20 degrees.
// Each corner is (radius, angle in degrees).
const POLAR = [[1.2, 8], [2.0, 4], [3.0, 3], [4.0, 6], [4.6, 12], [4.4, 19], [3.6, 20], [2.8, 17], [2.0, 13]];
const PETAL = POLAR.map(([r, a]) => [r * Math.cos((a * Math.PI) / 180), r * Math.sin((a * Math.PI) / 180)]);
const symmetries = [];
for (let k = 0; k < 8; k++) symmetries.push({ name: `R(${45 * k})`, m: rotate(45 * k) });
for (let k = 0; k < 8; k++) symmetries.push({ name: `R(${45 * k}) F`, m: multiply(rotate(45 * k), F) });
console.log('symmetry determinant what it is');
for (const { name, m } of symmetries) {
const d = det(m);
let what;
if (d > 0) what = near(m, IDENTITY) ? 'the identity' : `a turn by ${name.slice(2, -1)} degrees`;
else {
// A mirror leaves its line in place. Both vectors below lie along that line;
// take the longer one, because one of them is (0, 0) for the mirrors in the axes.
const v1 = [1 + m[0][0], m[1][0]];
const v2 = [m[0][1], 1 - m[0][0]];
const [vx, vy] = Math.hypot(...v1) >= Math.hypot(...v2) ? v1 : v2;
const angle = (Math.atan2(vy, vx) * 180) / Math.PI;
what = `a mirror in the line at ${num((angle + 180) % 180)} degrees`;
}
console.log(`${name.padEnd(13)}${num(d).padEnd(13)}${what}`);
}
const copies = symmetries.map(({ m }) => PETAL.map((p) => apply(m, p)));
let r45 = IDENTITY;
for (let i = 0; i < 8; i++) r45 = multiply(rotate(45), r45);
console.log('');
console.log(`R(45) applied 8 times is the identity: ${near(r45, IDENTITY) ? 'yes' : 'no'}`);
console.log(`every mirror applied twice is the identity: ${symmetries.slice(8).every(({ m }) => near(multiply(m, m), IDENTITY)) ? 'yes' : 'no'}`);
console.log(`R(45) F equals F R(-45): ${near(multiply(rotate(45), F), multiply(F, rotate(-45))) ? 'yes' : 'no'}`);
let closed = 0;
for (const a of symmetries) for (const b of symmetries) if (symmetries.some(({ m }) => near(m, multiply(a.m, b.m)))) closed++;
console.log(`products of two symmetries that are again one of the 16: ${closed} of ${symmetries.length ** 2}`);
console.log('');
console.log(`petal: ${PETAL.length} corners, signed area ${num(signedArea(PETAL))}`);
console.log(`pattern: ${copies.length} copies, ${copies.length * PETAL.length} corners`);
// Every copy's signed area, the turned ones first: "each" is printed only when all of a group agree.
const areas = copies.map(signedArea);
const group = (list) => (list.every((a) => Math.abs(a - list[0]) < 1e-12) ? `${num(list[0])} each` : list.map(num).join(', '));
console.log(`signed areas: the 8 turned copies ${group(areas.slice(0, 8))}, the 8 mirrored copies ${group(areas.slice(8))}`);
const svg = (poly) => `M${poly.map(([x, y]) => `${num(x)} ${num(-y)}`).join(' L')} Z`; // SVG's y points down
console.log(`SVG path of the petal: ${svg(PETAL)}`);
console.log(`SVG path of its mirror: ${svg(copies[8])}`); Output
symmetry determinant what it is R(0) 1 the identity R(45) 1 a turn by 45 degrees R(90) 1 a turn by 90 degrees R(135) 1 a turn by 135 degrees R(180) 1 a turn by 180 degrees R(225) 1 a turn by 225 degrees R(270) 1 a turn by 270 degrees R(315) 1 a turn by 315 degrees R(0) F -1 a mirror in the line at 0 degrees R(45) F -1 a mirror in the line at 22.5 degrees R(90) F -1 a mirror in the line at 45 degrees R(135) F -1 a mirror in the line at 67.5 degrees R(180) F -1 a mirror in the line at 90 degrees R(225) F -1 a mirror in the line at 112.5 degrees R(270) F -1 a mirror in the line at 135 degrees R(315) F -1 a mirror in the line at 157.5 degrees R(45) applied 8 times is the identity: yes every mirror applied twice is the identity: yes R(45) F equals F R(-45): yes products of two symmetries that are again one of the 16: 256 of 256 petal: 9 corners, signed area 1.947 pattern: 16 copies, 144 corners signed areas: the 8 turned copies 1.947 each, the 8 mirrored copies -1.947 each SVG path of the petal: M1.188 -0.167 L1.995 -0.14 L2.996 -0.157 L3.978 -0.418 L4.499 -0.956 L4.16 -1.432 L3.383 -1.231 L2.678 -0.819 L1.949 -0.45 Z SVG path of its mirror: M1.188 0.167 L1.995 0.14 L2.996 0.157 L3.978 0.418 L4.499 0.956 L4.16 1.432 L3.383 1.231 L2.678 0.819 L1.949 0.45 Z
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node rangoli.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The eight with determinant 1 are turns. The eight with determinant −1 are mirrors, and each R(45k) F is a mirror in the line at 22.5k degrees, half the angle of its turn, like the 126.87° of the mirror above. R(45°) applied eight times is the identity, every mirror applied twice is the identity, and all 256 products of two symmetries are again one of the 16, which is why turning or mirroring the finished pattern lands every petal on another. The signed areas tell the turned copies, 1.947 each, from the mirrored ones, −1.947, the same change of sign as the mirrored arrow. To write the pattern as SVG, whose y axis also points down, the program negates y as it writes each path.
UGC NET and university syllabi
For UGC NET, the Computer Science and Applications paper’s Unit 3 covers “Translation, Scaling, Rotation, Reflection and Shear Transformations; Matrix Representations and Homogeneous Coordinates; Composite Transforms”. GTU’s BE05000161 course, Computer Graphics and Visualization, covers the same transformations with composite transformations and homogeneous coordinates, and its practicals include basic 2D transformation operations. An exam-style question: find the matrix of a quarter turn about (1, 2). The sandwich gives rows [0, −1, 3] and [1, 0, 1].
Checkpoint
Check: Patterns from rotations and mirrors
Practise: compose and decompose
Two exercises build the matrix tools of this lesson, with sample tests that run in the page. The first composes a list of operations in order, about pivots and mirror lines, and is held to the lesson’s matrices and to two copies of the petal pattern; the second splits any matrix into T R H S and puts 500 seeded matrices back together:
Code lab · 2 steps
Build a 2D transform toolkit
Compose operations in the right order about any pivot or mirror line, then split any affine matrix back into translation, rotation, shear and scale.
- Compose transformations in order, about any point To do
- Split a matrix into translation, rotation, shear and scale To do
Exercise · Medium · JavaScript
Compose transformations in order, about any point
Finish composite(ops) in compose.mjs. It returns the 3 × 3 matrix, as three rows, that applies a list of operations to a point in the order they are listed: the first operation acts first.
{ op: 'rotate', degrees, about }and{ op: 'scale', x, y, about }turn or scale about the pointaboutwhen it is given, and about the origin when it is not.{ op: 'reflect', through: [[x1, y1], [x2, y2]] }mirrors in the line through the two points.translateandshear(x moves by k times y) already work on their own.
The starter multiplies the matrices in the wrong order, ignores about and cannot reflect. The sample tests check single operations, the order of two, the lesson's pivot cases, the mirror in y = 2x + 1, and two copies of the petal pattern, each entry within 1e-12.
Starter code · compose.mjs
/**
* composite(ops) returns the 3 x 3 matrix that applies the operations in the order listed.
* An operation is one of
* { op: 'translate', x, y }
* { op: 'rotate', degrees, about } about: an optional pivot [px, py]
* { op: 'scale', x, y, about } about: an optional fixed point [px, py]
* { op: 'shear', k } x moves by k times y
* { op: 'reflect', through } through: two points [[x1, y1], [x2, y2]] of the mirror line
* This first version multiplies in the wrong order, ignores `about` and cannot reflect.
*/
const translate = (x, y) => [[1, 0, x], [0, 1, y], [0, 0, 1]];
const rotate = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const scale = (x, y) => [[x, 0, 0], [0, y, 0], [0, 0, 1]];
const shear = (k) => [[1, k, 0], [0, 1, 0], [0, 0, 1]];
export function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
function matrixOf(op) {
switch (op.op) {
case 'translate':
return translate(op.x, op.y);
case 'rotate':
return rotate(op.degrees);
case 'scale':
return scale(op.x, op.y);
case 'shear':
return shear(op.k);
default:
throw new Error(`cannot do ${op.op} yet`);
}
}
export function composite(ops) {
let m = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
for (const op of ops) m = multiply(m, matrixOf(op));
return m;
} The sample tests · compose.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { composite } from './compose.mjs';
const near = (m, expected, message) => {
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
assert.ok(Math.abs(m[i][j] - expected[i][j]) < 1e-12, `${message}: row ${i + 1}, column ${j + 1} is ${m[i][j]}, expected ${expected[i][j]}`);
}
}
};
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const det = (m) => m[0][0] * m[1][1] - m[0][1] * m[1][0];
const round = (v) => {
const r = Math.round(v * 1000) / 1000;
return Object.is(r, -0) ? 0 : r;
};
const c30 = Math.cos(Math.PI / 6);
test('one operation each, as in the lesson', () => {
near(composite([{ op: 'translate', x: -3, y: 1 }]), [[1, 0, -3], [0, 1, 1], [0, 0, 1]], 'translate by (-3, 1)');
near(composite([{ op: 'rotate', degrees: 90 }]), [[0, -1, 0], [1, 0, 0], [0, 0, 1]], 'rotate by 90 degrees');
near(composite([{ op: 'scale', x: 2, y: 1.5 }]), [[2, 0, 0], [0, 1.5, 0], [0, 0, 1]], 'scale by (2, 1.5)');
near(composite([{ op: 'shear', k: 0.5 }]), [[1, 0.5, 0], [0, 1, 0], [0, 0, 1]], 'shear x by 0.5 y');
near(composite([]), [[1, 0, 0], [0, 1, 0], [0, 0, 1]], 'no operations');
});
test('the first operation acts first', () => {
// scale, then rotate: R(30) S(2, 1)
near(composite([{ op: 'scale', x: 2, y: 1 }, { op: 'rotate', degrees: 30 }]), [[2 * c30, -0.5, 0], [1, c30, 0], [0, 0, 1]], 'scale then rotate');
// rotate, then scale: S(2, 1) R(30)
near(composite([{ op: 'rotate', degrees: 30 }, { op: 'scale', x: 2, y: 1 }]), [[2 * c30, -1, 0], [0.5, c30, 0], [0, 0, 1]], 'rotate then scale');
const moved = apply(composite([{ op: 'translate', x: 3, y: 0 }, { op: 'rotate', degrees: 90 }]), [0, 0]);
assert.deepEqual(moved.map(round), [0, 3], 'move by (3, 0), then turn: the origin ends at (0, 3)');
});
test('rotate and scale about a pivot', () => {
near(composite([{ op: 'rotate', degrees: 90, about: [1, 2] }]), [[0, -1, 3], [1, 0, 1], [0, 0, 1]], 'rotate 90 about (1, 2)');
near(composite([{ op: 'scale', x: 2, y: 2, about: [6, 2] }]), [[2, 0, -6], [0, 2, -2], [0, 0, 1]], 'scale 2 about the tip (6, 2)');
const m = composite([{ op: 'rotate', degrees: 37, about: [-4, 9] }]);
assert.deepEqual(apply(m, [-4, 9]).map(round), [-4, 9], 'the pivot stays put');
});
test('reflect in any line', () => {
const m = composite([{ op: 'reflect', through: [[0, 1], [1, 3]] }]);
near(m, [[-0.6, 0.8, -0.8], [0.8, 0.6, 0.4], [0, 0, 1]], 'the mirror in y = 2x + 1');
near(composite([{ op: 'reflect', through: [[1, 3], [2, 5]] }]), m, 'the same line, given by two other points');
near(composite([{ op: 'reflect', through: [[1, 3], [-1, -1]] }]), m, 'the same line, its points in the other order');
assert.ok(Math.abs(det(m) + 1) < 1e-12, 'a mirror has determinant -1');
near(composite([{ op: 'reflect', through: [[2, -1], [5, 3]] }, { op: 'reflect', through: [[2, -1], [5, 3]] }]), [[1, 0, 0], [0, 1, 0], [0, 0, 1]], 'mirroring twice');
assert.deepEqual(apply(composite([{ op: 'reflect', through: [[0, 0], [0, 1]] }]), [3, 4]).map(round), [-3, 4], 'the mirror in the y axis');
});
test('the petal pattern of the lesson', () => {
const POLAR = [[1.2, 8], [2.0, 4], [3.0, 3], [4.0, 6], [4.6, 12], [4.4, 19], [3.6, 20], [2.8, 17], [2.0, 13]];
const PETAL = POLAR.map(([r, a]) => [r * Math.cos((a * Math.PI) / 180), r * Math.sin((a * Math.PI) / 180)]);
const copy = (ops) => PETAL.map((p) => apply(composite(ops), p).map(round));
assert.deepEqual(copy([{ op: 'rotate', degrees: 90 }]), [[-0.167, 1.188], [-0.14, 1.995], [-0.157, 2.996], [-0.418, 3.978], [-0.956, 4.499], [-1.432, 4.16], [-1.231, 3.383], [-0.819, 2.678], [-0.45, 1.949]], 'R(90)');
const mirrored = copy([{ op: 'reflect', through: [[0, 0], [1, 0]] }, { op: 'rotate', degrees: 45 }]);
assert.deepEqual(mirrored, [[0.958, 0.722], [1.509, 1.312], [2.229, 2.007], [3.109, 2.517], [3.858, 2.505], [3.955, 1.929], [3.263, 1.521], [2.472, 1.315], [1.696, 1.06]], 'mirror in the x axis, then R(45): R(45) F');
near(composite([{ op: 'reflect', through: [[0, 0], [1, 0]] }, { op: 'rotate', degrees: 45 }]), composite([{ op: 'rotate', degrees: -45 }, { op: 'reflect', through: [[0, 0], [1, 0]] }]), 'R(45) F equals F R(-45)');
}); A hint
With column vectors the matrix that acts first stands on the right, so each new operation goes on the left of what you have: m = multiply(next, m). For a pivot p, the sandwich is multiply(multiply(translate(p), M), translate(-p)). A mirror in a line through the origin with direction (dx, dy) is the matrix with rows [c, s] and [s, −c], where c = (dx² − dy²) ÷ (dx² + dy²) and s = 2·dx·dy ÷ (dx² + dy²); sandwich it about the line's first point.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Split a matrix into translation, rotation, shear and scale
Finish decompose(m) in decompose.mjs. It splits an affine matrix, given as three rows, into M = T(tx, ty) R(angle) H(shear) S(sx, sy), where H is the x shear with rows [1, shear] and [0, 1], and returns { tx, ty, angle, shear, sx, sy } with the angle in degrees.
- Keep sx > 0, so that a mirror comes out as a negative sy.
- Throw an
Errorwhen the matrix has determinant 0: it flattens the plane, and no T R H S can give it.
The starter reads both scales as the lengths of the two columns and never finds a shear, so it is right only when the matrix has no shear and no mirror. The sample tests check the lesson's matrices, 500 seeded matrices that must come apart and multiply back within 1e-9, and two matrices with determinant 0.
Starter code · decompose.mjs
/**
* decompose(m) splits an affine 3 x 3 matrix (three rows) into
* M = T(tx, ty) R(angle) H(shear) S(sx, sy)
* where H is the x shear [1 shear; 0 1] and S scales by sx and sy. Keep sx > 0, so that a mirror
* shows as a negative sy. Return { tx, ty, angle, shear, sx, sy }, with the angle in degrees.
* This first version reads the scales as the lengths of the two columns and never finds a shear.
*/
export function decompose(m) {
const sx = Math.hypot(m[0][0], m[1][0]);
const sy = Math.hypot(m[0][1], m[1][1]);
const angle = (Math.atan2(m[1][0], m[0][0]) * 180) / Math.PI;
return { tx: m[0][2], ty: m[1][2], angle, shear: 0, sx, sy };
} The sample tests · decompose.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { decompose } from './decompose.mjs';
const multiply = (a, b) => a.map((row) => [0, 1, 2].map((j) => row[0] * b[0][j] + row[1] * b[1][j] + row[2] * b[2][j]));
const T = (x, y) => [[1, 0, x], [0, 1, y], [0, 0, 1]];
const R = (degrees) => {
const r = (degrees * Math.PI) / 180;
return [[Math.cos(r), -Math.sin(r), 0], [Math.sin(r), Math.cos(r), 0], [0, 0, 1]];
};
const H = (k) => [[1, k, 0], [0, 1, 0], [0, 0, 1]];
const S = (x, y) => [[x, 0, 0], [0, y, 0], [0, 0, 1]];
const compose = (...ms) => ms.reduce(multiply);
const recompose = (d) => compose(T(d.tx, d.ty), R(d.angle), H(d.shear), S(d.sx, d.sy));
const close = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;
function expectParts(d, want, label) {
for (const k of ['tx', 'ty', 'angle', 'shear', 'sx', 'sy']) {
assert.ok(typeof d[k] === 'number' && close(d[k], want[k]), `${label}: ${k} is ${d[k]}, expected ${want[k]}`);
}
}
function expectSame(a, b, label) {
for (let i = 0; i < 2; i++) for (let j = 0; j < 3; j++) assert.ok(close(a[i][j], b[i][j]), `${label}: recomposed row ${i + 1}, column ${j + 1} is ${a[i][j]}, expected ${b[i][j]}`);
}
test('a rotation and a scale, no shear', () => {
expectParts(decompose(compose(T(4, -1), R(30), S(2, 1.5))), { tx: 4, ty: -1, angle: 30, shear: 0, sx: 2, sy: 1.5 }, 'T(4, -1) R(30) S(2, 1.5)');
});
test('the lesson cases: a shear, a hidden shear and a mirror', () => {
expectParts(decompose(compose(T(4, -1), R(30), H(0.5), S(2, 1.5))), { tx: 4, ty: -1, angle: 30, shear: 0.5, sx: 2, sy: 1.5 }, 'T(4, -1) R(30) H(0.5) S(2, 1.5)');
const hidden = decompose(multiply(S(2, 1), R(30)));
expectParts(hidden, { tx: 0, ty: 0, angle: (Math.atan2(0.5, Math.sqrt(3)) * 180) / Math.PI, shear: (-3 * Math.sqrt(3)) / 8, sx: Math.sqrt(3.25), sy: 2 / Math.sqrt(3.25) }, 'S(2, 1) R(30)');
const mirror = decompose([[-0.6, 0.8, -0.8], [0.8, 0.6, 0.4], [0, 0, 1]]);
expectParts(mirror, { tx: -0.8, ty: 0.4, angle: (Math.atan2(0.8, -0.6) * 180) / Math.PI, shear: 0, sx: 1, sy: -1 }, 'the mirror in y = 2x + 1');
});
test('500 seeded matrices come apart and go back together', () => {
let seed = 11; // mulberry32, so every run tests the same matrices
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const between = (lo, hi) => lo + (hi - lo) * random();
for (let n = 0; n < 500; n++) {
const want = { tx: between(-50, 50), ty: between(-50, 50), angle: between(-179, 179), shear: between(-2, 2), sx: between(0.2, 5), sy: between(0.2, 5) * (random() < 0.5 ? -1 : 1) };
const m = recompose(want);
const d = decompose(m);
expectParts(d, want, `matrix ${n + 1}`);
expectSame(recompose(d), m, `matrix ${n + 1}`);
}
});
test('a matrix that flattens the plane is refused', () => {
assert.throws(() => decompose(S(1, 0)), 'S(1, 0) has determinant 0');
assert.throws(() => decompose([[1, 2, 5], [2, 4, 6], [0, 0, 1]]), 'parallel columns: determinant 0');
}); A hint
Only the first column is free of the shear: R H S sends (1, 0) to sx·(cos a, sin a), so its length is sx and its direction is the angle. Turn the second column back by that angle, by multiplying it by R(−a), and you have H S applied to (0, 1), which is (shear × sy, sy): read sy from the second number, then divide to get the shear.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on all six ideas, from the five matrices to the petal pattern. The session opens with the idea your answers show you are weakest on, and it repeats a question you miss:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2-5 years): How do you rotate a shape about a point that is not the origin?
A rotation matrix turns every point about the origin, so rotating about a pivot p takes three steps: translate by −p so that the pivot sits at the origin, rotate, and translate back by p. With column vectors the single matrix is T(p) R(θ) T(−p), read from right to left. For a quarter turn about (1, 2) it has rows [0, −1, 3] and [1, 0, 1]: the 2 × 2 part is the plain rotation, and the last column, p − R p = (3, 1), is the correction that keeps the pivot fixed. The same sandwich scales about a point, and with a turn on each side it mirrors in any line. On a canvas, where y points down, the same matrix turns the shape clockwise on screen.
Two questions of this module go further, into animating between transforms and building a mirror tool. Their first steps and follow-ups are on the module’s question page:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An animation tool moves a sprite from a start matrix to an end matrix by interpolating each matrix entry. A sprite that turns by 180° shrinks to a point halfway, and one that turns by 90° looks about 29 % smaller in the middle frame. Explain both and fix the animation.
-
Fresher to mid
A vector drawing tool needs "mirror the selection across a guide line", where the guide can be drawn at any angle through any point. Build the transformation for a guide through (2, −1) and (5, 3), say what goes wrong if you build it from the line's slope, and describe the tests you would ship with it.
Key takeaways
- In homogeneous coordinates translation, scale, rotation, reflection and shear are 3 × 3 matrices, and the determinant of the 2 × 2 part is the area factor: 3 for the scale by (2, 1.5), and −1 for a mirror, which also reverses the order of the corners.
- To transform about a pivot p, use T(p) M T(−p): a quarter turn about (1, 2) has rows [0, −1, 3] and [1, 0, 1], and a mirror in any line is the same sandwich with a turn on each side.
- Rotations commute with each other and with uniform scales, but not with non-uniform scales, shears, translations or mirrors: S(2, 1) R(30°) turns a square into a parallelogram with a 123° corner.
- A canvas stores its matrix as setTransform(a, b, c, d, e, f), column by column, applies the last call first, and turns clockwise for positive angles because its y axis points down.
- Every invertible affine matrix splits into T R H S in one way once the convention is fixed; the split is a convention, so decompositions are compared by multiplying them back.
- One leaning petal and the 16 matrices R(45k) and R(45k) F give an eight-fold pattern with eight mirror lines.
Terms in this lesson
- composite transformation
- One matrix made by multiplying several transformation matrices, so that a whole chain of moves is applied at once. With column vectors the rightmost factor acts on a point first.
- pivot
- The point that a rotation or a scale keeps fixed. Transforming about a pivot p means moving p to the origin, transforming there, and moving it back: T(p) M T(−p).
- reflection
- A transformation that mirrors every point in a line and keeps the points of the line itself fixed. Its matrix has determinant −1, and applying it twice gives the identity.
- shear
- A transformation that slides each point parallel to one axis by an amount proportional to its other coordinate, as in x′ = x + k·y. It keeps areas and turns rectangles into parallelograms.
- symmetry
- A transformation that maps a shape or a pattern onto itself, such as a turn by 45 degrees of an eight-fold pattern or a mirror in one of its lines.
References
- HTML Living Standard: the canvas element, transformations (setTransform, transform, rotate) and the coordinate space (WHATWG)
- CSS Transforms Module Level 1: 13.2.1 Decomposing a 2D matrix (World Wide Web Consortium (W3C))
- glTF 2.0 Specification: 3.4 Coordinate System and Units (Khronos Group)
- ECMAScript Language Specification: Math.cos and Math.sin (Ecma International (TC39))
- UGC NET syllabus: Computer Science and Applications (code 87) (University Grants Commission (UGC NET Bureau))
- Computer Graphics and Visualization (BE05000161) syllabus (Gujarat Technological University)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress