UNPKG

@js-draw/math

Version:
535 lines (534 loc) 19.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Mat33 = void 0; const Vec2_1 = require("./Vec2"); const Vec3_1 = __importDefault(require("./Vec3")); /** * Represents a three dimensional linear transformation or * a two-dimensional affine transformation. (An affine transformation scales/rotates/shears * **and** translates while a linear transformation just scales/rotates/shears). * * In addition to other matrices, {@link Mat33}s can be used to transform {@link Vec3}s and {@link Vec2}s. * * For example, to move the point $(1, 1)$ by 5 units to the left and 6 units up, * ```ts,runnable,console * import {Mat33, Vec2} from '@js-draw/math'; * * const moveLeftAndUp = Mat33.translation(Vec2.of(5, 6)); * console.log(moveLeftAndUp); * ``` * * This `moveLeftAndUp` matrix could then translate (move) a {@link Vec2} using * {@link Mat33.transformVec2}: * * ```ts,runnable,console * ---use-previous--- * ---visible--- * console.log(moveLeftAndUp.transformVec2(Vec2.of(1, 1))); * console.log(moveLeftAndUp.transformVec2(Vec2.of(-1, 2))); * ``` * * It's also possible to create transformation matrices that scale and rotate. * A single transform matrix can be created from multiple using matrix multiplication * (see {@link Mat33.rightMul}): * * ```ts,runnable,console * ---use-previous--- * ---visible--- * // Create a matrix by right multiplying. * const scaleThenRotate = * // The resultant matrix first scales by a factor of two * Mat33.scaling2D(2).rightMul( * // ...then rotates by pi/2 radians = 90 degrees. * Mat33.zRotation(Math.PI / 2) * ); * console.log(scaleThenRotate); * * // Use scaleThenRotate to scale then rotate a vector. * console.log(scaleThenRotate.transformVec2(Vec2.unitX)); * ``` */ class Mat33 { /** * Creates a matrix from inputs in the form, * $$ * \begin{bmatrix} * a1 & a2 & a3 \\ * b1 & b2 & b3 \\ * c1 & c2 & c3 * \end{bmatrix} * $$ * * Static constructor methods are also available. * See {@link Mat33.scaling2D}, {@link Mat33.zRotation}, {@link Mat33.translation}, and {@link Mat33.fromCSSMatrix}. */ constructor(a1, a2, a3, b1, b2, b3, c1, c2, c3) { this.a1 = a1; this.a2 = a2; this.a3 = a3; this.b1 = b1; this.b2 = b2; this.b3 = b3; this.c1 = c1; this.c2 = c2; this.c3 = c3; this.cachedInverse = undefined; this.rows = [Vec3_1.default.of(a1, a2, a3), Vec3_1.default.of(b1, b2, b3), Vec3_1.default.of(c1, c2, c3)]; } /** * Creates a matrix from the given rows: * $$ * \begin{bmatrix} * \texttt{r1.x} & \texttt{r1.y} & \texttt{r1.z}\\ * \texttt{r2.x} & \texttt{r2.y} & \texttt{r2.z}\\ * \texttt{r3.x} & \texttt{r3.y} & \texttt{r3.z}\\ * \end{bmatrix} * $$ */ static ofRows(r1, r2, r3) { return new Mat33(r1.x, r1.y, r1.z, r2.x, r2.y, r2.z, r3.x, r3.y, r3.z); } /** * Either returns the inverse of this, or, if this matrix is singular/uninvertable, * returns Mat33.identity. * * This may cache the computed inverse and return the cached version instead of recomputing * it. */ inverse() { return this.computeInverse() ?? Mat33.identity; } invertable() { return this.computeInverse() !== null; } computeInverse() { if (this.cachedInverse !== undefined) { return this.cachedInverse; } const toIdentity = [this.rows[0], this.rows[1], this.rows[2]]; const toResult = [Vec3_1.default.unitX, Vec3_1.default.unitY, Vec3_1.default.unitZ]; // Convert toIdentity to the identity matrix and // toResult to the inverse through elementary row operations for (let cursor = 0; cursor < 3; cursor++) { // Select the [cursor]th diagonal entry let pivot = toIdentity[cursor].at(cursor); // Don't divide by zero (treat very small numbers as zero). const minDivideBy = 1e-10; if (Math.abs(pivot) < minDivideBy) { let swapIndex = -1; // For all other rows, for (let i = 1; i <= 2; i++) { const otherRowIdx = (cursor + i) % 3; if (Math.abs(toIdentity[otherRowIdx].at(cursor)) >= minDivideBy) { swapIndex = otherRowIdx; break; } } // Can't swap with another row? if (swapIndex === -1) { this.cachedInverse = null; return null; } const tmpIdentityRow = toIdentity[cursor]; const tmpResultRow = toResult[cursor]; // Swap! toIdentity[cursor] = toIdentity[swapIndex]; toResult[cursor] = toResult[swapIndex]; toIdentity[swapIndex] = tmpIdentityRow; toResult[swapIndex] = tmpResultRow; pivot = toIdentity[cursor].at(cursor); } // Make toIdentity[k = cursor] = 1 let scale = 1.0 / pivot; toIdentity[cursor] = toIdentity[cursor].times(scale); toResult[cursor] = toResult[cursor].times(scale); const cursorToIdentityRow = toIdentity[cursor]; const cursorToResultRow = toResult[cursor]; // Make toIdentity[k ≠ cursor] = 0 for (let i = 1; i <= 2; i++) { const otherRowIdx = (cursor + i) % 3; scale = -toIdentity[otherRowIdx].at(cursor); toIdentity[otherRowIdx] = toIdentity[otherRowIdx].plus(cursorToIdentityRow.times(scale)); toResult[otherRowIdx] = toResult[otherRowIdx].plus(cursorToResultRow.times(scale)); } } const inverse = Mat33.ofRows(toResult[0], toResult[1], toResult[2]); this.cachedInverse = inverse; return inverse; } transposed() { return new Mat33(this.a1, this.b1, this.c1, this.a2, this.b2, this.c2, this.a3, this.b3, this.c3); } /** * [Right-multiplies](https://en.wikipedia.org/wiki/Matrix_multiplication) this by `other`. * * See also {@link transformVec3} and {@link transformVec2}. * * Example: * ```ts,runnable,console * import {Mat33, Vec2} from '@js-draw/math'; * console.log(Mat33.identity.rightMul(Mat33.identity)); * * // Create a matrix by right multiplying. * const scaleThenRotate = * // The resultant matrix first scales by a factor of two * Mat33.scaling2D(2).rightMul( * // ...then rotates by pi/4 radians = 45 degrees. * Mat33.zRotation(Math.PI / 4) * ); * console.log(scaleThenRotate); * * // Use scaleThenRotate to scale then rotate a vector. * console.log(scaleThenRotate.transformVec2(Vec2.unitX)); * ``` */ rightMul(other) { other = other.transposed(); const at = (row, col) => { return this.rows[row].dot(other.rows[col]); }; return new Mat33(at(0, 0), at(0, 1), at(0, 2), at(1, 0), at(1, 1), at(1, 2), at(2, 0), at(2, 1), at(2, 2)); } /** * Applies this as an **affine** transformation to the given vector. * Returns a transformed version of `other`. * * Unlike {@link transformVec3}, this **does** translate the given vector. */ transformVec2(other) { // When transforming a Vec2, we want to use the z transformation // components of this for translation: // ⎡ . . tX ⎤ // ⎢ . . tY ⎥ // ⎣ 0 0 1 ⎦ // For this, we need other's z component to be 1 (so that tX and tY // are scaled by 1): let intermediate = Vec3_1.default.of(other.x, other.y, 1); intermediate = this.transformVec3(intermediate); // Drop the z=1 to allow magnitude to work as expected return Vec2_1.Vec2.of(intermediate.x, intermediate.y); } /** * Applies this as a linear transformation to the given vector (doesn't translate). * This is the standard way of transforming vectors in ℝ³. */ transformVec3(other) { return Vec3_1.default.of(this.rows[0].dot(other), this.rows[1].dot(other), this.rows[2].dot(other)); } /** @returns true iff this is the identity matrix. */ isIdentity() { if (this === Mat33.identity) { return true; } return this.eq(Mat33.identity); } /** Returns true iff this = other ± fuzz */ eq(other, fuzz = 0) { for (let i = 0; i < 3; i++) { if (!this.rows[i].eq(other.rows[i], fuzz)) { return false; } } return true; } /** * Creates a human-readable representation of the matrix. * * Example: * ```ts,runnable,console * import { Mat33 } from '@js-draw/math'; * console.log(Mat33.identity.toString()); * ``` */ toString() { let result = ''; const maxColumnLens = [0, 0, 0]; // Determine the longest item in each column so we can pad the others to that // length. for (const row of this.rows) { for (let i = 0; i < 3; i++) { maxColumnLens[i] = Math.max(maxColumnLens[0], `${row.at(i)}`.length); } } for (let i = 0; i < 3; i++) { if (i === 0) { result += '⎡ '; } else if (i === 1) { result += '⎢ '; } else { result += '⎣ '; } // Add each component of the ith row (after padding it) for (let j = 0; j < 3; j++) { const val = this.rows[i].at(j).toString(); let padding = ''; for (let i = val.length; i < maxColumnLens[j]; i++) { padding += ' '; } result += val + ', ' + padding; } if (i === 0) { result += ' ⎤'; } else if (i === 1) { result += ' ⎥'; } else { result += ' ⎦'; } result += '\n'; } return result.trimEnd(); } /** * ``` * result[0] = top left element * result[1] = element at row zero, column 1 * ... * ``` * * Example: * ```ts,runnable,console * import { Mat33 } from '@js-draw/math'; * console.log( * new Mat33( * 1, 2, 3, * 4, 5, 6, * 7, 8, 9, * ) * ); * ``` */ toArray() { return [this.a1, this.a2, this.a3, this.b1, this.b2, this.b3, this.c1, this.c2, this.c3]; } /** * Returns a new `Mat33` where each entry is the output of the function * `mapping`. * * @example * ``` * new Mat33( * 1, 2, 3, * 4, 5, 6, * 7, 8, 9, * ).mapEntries(component => component - 1); * // → ⎡ 0, 1, 2 ⎤ * // ⎢ 3, 4, 5 ⎥ * // ⎣ 6, 7, 8 ⎦ * ``` */ mapEntries(mapping) { return new Mat33(mapping(this.a1, [0, 0]), mapping(this.a2, [0, 1]), mapping(this.a3, [0, 2]), mapping(this.b1, [1, 0]), mapping(this.b2, [1, 1]), mapping(this.b3, [1, 2]), mapping(this.c1, [2, 0]), mapping(this.c2, [2, 1]), mapping(this.c3, [2, 2])); } /** Estimate the scale factor of this matrix (based on the first row). */ getScaleFactor() { return Math.hypot(this.a1, this.a2); } /** Returns the `idx`-th column (`idx` is 0-indexed). */ getColumn(idx) { return Vec3_1.default.of(this.rows[0].at(idx), this.rows[1].at(idx), this.rows[2].at(idx)); } /** Returns the magnitude of the entry with the largest entry */ maximumEntryMagnitude() { let greatestSoFar = Math.abs(this.a1); for (const entry of this.toArray()) { greatestSoFar = Math.max(greatestSoFar, Math.abs(entry)); } return greatestSoFar; } /** * Constructs a 3x3 translation matrix (for translating `Vec2`s) using * **transformVec2**. * * Creates a matrix in the form * $$ * \begin{pmatrix} * 1 & 0 & {\tt amount.x}\\ * 0 & 1 & {\tt amount.y}\\ * 0 & 0 & 1 * \end{pmatrix} * $$ */ static translation(amount) { // When transforming Vec2s by a 3x3 matrix, we give the input // Vec2s z = 1. As such, // outVec2.x = inVec2.x * 1 + inVec2.y * 0 + 1 * amount.x // ... return new Mat33(1, 0, amount.x, 0, 1, amount.y, 0, 0, 1); } /** * Creates a matrix for rotating `Vec2`s about `center` by some number of `radians`. * * For this function, {@link Vec2}s are considered to be points in 2D space. * * For example, * ```ts,runnable,console * import { Mat33, Vec2 } from '@js-draw/math'; * * const halfCircle = Math.PI; // PI radians = 180 degrees = 1/2 circle * const center = Vec2.of(1, 1); // The point (1,1) * const rotationMatrix = Mat33.zRotation(halfCircle, center); * * console.log( * 'Rotating (0,0) 180deg about', center, 'results in', * // Rotates (0,0) * rotationMatrix.transformVec2(Vec2.zero), * ); * ``` */ static zRotation(radians, center = Vec2_1.Vec2.zero) { if (radians === 0) { return Mat33.identity; } const cos = Math.cos(radians); const sin = Math.sin(radians); // Translate everything so that rotation is about the origin let result = Mat33.translation(center); result = result.rightMul(new Mat33(cos, -sin, 0, sin, cos, 0, 0, 0, 1)); return result.rightMul(Mat33.translation(center.times(-1))); } static scaling2D(amount, center = Vec2_1.Vec2.zero) { let result = Mat33.translation(center); let xAmount, yAmount; if (typeof amount === 'number') { xAmount = amount; yAmount = amount; } else { xAmount = amount.x; yAmount = amount.y; } result = result.rightMul(new Mat33(xAmount, 0, 0, 0, yAmount, 0, 0, 0, 1)); // Translate such that [center] goes to (0, 0) return result.rightMul(Mat33.translation(center.times(-1))); } /** * **Note**: Assumes `this.c1 = this.c2 = 0` and `this.c3 = 1`. * * @see {@link fromCSSMatrix} */ toCSSMatrix() { return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`; } /** * Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. * * Note that such a matrix has the form, * ``` * ⎡ a c e ⎤ * ⎢ b d f ⎥ * ⎣ 0 0 1 ⎦ * ``` */ static fromCSSMatrix(cssString) { if (cssString === '' || cssString === 'none') { return Mat33.identity; } // Normalize spacing cssString = cssString.trim().replace(/\s+/g, ' '); const parseArguments = (argumentString) => { const parsed = argumentString.split(/[, \t\n]+/g).map((argString) => { // Handle trailing spaces/commands if (argString.trim() === '') { return null; } let isPercentage = false; if (argString.endsWith('%')) { isPercentage = true; argString = argString.substring(0, argString.length - 1); } // Remove trailing px units. argString = argString.replace(/px$/gi, ''); const numberExp = /^[-]?\d*(?:\.\d*)?(?:[eE][-+]?\d+)?$/i; if (!numberExp.exec(argString)) { throw new Error(`All arguments to transform functions must be numeric (state: ${JSON.stringify({ currentArgument: argString, allArguments: argumentString, })})`); } let argNumber = parseFloat(argString); if (isPercentage) { argNumber /= 100; } return argNumber; }); return parsed.filter((n) => n !== null); }; const keywordToAction = { matrix: (matrixData) => { if (matrixData.length !== 6) { throw new Error(`Invalid matrix argument: ${matrixData}. Must have length 6`); } const a = matrixData[0]; const b = matrixData[1]; const c = matrixData[2]; const d = matrixData[3]; const e = matrixData[4]; const f = matrixData[5]; const transform = new Mat33(a, c, e, b, d, f, 0, 0, 1); return transform; }, scale: (scaleArgs) => { let scaleX, scaleY; if (scaleArgs.length === 1) { scaleX = scaleArgs[0]; scaleY = scaleArgs[0]; } else if (scaleArgs.length === 2) { scaleX = scaleArgs[0]; scaleY = scaleArgs[1]; } else { throw new Error(`The scale() function only supports two arguments. Given: ${scaleArgs}`); } return Mat33.scaling2D(Vec2_1.Vec2.of(scaleX, scaleY)); }, translate: (translateArgs) => { let translateX = 0; let translateY = 0; if (translateArgs.length === 1) { // If no y translation is given, assume 0. translateX = translateArgs[0]; } else if (translateArgs.length === 2) { translateX = translateArgs[0]; translateY = translateArgs[1]; } else { throw new Error(`The translate() function requires either 1 or 2 arguments. Given ${translateArgs}`); } return Mat33.translation(Vec2_1.Vec2.of(translateX, translateY)); }, }; // A command (\w+) // followed by a set of arguments ([^)]*) const partRegex = /(?:^|\W)(\w+)\s?\(([^)]*)\)/gi; let match; let matrix = null; while ((match = partRegex.exec(cssString)) !== null) { const action = match[1].toLowerCase(); if (!(action in keywordToAction)) { throw new Error(`Unsupported CSS transform action: ${action}`); } const args = parseArguments(match[2]); const currentMatrix = keywordToAction[action](args); if (!matrix) { matrix = currentMatrix; } else { matrix = matrix.rightMul(currentMatrix); } } return matrix ?? Mat33.identity; } } exports.Mat33 = Mat33; /** The 3x3 [identity matrix](https://en.wikipedia.org/wiki/Identity_matrix). */ Mat33.identity = new Mat33(1, 0, 0, 0, 1, 0, 0, 0, 1); exports.default = Mat33;