diff --git a/math.test.ts b/math.test.ts new file mode 100644 index 0000000..3f4ec32 --- /dev/null +++ b/math.test.ts @@ -0,0 +1,368 @@ +import {clamp, Vec2, AffineTransform, Rect} from './math' +import * as jsc from 'jsverify' + +test('clamp', () => { + jsc.assertForall(jsc.number, jsc.number, jsc.number, (a, b, c) => { + const result = clamp(a, b, c) + if (a < b) return result == b + if (a > c) return result == c + return result == a + }) +}) + +// Change this to jsc.integer to debug failures more easily +let numericType = jsc.number + +const arbitraryVec2 = jsc + .record({x: jsc.integer, y: numericType}) + .smap(v => new Vec2(v.x, v.y), v => v) + +const positiveVec2 = jsc.suchthat(arbitraryVec2, v => v.x > 0 && v.y > 0) + +const arbitraryTransform = jsc + .record({ + m00: numericType, + m01: numericType, + m02: numericType, + m10: numericType, + m11: numericType, + m12: numericType, + }) + .smap(t => new AffineTransform(t.m00, t.m01, t.m02, t.m10, t.m11, t.m12), t => t) + +const invertibleTransform = jsc.suchthat(arbitraryTransform, t => t.det() != 0) + +const simpleTransform = jsc.suchthat( + jsc + .record({scale: arbitraryVec2, translation: arbitraryVec2}) + .smap( + t => AffineTransform.withScale(t.scale).withTranslation(t.translation), + t => ({scale: t.getScale(), translation: t.getTranslation()}), + ), + t => t.det() != 0, +) + +const arbitraryRect = jsc + .record({origin: arbitraryVec2, size: positiveVec2}) + .smap(r => new Rect(r.origin, r.size), r => r) + +describe('Vec2', () => { + test('constructor', () => { + jsc.assertForall(jsc.number, jsc.number, (a, b) => { + const v = new Vec2(a, b) + return v.x == a && v.y == b + }) + }) + + test('withX', () => { + jsc.assertForall(arbitraryVec2, jsc.number, (v, n) => { + return v.withX(n).x === n + }) + }) + + test('withY', () => { + jsc.assertForall(arbitraryVec2, jsc.number, (v, n) => { + return v.withY(n).y === n + }) + }) + + test('plus', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + const sum = v1.plus(v2) + return sum.x === v1.x + v2.x && sum.y === v1.y + v2.y + }) + }) + + test('minus', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + const diff = v1.minus(v2) + return diff.x === v1.x - v2.x && diff.y === v1.y - v2.y + }) + }) + + test('times', () => { + jsc.assertForall(arbitraryVec2, jsc.number, (v1, s) => { + const prod = v1.times(s) + return prod.x === v1.x * s && prod.y === v1.y * s + }) + }) + + test('timesPointwise', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + const prod = v1.timesPointwise(v2) + return prod.x === v1.x * v2.x && prod.y === v1.y * v2.y + }) + }) + + test('dividedByPointwise', () => { + jsc.assertForall( + arbitraryVec2, + jsc.suchthat(arbitraryVec2, v => v.x !== 0 && v.y !== 0), + (v1, v2) => { + const div = v1.dividedByPointwise(v2) + return div.x === v1.x / v2.x && div.y === v1.y / v2.y + }, + ) + }) + + test('dot', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return v1.dot(v2) === v1.x * v2.x + v1.y * v2.y + }) + }) + + test('equals', () => { + jsc.assertForall(jsc.number, jsc.number, (a, b) => { + return new Vec2(a, b).equals(new Vec2(a, b)) + }) + + jsc.assertForall(arbitraryVec2, arbitraryVec2, (a, b) => { + return a.equals(b) === (a.x === b.x && a.y === b.y) + }) + }) + + test('length2', () => { + jsc.assertForall(arbitraryVec2, v => { + return v.length2() === v.x * v.x + v.y * v.y + }) + }) + + test('length', () => { + jsc.assertForall(arbitraryVec2, v => { + return v.length() === Math.sqrt(v.x * v.x + v.y * v.y) + }) + }) + + test('abs', () => { + jsc.assertForall(arbitraryVec2, v => { + const q = v.abs() + return q.x === Math.abs(v.x) && q.y === Math.abs(v.y) + }) + }) + + test('min', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + const min = Vec2.min(v1, v2) + return min.x === Math.min(v1.x, v2.x) && min.y === Math.min(v1.y, v2.y) + }) + }) + + test('max', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + const max = Vec2.max(v1, v2) + return max.x === Math.max(v1.x, v2.x) && max.y === Math.max(v1.y, v2.y) + }) + }) + + test('flatten', () => { + jsc.assertForall(arbitraryVec2, v1 => { + const flat = v1.flatten() + return flat[0] == v1.x && flat[1] == v1.y + }) + }) +}) + +describe('Rect', () => { + test('isEmpty', () => { + jsc.assertForall(arbitraryVec2, jsc.number, (v, n) => { + return new Rect(v, new Vec2(0, n)).isEmpty() + }) + jsc.assertForall(arbitraryVec2, jsc.number, (v, n) => { + return new Rect(v, new Vec2(n, 0)).isEmpty() + }) + jsc.assertForall(arbitraryVec2, v => { + return !new Rect(v, Vec2.unit).isEmpty() + }) + }) + + test('width', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).width() == v2.x + }) + }) + test('height', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).height() == v2.y + }) + }) + test('left', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).left() == v1.x + }) + }) + test('top', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).top() == v1.y + }) + }) + test('right', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).right() == v1.x + v2.x + }) + }) + test('bottom', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).bottom() == v1.y + v2.y + }) + }) + test('topLeft', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).topLeft().equals(v1) + }) + }) + test('topRight', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).topRight().equals(v1.plus(v2.withY(0))) + }) + }) + test('bottomLeft', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).bottomLeft().equals(v1.plus(v2.withX(0))) + }) + }) + test('bottomRight', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return new Rect(v1, v2).bottomRight().equals(v1.plus(v2)) + }) + }) + + test('withOrigin', () => { + jsc.assertForall(arbitraryRect, arbitraryVec2, (r, v) => { + return r.withOrigin(v).origin.equals(v) + }) + }) + test('withSize', () => { + jsc.assertForall(arbitraryRect, arbitraryVec2, (r, v) => { + return r.withSize(v).size.equals(v) + }) + }) + + test('closestPointTo', () => { + jsc.assertForall(arbitraryRect, arbitraryVec2, (r, v) => { + const p = r.closestPointTo(v) + return p.x >= r.left() && p.x <= r.right() && p.y >= r.top() && p.y <= r.bottom() + }) + }) + + test('hasIntersectionWith', () => { + jsc.assertForall(arbitraryRect, arbitraryRect, (r1, r2) => { + return r1.hasIntersectionWith(r2) === !r1.intersectWith(r2).isEmpty() + }) + }) + + test('intersectWith', () => { + jsc.assertForall(arbitraryRect, arbitraryRect, (r1, r2) => { + const inter = r1.intersectWith(r2) + return inter.isEmpty() || (r1.hasIntersectionWith(inter) && r2.hasIntersectionWith(inter)) + }) + }) +}) + +describe('AffineTransform', () => { + test('inverted', () => { + expect(new AffineTransform(0, 0, 0, 0, 0, 0).inverted()).toBe(null) + + jsc.assertForall(invertibleTransform, t => { + return t + .inverted()! + .inverted()! + .approxEquals(t) + }) + }) + + test('translation', () => { + jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => { + return t + .withTranslation(v1) + .getTranslation() + .equals(v1) + }) + + jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => { + const initialTranslation = t.getTranslation() + return t + .translatedBy(v1) + .getTranslation() + .approxEquals(initialTranslation.plus(v1)) + }) + }) + + test('scale', () => { + jsc.assertForall(arbitraryTransform, arbitraryVec2, (t, v1) => { + return t + .withScale(v1) + .getScale() + .equals(v1) + }) + }) + + test('transformVector', () => { + // Vector transformation are translation-invariant + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return AffineTransform.withTranslation(v1) + .transformVector(v2) + .approxEquals(v2) + }) + + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return AffineTransform.withScale(v1) + .transformVector(v2) + .approxEquals(v2.timesPointwise(v1)) + }) + }) + + test('inverseTransformVector', () => { + jsc.assertForall(invertibleTransform, arbitraryVec2, (t, v) => { + return t.inverseTransformVector(t.transformVector(v))!.approxEquals(v) + }) + }) + + test('transformPosition', () => { + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return AffineTransform.withTranslation(v1) + .transformPosition(v2) + .approxEquals(v2.plus(v1)) + }) + + jsc.assertForall(arbitraryVec2, arbitraryVec2, (v1, v2) => { + return AffineTransform.withScale(v1) + .transformPosition(v2) + .approxEquals(v2.timesPointwise(v1)) + }) + }) + + test('inverseTransformPosition', () => { + jsc.assertForall(invertibleTransform, arbitraryVec2, (t, v) => { + return t.inverseTransformPosition(t.transformPosition(v))!.approxEquals(v) + }) + }) + + test('transformRect', () => { + jsc.assertForall(arbitraryVec2, arbitraryRect, (v, r) => { + return AffineTransform.withTranslation(v) + .transformRect(r) + .equals(r.withOrigin(r.origin.plus(v))) + }) + + jsc.assertForall(arbitraryVec2, arbitraryRect, (v, r) => { + const t = AffineTransform.withScale(v) + const rt = t.transformRect(r) + return Math.abs(rt.area() - r.area() * Math.abs(t.det())) < 1e-6 + }) + }) + + test('inverseTransformRect', () => { + jsc.assertForall(simpleTransform, arbitraryRect, (t, r) => { + return t.inverseTransformRect(t.transformRect(r))!.approxEquals(r) + }) + }) + + test('times', () => { + jsc.assertForall(invertibleTransform, invertibleTransform, (t1, t2) => { + return t1 + .times(t2) + .times(t2.inverted()!) + .approxEquals(t1) + }) + }) +}) diff --git a/math.ts b/math.ts index 63fb9d9..bf8d417 100644 --- a/math.ts +++ b/math.ts @@ -34,6 +34,10 @@ export class Vec2 { equals(other: Vec2) { return this.x === other.x && this.y === other.y } + approxEquals(other: Vec2, epsilon = 1e-9) { + return Math.abs(this.x - other.x) < epsilon && Math.abs(this.y - other.y) < epsilon + } + length2() { return this.dot(this) } @@ -130,6 +134,17 @@ export class AffineTransform { ) } + approxEquals(other: AffineTransform, epsilon = 1e-9) { + return ( + Math.abs(this.m00 - other.m00) < epsilon && + Math.abs(this.m01 - other.m01) < epsilon && + Math.abs(this.m02 - other.m02) < epsilon && + Math.abs(this.m10 - other.m10) < epsilon && + Math.abs(this.m11 - other.m11) < epsilon && + Math.abs(this.m12 - other.m12) < epsilon + ) + } + timesScalar(s: number) { const {m00, m01, m02, m10, m11, m12} = this return new AffineTransform(s * m00, s * m01, s * m02, s * m10, s * m11, s * m12) @@ -313,6 +328,14 @@ export class Rect { return this.origin.equals(other.origin) && this.size.equals(other.size) } + approxEquals(other: Rect) { + return this.origin.approxEquals(other.origin) && this.size.approxEquals(other.size) + } + + area() { + return this.size.x * this.size.y + } + static empty = new Rect(Vec2.zero, Vec2.zero) static unit = new Rect(Vec2.zero, Vec2.unit) static NDC = new Rect(new Vec2(-1, -1), new Vec2(2, 2)) diff --git a/package-lock.json b/package-lock.json index 447a3fd..8820e68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6395,6 +6395,18 @@ "verror": "1.10.0" } }, + "jsverify": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/jsverify/-/jsverify-0.8.3.tgz", + "integrity": "sha1-AukXjhkktar5WcAjmvhO1cbQc4w=", + "dev": true, + "requires": { + "lazy-seq": "1.0.0", + "rc4": "0.1.5", + "trampa": "1.0.0", + "typify-parser": "1.1.0" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -6411,6 +6423,12 @@ "dev": true, "optional": true }, + "lazy-seq": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lazy-seq/-/lazy-seq-1.0.0.tgz", + "integrity": "sha1-iAy4qrJWAmOC4C9T7AiWgqdMW2o=", + "dev": true + }, "lcid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", @@ -9994,6 +10012,12 @@ "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=", "dev": true }, + "rc4": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/rc4/-/rc4-0.1.5.tgz", + "integrity": "sha1-CMbgSgFo9utiHCKrbLEVG9n0pk0=", + "dev": true + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -11942,6 +11966,12 @@ } } }, + "trampa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trampa/-/trampa-1.0.0.tgz", + "integrity": "sha1-Ukc0esM0gH+mwAAERMuRtjmECtU=", + "dev": true + }, "trim-right": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", @@ -12319,6 +12349,12 @@ "semver": "5.5.0" } }, + "typify-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/typify-parser/-/typify-parser-1.1.0.tgz", + "integrity": "sha1-rHO/pfJTQ0aOLQ8zRsYRe8A9PJk=", + "dev": true + }, "uglify-es": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.2.2.tgz", diff --git a/package.json b/package.json index 160bfb8..2a211f0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "eslint": "^4.19.1", "eslint-plugin-prettier": "^2.6.0", "jest": "^23.0.1", + "jsverify": "^0.8.3", "pako": "1.0.6", "parcel-bundler": "1.7.0", "preact": "8.2.7",