import { BoxModel } from '@tldraw/tlschema' import { Vec, VecLike } from './Vec' import { PI, PI2, toPrecision } from './utils' /** @public */ export type BoxLike = BoxModel | Box /** @public */ export type SelectionEdge = 'top' | 'right' | 'bottom' | 'left' /** @public */ export type SelectionCorner = 'top_left' | 'top_right' | 'bottom_right' | 'bottom_left' /** @public */ export type SelectionHandle = SelectionEdge | SelectionCorner /** @public */ export type RotateCorner = | 'top_left_rotate' | 'top_right_rotate' | 'bottom_right_rotate' | 'bottom_left_rotate' | 'mobile_rotate' /** @public */ export class Box { constructor(x = 0, y = 0, w = 0, h = 0) { this.x = x this.y = y this.w = w this.h = h } x = 0 y = 0 w = 0 h = 0 // eslint-disable-next-line no-restricted-syntax get point() { return new Vec(this.x, this.y) } // eslint-disable-next-line no-restricted-syntax set point(val: Vec) { this.x = val.x this.y = val.y } // eslint-disable-next-line no-restricted-syntax get minX() { return this.x } // eslint-disable-next-line no-restricted-syntax set minX(n: number) { this.x = n } // eslint-disable-next-line no-restricted-syntax get midX() { return this.x + this.w / 2 } // eslint-disable-next-line no-restricted-syntax get maxX() { return this.x + this.w } // eslint-disable-next-line no-restricted-syntax get minY() { return this.y } // eslint-disable-next-line no-restricted-syntax set minY(n: number) { this.y = n } // eslint-disable-next-line no-restricted-syntax get midY() { return this.y + this.h / 2 } // eslint-disable-next-line no-restricted-syntax get maxY() { return this.y + this.h } // eslint-disable-next-line no-restricted-syntax get width() { return this.w } // eslint-disable-next-line no-restricted-syntax set width(n: number) { this.w = n } // eslint-disable-next-line no-restricted-syntax get height() { return this.h } // eslint-disable-next-line no-restricted-syntax set height(n: number) { this.h = n } // eslint-disable-next-line no-restricted-syntax get aspectRatio() { return this.width / this.height } // eslint-disable-next-line no-restricted-syntax get center() { return new Vec(this.midX, this.midY) } // eslint-disable-next-line no-restricted-syntax set center(v: Vec) { this.minX = v.x - this.width / 2 this.minY = v.y - this.height / 2 } // eslint-disable-next-line no-restricted-syntax get corners() { return [ new Vec(this.minX, this.minY), new Vec(this.maxX, this.minY), new Vec(this.maxX, this.maxY), new Vec(this.minX, this.maxY), ] } // eslint-disable-next-line no-restricted-syntax get cornersAndCenter() { return [ new Vec(this.minX, this.minY), new Vec(this.maxX, this.minY), new Vec(this.maxX, this.maxY), new Vec(this.minX, this.maxY), this.center, ] } // eslint-disable-next-line no-restricted-syntax get sides(): Array<[Vec, Vec]> { const { corners } = this return [ [corners[0], corners[1]], [corners[1], corners[2]], [corners[2], corners[3]], [corners[3], corners[0]], ] } // eslint-disable-next-line no-restricted-syntax get size(): Vec { return new Vec(this.w, this.h) } toFixed() { this.x = toPrecision(this.x) this.y = toPrecision(this.y) this.w = toPrecision(this.w) this.h = toPrecision(this.h) return this } setTo(B: Box) { this.x = B.x this.y = B.y this.w = B.w this.h = B.h return this } set(x = 0, y = 0, w = 0, h = 0) { this.x = x this.y = y this.w = w this.h = h return this } expand(A: Box) { const minX = Math.min(this.minX, A.minX) const minY = Math.min(this.minY, A.minY) const maxX = Math.max(this.maxX, A.maxX) const maxY = Math.max(this.maxY, A.maxY) this.x = minX this.y = minY this.w = maxX - minX this.h = maxY - minY return this } expandBy(n: number) { this.x -= n this.y -= n this.w += n * 2 this.h += n * 2 return this } scale(n: number) { this.x /= n this.y /= n this.w /= n this.h /= n return this } clone() { const { x, y, w, h } = this return new Box(x, y, w, h) } translate(delta: VecLike) { this.x += delta.x this.y += delta.y return this } snapToGrid(size: number) { const minX = Math.round(this.minX / size) * size const minY = Math.round(this.minY / size) * size const maxX = Math.round(this.maxX / size) * size const maxY = Math.round(this.maxY / size) * size this.minX = minX this.minY = minY this.width = Math.max(1, maxX - minX) this.height = Math.max(1, maxY - minY) } collides(B: Box) { return Box.Collides(this, B) } contains(B: Box) { return Box.Contains(this, B) } includes(B: Box) { return Box.Includes(this, B) } containsPoint(V: VecLike, margin = 0) { return Box.ContainsPoint(this, V, margin) } getHandlePoint(handle: SelectionCorner | SelectionEdge) { switch (handle) { case 'top_left': return new Vec(this.minX, this.minY) case 'top_right': return new Vec(this.maxX, this.minY) case 'bottom_left': return new Vec(this.minX, this.maxY) case 'bottom_right': return new Vec(this.maxX, this.maxY) case 'top': return new Vec(this.midX, this.minY) case 'right': return new Vec(this.maxX, this.midY) case 'bottom': return new Vec(this.midX, this.maxY) case 'left': return new Vec(this.minX, this.midY) } } toJson(): BoxModel { return { x: this.minX, y: this.minY, w: this.w, h: this.h } } resize(handle: SelectionCorner | SelectionEdge | string, dx: number, dy: number) { const { minX: a0x, minY: a0y, maxX: a1x, maxY: a1y } = this let { minX: b0x, minY: b0y, maxX: b1x, maxY: b1y } = this // Use the delta to adjust the new box by changing its corners. // The dragging handle (corner or edge) will determine which // corners should change. switch (handle) { case 'left': case 'top_left': case 'bottom_left': { b0x += dx break } case 'right': case 'top_right': case 'bottom_right': { b1x += dx break } } switch (handle) { case 'top': case 'top_left': case 'top_right': { b0y += dy break } case 'bottom': case 'bottom_left': case 'bottom_right': { b1y += dy break } } const scaleX = (b1x - b0x) / (a1x - a0x) const scaleY = (b1y - b0y) / (a1y - a0y) const flipX = scaleX < 0 const flipY = scaleY < 0 if (flipX) { const t = b1x b1x = b0x b0x = t } if (flipY) { const t = b1y b1y = b0y b0y = t } this.minX = b0x this.minY = b0y this.width = Math.abs(b1x - b0x) this.height = Math.abs(b1y - b0y) } union(box: BoxModel) { const minX = Math.min(this.minX, box.x) const minY = Math.min(this.minY, box.y) const maxX = Math.max(this.maxX, box.w + box.x) const maxY = Math.max(this.maxY, box.h + box.y) this.x = minX this.y = minY this.width = maxX - minX this.height = maxY - minY return this } static From(box: BoxModel) { return new Box(box.x, box.y, box.w, box.h) } static FromCenter(center: VecLike, size: VecLike) { return new Box(center.x - size.x / 2, center.y - size.y / 2, size.x, size.y) } static FromPoints(points: VecLike[]) { if (points.length === 0) return new Box() let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity let point: VecLike for (let i = 0, n = points.length; i < n; i++) { point = points[i] minX = Math.min(point.x, minX) minY = Math.min(point.y, minY) maxX = Math.max(point.x, maxX) maxY = Math.max(point.y, maxY) } return new Box(minX, minY, maxX - minX, maxY - minY) } static AroundPoint(point: VecLike, n: number) { return new Box(point.x - n, point.y - n, 2 * n, 2 * n) } static Expand(A: Box, B: Box) { const minX = Math.min(B.minX, A.minX) const minY = Math.min(B.minY, A.minY) const maxX = Math.max(B.maxX, A.maxX) const maxY = Math.max(B.maxY, A.maxY) return new Box(minX, minY, maxX - minX, maxY - minY) } static ExpandBy(A: Box, n: number) { return new Box(A.minX - n, A.minY - n, A.width + n * 2, A.height + n * 2) } static Collides = (A: Box, B: Box) => { return !(A.maxX < B.minX || A.minX > B.maxX || A.maxY < B.minY || A.minY > B.maxY) } static Contains = (A: Box, B: Box) => { return A.minX < B.minX && A.minY < B.minY && A.maxY > B.maxY && A.maxX > B.maxX } static Includes = (A: Box, B: Box) => { return Box.Collides(A, B) || Box.Contains(A, B) } static ContainsPoint = (A: Box, B: VecLike, margin = 0) => { return !( B.x < A.minX - margin || B.y < A.minY - margin || B.x > A.maxX + margin || B.y > A.maxY + margin ) } static Common = (boxes: Box[]): Box => { let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity for (let i = 0; i < boxes.length; i++) { const B = boxes[i] minX = Math.min(minX, B.minX) minY = Math.min(minY, B.minY) maxX = Math.max(maxX, B.maxX) maxY = Math.max(maxY, B.maxY) } return new Box(minX, minY, maxX - minX, maxY - minY) } static Sides = (A: Box, inset = 0) => { const { corners } = A if (inset) { // TODO: Inset the corners by the inset amount. } return [ [corners[0], corners[1]], [corners[1], corners[2]], [corners[2], corners[3]], [corners[3], corners[0]], ] } static Resize( box: Box, handle: SelectionCorner | SelectionEdge | string, dx: number, dy: number, isAspectRatioLocked = false ) { const { minX: a0x, minY: a0y, maxX: a1x, maxY: a1y } = box let { minX: b0x, minY: b0y, maxX: b1x, maxY: b1y } = box // Use the delta to adjust the new box by changing its corners. // The dragging handle (corner or edge) will determine which // corners should change. switch (handle) { case 'left': case 'top_left': case 'bottom_left': { b0x += dx break } case 'right': case 'top_right': case 'bottom_right': { b1x += dx break } } switch (handle) { case 'top': case 'top_left': case 'top_right': { b0y += dy break } case 'bottom': case 'bottom_left': case 'bottom_right': { b1y += dy break } } const scaleX = (b1x - b0x) / (a1x - a0x) const scaleY = (b1y - b0y) / (a1y - a0y) const flipX = scaleX < 0 const flipY = scaleY < 0 /* 2. Aspect ratio If the aspect ratio is locked, adjust the corners so that the new box's aspect ratio matches the original aspect ratio. */ if (isAspectRatioLocked) { const aspectRatio = (a1x - a0x) / (a1y - a0y) const bw = Math.abs(b1x - b0x) const bh = Math.abs(b1y - b0y) const tw = bw * (scaleY < 0 ? 1 : -1) * (1 / aspectRatio) const th = bh * (scaleX < 0 ? 1 : -1) * aspectRatio const isTall = aspectRatio < bw / bh switch (handle) { case 'top_left': { if (isTall) b0y = b1y + tw else b0x = b1x + th break } case 'top_right': { if (isTall) b0y = b1y + tw else b1x = b0x - th break } case 'bottom_right': { if (isTall) b1y = b0y - tw else b1x = b0x - th break } case 'bottom_left': { if (isTall) b1y = b0y - tw else b0x = b1x + th break } case 'bottom': case 'top': { const m = (b0x + b1x) / 2 const w = bh * aspectRatio b0x = m - w / 2 b1x = m + w / 2 break } case 'left': case 'right': { const m = (b0y + b1y) / 2 const h = bw / aspectRatio b0y = m - h / 2 b1y = m + h / 2 break } } } if (flipX) { const t = b1x b1x = b0x b0x = t } if (flipY) { const t = b1y b1y = b0y b0y = t } const final = new Box(b0x, b0y, Math.abs(b1x - b0x), Math.abs(b1y - b0y)) return { box: final, scaleX: +((final.width / box.width) * (scaleX > 0 ? 1 : -1)).toFixed(5), scaleY: +((final.height / box.height) * (scaleY > 0 ? 1 : -1)).toFixed(5), } } equals(other: Box | BoxModel) { return Box.Equals(this, other) } static Equals(a: Box | BoxModel, b: Box | BoxModel) { return b.x === a.x && b.y === a.y && b.w === a.w && b.h === a.h } zeroFix() { this.w = Math.max(1, this.w) this.h = Math.max(1, this.h) return this } static ZeroFix(other: Box | BoxModel) { return new Box(other.x, other.y, Math.max(1, other.w), Math.max(1, other.h)) } } /** @public */ export function flipSelectionHandleY(handle: SelectionHandle) { switch (handle) { case 'top': return 'bottom' case 'bottom': return 'top' case 'top_left': return 'bottom_left' case 'top_right': return 'bottom_right' case 'bottom_left': return 'top_left' case 'bottom_right': return 'top_right' default: return handle } } /** @public */ export function flipSelectionHandleX(handle: SelectionHandle) { switch (handle) { case 'left': return 'right' case 'right': return 'left' case 'top_left': return 'top_right' case 'top_right': return 'top_left' case 'bottom_left': return 'bottom_right' case 'bottom_right': return 'bottom_left' default: return handle } } const ORDERED_SELECTION_HANDLES = [ 'top', 'top_right', 'right', 'bottom_right', 'bottom', 'bottom_left', 'left', 'top_left', ] as const /** @public */ export function rotateSelectionHandle(handle: SelectionHandle, rotation: number): SelectionHandle { // first find out how many tau we need to rotate by rotation = rotation % PI2 const numSteps = Math.round(rotation / (PI / 4)) const currentIndex = ORDERED_SELECTION_HANDLES.indexOf(handle) return ORDERED_SELECTION_HANDLES[(currentIndex + numSteps) % ORDERED_SELECTION_HANDLES.length] } /** @public */ export function isSelectionCorner(selection: string): selection is SelectionCorner { return ( selection === 'top_left' || selection === 'top_right' || selection === 'bottom_right' || selection === 'bottom_left' ) } /** @public */ export const ROTATE_CORNER_TO_SELECTION_CORNER = { top_left_rotate: 'top_left', top_right_rotate: 'top_right', bottom_right_rotate: 'bottom_right', bottom_left_rotate: 'bottom_left', mobile_rotate: 'top_left', } as const