RotatedDOMRect 2, electric boogaloo
This commit is contained in:
parent
b850fc9704
commit
8508607b6d
|
|
@ -0,0 +1,288 @@
|
|||
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<Point>;
|
||||
/** 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<Point>;
|
||||
/** Top-right corner of the rotated rectangle */
|
||||
topRight: Readonly<Point>;
|
||||
/** Bottom-left corner of the rotated rectangle */
|
||||
bottomLeft: Readonly<Point>;
|
||||
/** Bottom-right corner of the rotated rectangle */
|
||||
bottomRight: Readonly<Point>;
|
||||
/**
|
||||
* Returns an axis-aligned bounding box that contains the rotated rectangle
|
||||
* @returns A DOMRectInit object representing the bounds
|
||||
*/
|
||||
getBounds(): Required<DOMRectInit>;
|
||||
|
||||
/** Mutate multiple properties at once efficiently */
|
||||
update(updates: Partial<RotatedDOMRectInit>): void;
|
||||
|
||||
/** Create a new instance with modified properties */
|
||||
with(updates: Partial<RotatedDOMRectInit>): 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<Point> {
|
||||
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<Point> {
|
||||
this.deriveValuesIfCacheInvalid();
|
||||
return { x: this._topLeftX!, y: this._topLeftY! };
|
||||
}
|
||||
|
||||
get topRight(): Readonly<Point> {
|
||||
this.deriveValuesIfCacheInvalid();
|
||||
return { x: this._topRightX!, y: this._topRightY! };
|
||||
}
|
||||
|
||||
get bottomLeft(): Readonly<Point> {
|
||||
this.deriveValuesIfCacheInvalid();
|
||||
return { x: this._bottomLeftX!, y: this._bottomLeftY! };
|
||||
}
|
||||
|
||||
get bottomRight(): Readonly<Point> {
|
||||
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<DOMRectInit> {
|
||||
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<RotatedDOMRectInit>) {
|
||||
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<RotatedDOMRectInit>): 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue