import { Point } from './types'; import { Vector } from './Vector'; type RotatedDOMRectInit = { x?: number; y?: number; width?: number; height?: number; rotation?: number; }; /** * Represents a rectangle that can be rotated in 2D space. * All coordinates are relative to the center of the rectangle. */ interface IRotatedDOMRect { /** X coordinate of the rectangle's center */ x: number; /** Y coordinate of the rectangle's center */ y: number; /** Center point of the rectangle */ center: Readonly; /** Width of the rectangle */ width: number; /** Height of the rectangle */ height: number; /** Rotation of the rectangle in radians */ rotation: number; /** Top-left corner of the rotated rectangle */ topLeft: Readonly; /** Top-right corner of the rotated rectangle */ topRight: Readonly; /** Bottom-left corner of the rotated rectangle */ bottomLeft: Readonly; /** Bottom-right corner of the rotated rectangle */ bottomRight: Readonly; /** * Returns an axis-aligned bounding box that contains the rotated rectangle * @returns A DOMRectInit object representing the bounds */ getBounds(): Required; /** Mutate multiple properties at once efficiently */ update(updates: Partial): void; /** Create a new instance with modified properties */ with(updates: Partial): RotatedDOMRect; } export class RotatedDOMRect implements IRotatedDOMRect { private _center: Point = { x: 0, y: 0 }; private _width: number = 0; private _height: number = 0; private _rotation: number = 0; // Cached derived values private _sinR: number | null = null; private _cosR: number | null = null; private _topLeftX: number | null = null; private _topLeftY: number | null = null; private _topRightX: number | null = null; private _topRightY: number | null = null; private _bottomLeftX: number | null = null; private _bottomLeftY: number | null = null; private _bottomRightX: number | null = null; private _bottomRightY: number | null = null; constructor({ x, y, width, height, rotation }: RotatedDOMRectInit = {}) { this._center = { x: x ?? 0, y: y ?? 0 }; this._width = width ?? 0; this._height = height ?? 0; this._rotation = rotation ?? 0; } /* ——— Getters ——— */ get center(): Readonly { return this._center; } get x(): number { return this._center.x; } get y(): number { return this._center.y; } get width(): number { return this._width; } get height(): number { return this._height; } get rotation(): number { return this._rotation; } get topLeft(): Readonly { this.deriveValuesIfCacheInvalid(); return { x: this._topLeftX!, y: this._topLeftY! }; } get topRight(): Readonly { this.deriveValuesIfCacheInvalid(); return { x: this._topRightX!, y: this._topRightY! }; } get bottomLeft(): Readonly { this.deriveValuesIfCacheInvalid(); return { x: this._bottomLeftX!, y: this._bottomLeftY! }; } get bottomRight(): Readonly { this.deriveValuesIfCacheInvalid(); return { x: this._bottomRightX!, y: this._bottomRightY! }; } /* ——— Setters ——— */ set center(point: Point) { this._center = point; this.invalidateCache(); } set x(value: number) { this._center.x = value; this.invalidateCache(); } set y(value: number) { this._center.y = value; this.invalidateCache(); } set width(value: number) { this._width = value; this.invalidateCache(); } set height(value: number) { this._height = value; this.invalidateCache(); } set rotation(value: number) { this._rotation = value; this.invalidateCache(); } set topLeft(point: Point) { this.moveCorner(point, this.bottomRight); } set topRight(point: Point) { this.moveCorner(point, this.bottomLeft); } set bottomLeft(point: Point) { this.moveCorner(point, this.topRight); } set bottomRight(point: Point) { this.moveCorner(point, this.topLeft); } getBounds(): Required { if (!this.isCacheValid()) { this.deriveValuesIfCacheInvalid(); } const minX = Math.min(this._topLeftX!, this._topRightX!, this._bottomLeftX!, this._bottomRightX!); const maxX = Math.max(this._topLeftX!, this._topRightX!, this._bottomLeftX!, this._bottomRightX!); const minY = Math.min(this._topLeftY!, this._topRightY!, this._bottomLeftY!, this._bottomRightY!); const maxY = Math.max(this._topLeftY!, this._topRightY!, this._bottomLeftY!, this._bottomRightY!); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; } /** Mutate multiple properties at once efficiently */ update(updates: Partial) { if (updates.x !== undefined) this._center.x = updates.x; if (updates.y !== undefined) this._center.y = updates.y; if (updates.width !== undefined) this._width = updates.width; if (updates.height !== undefined) this._height = updates.height; if (updates.rotation !== undefined) this._rotation = updates.rotation; this.invalidateCache(); } /** Create a new instance with modified properties */ with(updates: Partial): RotatedDOMRect { return new RotatedDOMRect({ x: updates.x ?? this._center.x, y: updates.y ?? this._center.y, width: updates.width ?? this._width, height: updates.height ?? this._height, rotation: updates.rotation ?? this._rotation, }); } /* ——— Private methods ——— */ private moveCorner(newCorner: Point, oppositeCorner: Point) { // Calculate new center midway between the two corners this._center = Vector.lerp(newCorner, oppositeCorner, 0.5); // Get vector from opposite corner to new corner const delta = Vector.sub(newCorner, oppositeCorner); // Rotate the delta vector back by -rotation to get width/height const rotated = Vector.rotate(delta, -this._rotation); // Update width and height with absolute values this._width = Math.abs(rotated.x); this._height = Math.abs(rotated.y); // Invalidate cache to ensure all derived values are recalculated this.invalidateCache(); } private getTrigValues(): { sin: number; cos: number } { if (this._sinR === null || this._cosR === null) { this._sinR = Math.sin(this._rotation); this._cosR = Math.cos(this._rotation); } return { sin: this._sinR, cos: this._cosR }; } private deriveValuesIfCacheInvalid() { if (this.isCacheValid()) return; const halfWidth = this._width / 2; const halfHeight = this._height / 2; const { sin, cos } = this.getTrigValues(); this._topLeftX = this._center.x - halfWidth * cos + halfHeight * sin; this._topLeftY = this._center.y - halfWidth * sin - halfHeight * cos; this._topRightX = this._center.x + halfWidth * cos + halfHeight * sin; this._topRightY = this._center.y + halfWidth * sin - halfHeight * cos; this._bottomLeftX = this._center.x - halfWidth * cos - halfHeight * sin; this._bottomLeftY = this._center.y - halfWidth * sin + halfHeight * cos; this._bottomRightX = this._center.x + halfWidth * cos - halfHeight * sin; this._bottomRightY = this._center.y + halfWidth * sin + halfHeight * cos; } private invalidateCache() { this._sinR = null; this._cosR = null; this._topLeftX = null; this._topLeftY = null; this._topRightX = null; this._topRightY = null; this._bottomLeftX = null; this._bottomLeftY = null; this._bottomRightX = null; this._bottomRightY = null; } /** * Checks if the cached corner coordinates are valid. * Type assertion is safe because invalidateCache() clears all values atomically, * and calculateCorners() sets all values atomically. */ private isCacheValid(): this is { _topLeftX: number; _topLeftY: number; _topRightX: number; _topRightY: number; _bottomLeftX: number; _bottomLeftY: number; _bottomRightX: number; _bottomRightY: number; _sinR: number; _cosR: number; } { return this._topLeftX !== null; } }