diff --git a/scripts/steiger-rules.ts b/scripts/steiger-rules.ts index 70f2297e5..edda8e628 100644 --- a/scripts/steiger-rules.ts +++ b/scripts/steiger-rules.ts @@ -272,6 +272,11 @@ const ENGINE_TEST_DOMAIN_REDIRECTS: Array<{ from: 'tests/engine/vector/basic', to: 'tests/engine/vector/blob/, tests/engine/vector/normalize.test.ts, tests/engine/vector/validate.test.ts, or tests/engine/vector/bounds.test.ts', source: 'the vector module under test' + }, + { + from: 'tests/engine/geometry/basic', + to: 'tests/engine/geometry/angles.test.ts, tests/engine/geometry/rotation.test.ts, tests/engine/geometry/bounds.test.ts, or tests/engine/geometry/visual-bounds.test.ts', + source: 'the geometry module under test' } ] diff --git a/tests/engine/geometry/angles.test.ts b/tests/engine/geometry/angles.test.ts new file mode 100644 index 000000000..519028563 --- /dev/null +++ b/tests/engine/geometry/angles.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'bun:test' + +import { degToRad, radToDeg } from '@open-pencil/core' + +describe('degToRad / radToDeg', () => { + test('degToRad converts degrees to radians', () => { + expect(degToRad(0)).toBe(0) + expect(degToRad(90)).toBeCloseTo(Math.PI / 2, 10) + expect(degToRad(180)).toBeCloseTo(Math.PI, 10) + expect(degToRad(360)).toBeCloseTo(Math.PI * 2, 10) + }) + + test('radToDeg converts radians to degrees', () => { + expect(radToDeg(0)).toBe(0) + expect(radToDeg(Math.PI / 2)).toBeCloseTo(90, 10) + expect(radToDeg(Math.PI)).toBeCloseTo(180, 10) + }) + + test('roundtrip preserves value', () => { + expect(radToDeg(degToRad(45))).toBeCloseTo(45, 10) + expect(radToDeg(degToRad(-90))).toBeCloseTo(-90, 10) + }) +}) + diff --git a/tests/engine/geometry/bounds.test.ts b/tests/engine/geometry/bounds.test.ts new file mode 100644 index 000000000..6c77857b4 --- /dev/null +++ b/tests/engine/geometry/bounds.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from 'bun:test' + +import { computeAbsoluteBounds, computeBounds, type Vector } from '@open-pencil/core' + +describe('computeBounds', () => { + test('empty iterable returns zero rect', () => { + expect(computeBounds([])).toEqual({ x: 0, y: 0, width: 0, height: 0 }) + }) + + test('single rect returns itself', () => { + const r = { x: 10, y: 20, width: 30, height: 40 } + expect(computeBounds([r])).toEqual(r) + }) + + test('multiple rects returns union', () => { + const result = computeBounds([ + { x: 0, y: 0, width: 100, height: 100 }, + { x: 50, y: 50, width: 100, height: 100 } + ]) + expect(result).toEqual({ x: 0, y: 0, width: 150, height: 150 }) + }) + + test('disjoint rects returns union spanning both', () => { + const result = computeBounds([ + { x: 0, y: 0, width: 10, height: 10 }, + { x: 100, y: 100, width: 10, height: 10 } + ]) + expect(result).toEqual({ x: 0, y: 0, width: 110, height: 110 }) + }) +}) + +describe('computeAbsoluteBounds', () => { + const idPos = (id: string) => { + const map: Record = { + a: { x: 10, y: 20 }, + b: { x: 50, y: 60 }, + c: { x: 0, y: 0 } + } + return map[id] ?? { x: 0, y: 0 } + } + + test('empty iterable returns zero rect', () => { + expect(computeAbsoluteBounds([], idPos)).toEqual({ x: 0, y: 0, width: 0, height: 0 }) + }) + + test('single node returns its bounds', () => { + const result = computeAbsoluteBounds([{ id: 'a', width: 30, height: 40 }], idPos) + expect(result).toEqual({ x: 10, y: 20, width: 30, height: 40 }) + }) + + test('multiple nodes returns union', () => { + const result = computeAbsoluteBounds( + [ + { id: 'a', width: 30, height: 40 }, + { id: 'b', width: 20, height: 20 } + ], + idPos + ) + expect(result).toEqual({ x: 10, y: 20, width: 60, height: 60 }) + }) +}) + diff --git a/tests/engine/geometry/rotation.test.ts b/tests/engine/geometry/rotation.test.ts new file mode 100644 index 000000000..ef400f61f --- /dev/null +++ b/tests/engine/geometry/rotation.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect } from 'bun:test' + +import { rotatePoint, rotatedBBox, rotatedCorners } from '@open-pencil/core' + +describe('rotatePoint', () => { + test('no rotation returns same point', () => { + const result = rotatePoint(10, 20, 0, 0, 0) + expect(result.x).toBeCloseTo(10, 10) + expect(result.y).toBeCloseTo(20, 10) + }) + + test('90° rotation around origin', () => { + const result = rotatePoint(1, 0, 0, 0, Math.PI / 2) + expect(result.x).toBeCloseTo(0, 10) + expect(result.y).toBeCloseTo(1, 10) + }) + + test('180° rotation negates both axes', () => { + const result = rotatePoint(1, 0, 0, 0, Math.PI) + expect(result.x).toBeCloseTo(-1, 10) + expect(result.y).toBeCloseTo(0, 10) + }) + + test('rotation around non-origin center', () => { + // Rotate (1,0) around (0.5, 0.5) by 90° → (1, 1) + const result = rotatePoint(1, 0, 0.5, 0.5, Math.PI / 2) + expect(result.x).toBeCloseTo(1, 10) + expect(result.y).toBeCloseTo(1, 10) + }) +}) + +describe('rotatedCorners', () => { + test('zero rotation returns axis-aligned corners', () => { + const [tl, tr, br, bl] = rotatedCorners(50, 50, 50, 50, 0) + expect(tl).toEqual({ x: 0, y: 0 }) + expect(tr).toEqual({ x: 100, y: 0 }) + expect(br).toEqual({ x: 100, y: 100 }) + expect(bl).toEqual({ x: 0, y: 100 }) + }) + + test('90° rotation swaps width and height positions', () => { + const [tl, tr, br, bl] = rotatedCorners(50, 50, 50, 25, 90) + // After 90° rotation: corners should be at different positions + // The center (50,50) stays the same + const cx = (tl.x + tr.x + br.x + bl.x) / 4 + const cy = (tl.y + tr.y + br.y + bl.y) / 4 + expect(cx).toBeCloseTo(50, 10) + expect(cy).toBeCloseTo(50, 10) + }) +}) + +describe('rotatedBBox', () => { + test('zero rotation returns input as bbox', () => { + const bbox = rotatedBBox(10, 20, 30, 40, 0) + expect(bbox).toEqual({ + left: 10, + right: 40, + top: 20, + bottom: 60, + centerX: 25, + centerY: 40 + }) + }) + + test('45° rotation of square expands bbox', () => { + const bbox = rotatedBBox(0, 0, 100, 100, 45) + // A 100x100 square rotated 45° has a bbox of ~141.4 wide/tall + const side = Math.sqrt(100 * 100 * 2) + expect(bbox.right - bbox.left).toBeCloseTo(side, 5) + expect(bbox.bottom - bbox.top).toBeCloseTo(side, 5) + // Center should remain at (50, 50) + expect(bbox.centerX).toBeCloseTo(50, 5) + expect(bbox.centerY).toBeCloseTo(50, 5) + }) + + test('90° rotation of rectangle swaps dimensions', () => { + const bbox = rotatedBBox(0, 0, 200, 100, 90) + // A 200x100 rect rotated 90° around its center (100, 50) → bbox 100x200 + // The center is at (100, 50) regardless of rotation + expect(bbox.centerX).toBeCloseTo(100, 5) + expect(bbox.centerY).toBeCloseTo(50, 5) + // Width of bbox = original height, height of bbox = original width + expect(bbox.right - bbox.left).toBeCloseTo(100, 5) + expect(bbox.bottom - bbox.top).toBeCloseTo(200, 5) + }) + + test('negative rotation works correctly', () => { + const pos = rotatedBBox(0, 0, 100, 100, -45) + const neg = rotatedBBox(0, 0, 100, 100, 45) + // Bounding box should be same size regardless of rotation direction + expect(pos.right - pos.left).toBeCloseTo(neg.right - neg.left, 5) + expect(pos.bottom - pos.top).toBeCloseTo(neg.bottom - neg.top, 5) + }) +}) + diff --git a/tests/engine/geometry/basic.test.ts b/tests/engine/geometry/visual-bounds.test.ts similarity index 64% rename from tests/engine/geometry/basic.test.ts rename to tests/engine/geometry/visual-bounds.test.ts index 3c0157ced..db60865f8 100644 --- a/tests/engine/geometry/basic.test.ts +++ b/tests/engine/geometry/visual-bounds.test.ts @@ -1,194 +1,9 @@ -/** - * Tests for geometry utility functions in packages/core/src/geometry.ts. - * - * These pure functions compute bounding boxes, rotated corners, and visual bounds - * for scene nodes. They are used by the renderer for culling, export bounds - * computation, and hit testing. - */ import { describe, test, expect } from 'bun:test' -import { - computeBounds, - computeAbsoluteBounds, - degToRad, - radToDeg, - rotatePoint, - rotatedCorners, - rotatedBBox, - type Vector -} from '@open-pencil/core' +import type { Vector } from '@open-pencil/core' import { computeVisualBounds } from '#core/geometry' -describe('degToRad / radToDeg', () => { - test('degToRad converts degrees to radians', () => { - expect(degToRad(0)).toBe(0) - expect(degToRad(90)).toBeCloseTo(Math.PI / 2, 10) - expect(degToRad(180)).toBeCloseTo(Math.PI, 10) - expect(degToRad(360)).toBeCloseTo(Math.PI * 2, 10) - }) - - test('radToDeg converts radians to degrees', () => { - expect(radToDeg(0)).toBe(0) - expect(radToDeg(Math.PI / 2)).toBeCloseTo(90, 10) - expect(radToDeg(Math.PI)).toBeCloseTo(180, 10) - }) - - test('roundtrip preserves value', () => { - expect(radToDeg(degToRad(45))).toBeCloseTo(45, 10) - expect(radToDeg(degToRad(-90))).toBeCloseTo(-90, 10) - }) -}) - -describe('rotatePoint', () => { - test('no rotation returns same point', () => { - const result = rotatePoint(10, 20, 0, 0, 0) - expect(result.x).toBeCloseTo(10, 10) - expect(result.y).toBeCloseTo(20, 10) - }) - - test('90° rotation around origin', () => { - const result = rotatePoint(1, 0, 0, 0, Math.PI / 2) - expect(result.x).toBeCloseTo(0, 10) - expect(result.y).toBeCloseTo(1, 10) - }) - - test('180° rotation negates both axes', () => { - const result = rotatePoint(1, 0, 0, 0, Math.PI) - expect(result.x).toBeCloseTo(-1, 10) - expect(result.y).toBeCloseTo(0, 10) - }) - - test('rotation around non-origin center', () => { - // Rotate (1,0) around (0.5, 0.5) by 90° → (1, 1) - const result = rotatePoint(1, 0, 0.5, 0.5, Math.PI / 2) - expect(result.x).toBeCloseTo(1, 10) - expect(result.y).toBeCloseTo(1, 10) - }) -}) - -describe('rotatedCorners', () => { - test('zero rotation returns axis-aligned corners', () => { - const [tl, tr, br, bl] = rotatedCorners(50, 50, 50, 50, 0) - expect(tl).toEqual({ x: 0, y: 0 }) - expect(tr).toEqual({ x: 100, y: 0 }) - expect(br).toEqual({ x: 100, y: 100 }) - expect(bl).toEqual({ x: 0, y: 100 }) - }) - - test('90° rotation swaps width and height positions', () => { - const [tl, tr, br, bl] = rotatedCorners(50, 50, 50, 25, 90) - // After 90° rotation: corners should be at different positions - // The center (50,50) stays the same - const cx = (tl.x + tr.x + br.x + bl.x) / 4 - const cy = (tl.y + tr.y + br.y + bl.y) / 4 - expect(cx).toBeCloseTo(50, 10) - expect(cy).toBeCloseTo(50, 10) - }) -}) - -describe('rotatedBBox', () => { - test('zero rotation returns input as bbox', () => { - const bbox = rotatedBBox(10, 20, 30, 40, 0) - expect(bbox).toEqual({ - left: 10, - right: 40, - top: 20, - bottom: 60, - centerX: 25, - centerY: 40 - }) - }) - - test('45° rotation of square expands bbox', () => { - const bbox = rotatedBBox(0, 0, 100, 100, 45) - // A 100x100 square rotated 45° has a bbox of ~141.4 wide/tall - const side = Math.sqrt(100 * 100 * 2) - expect(bbox.right - bbox.left).toBeCloseTo(side, 5) - expect(bbox.bottom - bbox.top).toBeCloseTo(side, 5) - // Center should remain at (50, 50) - expect(bbox.centerX).toBeCloseTo(50, 5) - expect(bbox.centerY).toBeCloseTo(50, 5) - }) - - test('90° rotation of rectangle swaps dimensions', () => { - const bbox = rotatedBBox(0, 0, 200, 100, 90) - // A 200x100 rect rotated 90° around its center (100, 50) → bbox 100x200 - // The center is at (100, 50) regardless of rotation - expect(bbox.centerX).toBeCloseTo(100, 5) - expect(bbox.centerY).toBeCloseTo(50, 5) - // Width of bbox = original height, height of bbox = original width - expect(bbox.right - bbox.left).toBeCloseTo(100, 5) - expect(bbox.bottom - bbox.top).toBeCloseTo(200, 5) - }) - - test('negative rotation works correctly', () => { - const pos = rotatedBBox(0, 0, 100, 100, -45) - const neg = rotatedBBox(0, 0, 100, 100, 45) - // Bounding box should be same size regardless of rotation direction - expect(pos.right - pos.left).toBeCloseTo(neg.right - neg.left, 5) - expect(pos.bottom - pos.top).toBeCloseTo(neg.bottom - neg.top, 5) - }) -}) - -describe('computeBounds', () => { - test('empty iterable returns zero rect', () => { - expect(computeBounds([])).toEqual({ x: 0, y: 0, width: 0, height: 0 }) - }) - - test('single rect returns itself', () => { - const r = { x: 10, y: 20, width: 30, height: 40 } - expect(computeBounds([r])).toEqual(r) - }) - - test('multiple rects returns union', () => { - const result = computeBounds([ - { x: 0, y: 0, width: 100, height: 100 }, - { x: 50, y: 50, width: 100, height: 100 } - ]) - expect(result).toEqual({ x: 0, y: 0, width: 150, height: 150 }) - }) - - test('disjoint rects returns union spanning both', () => { - const result = computeBounds([ - { x: 0, y: 0, width: 10, height: 10 }, - { x: 100, y: 100, width: 10, height: 10 } - ]) - expect(result).toEqual({ x: 0, y: 0, width: 110, height: 110 }) - }) -}) - -describe('computeAbsoluteBounds', () => { - const idPos = (id: string) => { - const map: Record = { - a: { x: 10, y: 20 }, - b: { x: 50, y: 60 }, - c: { x: 0, y: 0 } - } - return map[id] ?? { x: 0, y: 0 } - } - - test('empty iterable returns zero rect', () => { - expect(computeAbsoluteBounds([], idPos)).toEqual({ x: 0, y: 0, width: 0, height: 0 }) - }) - - test('single node returns its bounds', () => { - const result = computeAbsoluteBounds([{ id: 'a', width: 30, height: 40 }], idPos) - expect(result).toEqual({ x: 10, y: 20, width: 30, height: 40 }) - }) - - test('multiple nodes returns union', () => { - const result = computeAbsoluteBounds( - [ - { id: 'a', width: 30, height: 40 }, - { id: 'b', width: 20, height: 20 } - ], - idPos - ) - expect(result).toEqual({ x: 10, y: 20, width: 60, height: 60 }) - }) -}) - describe('computeVisualBounds', () => { const idPos = (id: string) => { const map: Record = {