UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

503 lines (429 loc) • 14 kB
import { assert, invLerp } from '@tldraw/utils' import { Box } from '../Box' import { Mat, MatModel } from '../Mat' import { Vec, VecLike } from '../Vec' import { intersectCirclePolygon, intersectCirclePolyline, intersectLineSegmentPolygon, intersectLineSegmentPolyline, intersectPolys, linesIntersect, } from '../intersect' import { approximately, pointInPolygon } from '../utils' /** * Filter geometry within a group. * * Filters are ignored when called directly on primitive geometries, but can be used to narrow down * the results of an operation on `Group2d` geometries. * * @public */ export interface Geometry2dFilters { readonly includeLabels?: boolean readonly includeInternal?: boolean } /** @public */ export const Geometry2dFilters: { EXCLUDE_NON_STANDARD: Geometry2dFilters INCLUDE_ALL: Geometry2dFilters EXCLUDE_LABELS: Geometry2dFilters EXCLUDE_INTERNAL: Geometry2dFilters } = { EXCLUDE_NON_STANDARD: { includeLabels: false, includeInternal: false, }, INCLUDE_ALL: { includeLabels: true, includeInternal: true }, EXCLUDE_LABELS: { includeLabels: false, includeInternal: true }, EXCLUDE_INTERNAL: { includeLabels: true, includeInternal: false }, } /** @public */ export interface TransformedGeometry2dOptions { isLabel?: boolean isEmptyLabel?: boolean isInternal?: boolean debugColor?: string ignore?: boolean } /** @public */ export interface Geometry2dOptions extends TransformedGeometry2dOptions { isFilled: boolean isClosed: boolean } /** @public */ export abstract class Geometry2d { // todo: consider making accessors for these too, so that they can be overridden in subclasses by geometries with more complex logic isFilled = false isClosed = true isLabel = false isEmptyLabel = false isInternal = false debugColor?: string ignore?: boolean constructor(opts: Geometry2dOptions) { const { isLabel = false, isEmptyLabel = false, isInternal = false } = opts this.isFilled = opts.isFilled this.isClosed = opts.isClosed this.debugColor = opts.debugColor this.ignore = opts.ignore this.isLabel = isLabel this.isEmptyLabel = isEmptyLabel this.isInternal = isInternal } isExcludedByFilter(filters?: Geometry2dFilters) { if (!filters) return false if (this.isLabel && !filters.includeLabels) return true if (this.isInternal && !filters.includeInternal) return true return false } abstract getVertices(filters: Geometry2dFilters): Vec[] abstract nearestPoint(point: VecLike, _filters?: Geometry2dFilters): Vec hitTestPoint(point: VecLike, margin = 0, hitInside = false, _filters?: Geometry2dFilters) { // First check whether the point is inside if (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)) { return true } // Then check whether the distance is within the margin return Vec.Dist2(point, this.nearestPoint(point)) <= margin * margin } distanceToPoint(point: VecLike, hitInside = false, filters?: Geometry2dFilters) { return ( Vec.Dist(point, this.nearestPoint(point, filters)) * (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices) ? -1 : 1) ) } distanceToLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) { if (Vec.Equals(A, B)) return this.distanceToPoint(A, false, filters) const { vertices } = this let nearest: Vec | undefined let dist = Infinity let d: number, p: Vec, q: Vec const nextLimit = this.isClosed ? vertices.length : vertices.length - 1 for (let i = 0; i < vertices.length; i++) { p = vertices[i] if (i < nextLimit) { const next = vertices[(i + 1) % vertices.length] if (linesIntersect(A, B, p, next)) return 0 } q = Vec.NearestPointOnLineSegment(A, B, p, true) d = Vec.Dist2(p, q) if (d < dist) { dist = d nearest = q } } if (!nearest) throw Error('nearest point not found') return this.isClosed && this.isFilled && pointInPolygon(nearest, this.vertices) ? -dist : dist } hitTestLineSegment(A: VecLike, B: VecLike, distance = 0, filters?: Geometry2dFilters): boolean { return this.distanceToLineSegment(A, B, filters) <= distance } intersectLineSegment(A: VecLike, B: VecLike, _filters?: Geometry2dFilters): VecLike[] { const intersections = this.isClosed ? intersectLineSegmentPolygon(A, B, this.vertices) : intersectLineSegmentPolyline(A, B, this.vertices) return intersections ?? [] } intersectCircle(center: VecLike, radius: number, _filters?: Geometry2dFilters): VecLike[] { const intersections = this.isClosed ? intersectCirclePolygon(center, radius, this.vertices) : intersectCirclePolyline(center, radius, this.vertices) return intersections ?? [] } intersectPolygon(polygon: VecLike[], _filters?: Geometry2dFilters): VecLike[] { return intersectPolys(polygon, this.vertices, true, this.isClosed) } intersectPolyline(polyline: VecLike[], _filters?: Geometry2dFilters): VecLike[] { return intersectPolys(polyline, this.vertices, false, this.isClosed) } /** * Find a point along the edge of the geometry that is a fraction `t` along the entire way round. */ interpolateAlongEdge(t: number, _filters?: Geometry2dFilters): Vec { const { vertices } = this if (t <= 0) return vertices[0] const distanceToTravel = t * this.length let distanceTraveled = 0 for (let i = 0; i < (this.isClosed ? vertices.length : vertices.length - 1); i++) { const curr = vertices[i] const next = vertices[(i + 1) % vertices.length] const dist = Vec.Dist(curr, next) const newDistanceTraveled = distanceTraveled + dist if (newDistanceTraveled >= distanceToTravel) { const p = Vec.Lrp( curr, next, invLerp(distanceTraveled, newDistanceTraveled, distanceToTravel) ) return p } distanceTraveled = newDistanceTraveled } return this.isClosed ? vertices[0] : vertices[vertices.length - 1] } /** * Take `point`, find the closest point to it on the edge of the geometry, and return how far * along the edge it is as a fraction of the total length. */ uninterpolateAlongEdge(point: VecLike, _filters?: Geometry2dFilters): number { const { vertices, length } = this let closestSegment = null let closestDistance = Infinity let distanceTraveled = 0 for (let i = 0; i < (this.isClosed ? vertices.length : vertices.length - 1); i++) { const curr = vertices[i] const next = vertices[(i + 1) % vertices.length] const nearestPoint = Vec.NearestPointOnLineSegment(curr, next, point, true) const distance = Vec.Dist(nearestPoint, point) if (distance < closestDistance) { closestDistance = distance closestSegment = { start: curr, end: next, nearestPoint, distanceToStart: distanceTraveled, } } distanceTraveled += Vec.Dist(curr, next) } assert(closestSegment) const distanceAlongRoute = closestSegment.distanceToStart + Vec.Dist(closestSegment.start, closestSegment.nearestPoint) return distanceAlongRoute / length } /** @deprecated Iterate the vertices instead. */ nearestPointOnLineSegment(A: VecLike, B: VecLike): Vec { const { vertices } = this let nearest: Vec | undefined let dist = Infinity let d: number, p: Vec, q: Vec for (let i = 0; i < vertices.length; i++) { p = vertices[i] q = Vec.NearestPointOnLineSegment(A, B, p, true) d = Vec.Dist2(p, q) if (d < dist) { dist = d nearest = q } } if (!nearest) throw Error('nearest point not found') return nearest } isPointInBounds(point: VecLike, margin = 0) { const { bounds } = this return !( point.x < bounds.minX - margin || point.y < bounds.minY - margin || point.x > bounds.maxX + margin || point.y > bounds.maxY + margin ) } transform(transform: MatModel, opts?: TransformedGeometry2dOptions): Geometry2d { return new TransformedGeometry2d(this, transform, opts) } private _vertices: Vec[] | undefined // eslint-disable-next-line no-restricted-syntax get vertices(): Vec[] { if (!this._vertices) { this._vertices = this.getVertices(Geometry2dFilters.EXCLUDE_LABELS) } return this._vertices } getBounds() { return Box.FromPoints(this.vertices) } private _bounds: Box | undefined // eslint-disable-next-line no-restricted-syntax get bounds(): Box { if (!this._bounds) { this._bounds = this.getBounds() } return this._bounds } // eslint-disable-next-line no-restricted-syntax get center() { return this.bounds.center } private _area: number | undefined // eslint-disable-next-line no-restricted-syntax get area() { if (!this._area) { this._area = this.getArea() } return this._area } getArea() { if (!this.isClosed) { return 0 } const { vertices } = this let area = 0 for (let i = 0, n = vertices.length; i < n; i++) { const curr = vertices[i] const next = vertices[(i + 1) % n] area += curr.x * next.y - next.x * curr.y } return area / 2 } toSimpleSvgPath() { let path = '' const { vertices } = this const n = vertices.length if (n === 0) return path path += `M${vertices[0].x},${vertices[0].y}` for (let i = 1; i < n; i++) { path += `L${vertices[i].x},${vertices[i].y}` } if (this.isClosed) { path += 'Z' } return path } private _length?: number // eslint-disable-next-line no-restricted-syntax get length() { if (this._length) return this._length this._length = this.getLength(Geometry2dFilters.EXCLUDE_LABELS) return this._length } getLength(_filters?: Geometry2dFilters) { const vertices = this.getVertices(_filters ?? Geometry2dFilters.EXCLUDE_LABELS) if (vertices.length === 0) return 0 let prev = vertices[0] let length = 0 for (let i = 1; i < vertices.length; i++) { const next = vertices[i] length += Vec.Dist(prev, next) prev = next } if (this.isClosed) { length += Vec.Dist(vertices[vertices.length - 1], vertices[0]) } return length } abstract getSvgPathData(first: boolean): string } // ================================================================================================= // Because Geometry2d.transform depends on TransformedGeometry2d, we need to define it here instead // of in its own files. This prevents a circular import error. // ================================================================================================= /** @public */ export class TransformedGeometry2d extends Geometry2d { private readonly inverse: MatModel private readonly decomposed constructor( private readonly geometry: Geometry2d, private readonly matrix: MatModel, opts?: TransformedGeometry2dOptions ) { super(geometry) this.inverse = Mat.Inverse(matrix) this.decomposed = Mat.Decompose(matrix) if (opts) { if (opts.isLabel != null) this.isLabel = opts.isLabel if (opts.isInternal != null) this.isInternal = opts.isInternal if (opts.debugColor != null) this.debugColor = opts.debugColor if (opts.ignore != null) this.ignore = opts.ignore } assert( approximately(this.decomposed.scaleX, this.decomposed.scaleY), 'non-uniform scaling is not yet supported' ) } getVertices(filters: Geometry2dFilters): Vec[] { return this.geometry.getVertices(filters).map((v) => Mat.applyToPoint(this.matrix, v)) } nearestPoint(point: VecLike, filters?: Geometry2dFilters): Vec { return Mat.applyToPoint( this.matrix, this.geometry.nearestPoint(Mat.applyToPoint(this.inverse, point), filters) ) } override hitTestPoint( point: VecLike, margin = 0, hitInside?: boolean, filters?: Geometry2dFilters ): boolean { return this.geometry.hitTestPoint( Mat.applyToPoint(this.inverse, point), margin / this.decomposed.scaleX, hitInside, filters ) } override distanceToPoint(point: VecLike, hitInside = false, filters?: Geometry2dFilters) { return ( this.geometry.distanceToPoint(Mat.applyToPoint(this.inverse, point), hitInside, filters) * this.decomposed.scaleX ) } override distanceToLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) { return ( this.geometry.distanceToLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), filters ) * this.decomposed.scaleX ) } override hitTestLineSegment( A: VecLike, B: VecLike, distance = 0, filters?: Geometry2dFilters ): boolean { return this.geometry.hitTestLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), distance / this.decomposed.scaleX, filters ) } override intersectLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), filters ) ) } override intersectCircle(center: VecLike, radius: number, filters?: Geometry2dFilters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectCircle( Mat.applyToPoint(this.inverse, center), radius / this.decomposed.scaleX, filters ) ) } override intersectPolygon(polygon: VecLike[], filters?: Geometry2dFilters): VecLike[] { return Mat.applyToPoints( this.matrix, this.geometry.intersectPolygon(Mat.applyToPoints(this.inverse, polygon), filters) ) } override intersectPolyline(polyline: VecLike[], filters?: Geometry2dFilters): VecLike[] { return Mat.applyToPoints( this.matrix, this.geometry.intersectPolyline(Mat.applyToPoints(this.inverse, polyline), filters) ) } override transform(transform: MatModel, opts?: TransformedGeometry2dOptions): Geometry2d { return new TransformedGeometry2d(this.geometry, Mat.Multiply(transform, this.matrix), { isLabel: opts?.isLabel ?? this.isLabel, isInternal: opts?.isInternal ?? this.isInternal, debugColor: opts?.debugColor ?? this.debugColor, ignore: opts?.ignore ?? this.ignore, }) } getSvgPathData(): string { throw new Error('Cannot get SVG path data for transformed geometry.') } }