UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

413 lines (412 loc) • 13.6 kB
import { assert, invLerp } from "@tldraw/utils"; import { Box } from "../Box.mjs"; import { intersectCirclePolygon, intersectCirclePolyline, intersectLineSegmentPolygon, intersectLineSegmentPolyline, intersectPolys, linesIntersect, polygonIntersectsPolyline, polygonsIntersect } from "../intersect.mjs"; import { Mat } from "../Mat.mjs"; import { approximately, pointInPolygon } from "../utils.mjs"; import { Vec } from "../Vec.mjs"; const 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 } }; 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; excludeFromShapeBounds = false; debugColor; ignore; constructor(opts) { const { isLabel = false, isEmptyLabel = false, isInternal = false, excludeFromShapeBounds = 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; this.excludeFromShapeBounds = excludeFromShapeBounds; } isExcludedByFilter(filters) { if (!filters) return false; if (this.isLabel && !filters.includeLabels) return true; if (this.isInternal && !filters.includeInternal) return true; return false; } hitTestPoint(point, margin = 0, hitInside = false, _filters) { if (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)) { return true; } return Vec.Dist2(point, this.nearestPoint(point)) <= margin * margin; } distanceToPoint(point, hitInside = false, filters) { return Vec.Dist(point, this.nearestPoint(point, filters)) * (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices) ? -1 : 1); } distanceToLineSegment(A, B, filters) { if (Vec.Equals(A, B)) return this.distanceToPoint(A, false, filters); const { vertices } = this; if (vertices.length === 0) throw Error("nearest point not found"); if (vertices.length === 1) return Vec.Dist(A, vertices[0]); let nearest; let dist = Infinity; let d, p, q; 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"); dist = Math.sqrt(dist); return this.isClosed && this.isFilled && pointInPolygon(nearest, this.vertices) ? -dist : dist; } hitTestLineSegment(A, B, distance = 0, filters) { return this.distanceToLineSegment(A, B, filters) <= distance; } intersectLineSegment(A, B, _filters) { const intersections = this.isClosed ? intersectLineSegmentPolygon(A, B, this.vertices) : intersectLineSegmentPolyline(A, B, this.vertices); return intersections ?? []; } intersectCircle(center, radius, _filters) { const intersections = this.isClosed ? intersectCirclePolygon(center, radius, this.vertices) : intersectCirclePolyline(center, radius, this.vertices); return intersections ?? []; } intersectPolygon(polygon, _filters) { return intersectPolys(polygon, this.vertices, true, this.isClosed); } intersectPolyline(polyline, _filters) { 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, _filters) { const { vertices } = this; if (vertices.length === 0) return new Vec(0, 0); if (vertices.length === 1) return vertices[0]; 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) { if (dist === 0) return curr; 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, _filters) { const { vertices, length } = this; let closestSegment = null; let closestDistance = Infinity; let distanceTraveled = 0; if (vertices.length === 0 || vertices.length === 1) return 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 length === 0 ? 0 : distanceAlongRoute / length; } isPointInBounds(point, 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); } overlapsPolygon(_polygon) { const polygon = _polygon.map((v) => Vec.From(v)); const { vertices, center, isFilled, isEmptyLabel, isClosed } = this; if (isEmptyLabel) return false; if (vertices.some((v) => pointInPolygon(v, polygon))) { return true; } if (isClosed) { if (isFilled) { if (pointInPolygon(center, polygon)) { return true; } if (polygon.every((v) => pointInPolygon(v, vertices))) { return true; } } if (polygonsIntersect(polygon, vertices)) { return true; } } else { if (polygonIntersectsPolyline(polygon, vertices)) { return true; } } return false; } transform(transform, opts) { return new TransformedGeometry2d(this, transform, opts); } _vertices; // eslint-disable-next-line tldraw/no-setter-getter get vertices() { if (!this._vertices) { this._vertices = this.getVertices(Geometry2dFilters.EXCLUDE_LABELS); } return this._vertices; } getBoundsVertices() { if (this.excludeFromShapeBounds) return []; return this.vertices; } _boundsVertices; // eslint-disable-next-line tldraw/no-setter-getter get boundsVertices() { if (!this._boundsVertices) { this._boundsVertices = this.getBoundsVertices(); } return this._boundsVertices; } getBounds() { return Box.FromPoints(this.boundsVertices); } _bounds; // eslint-disable-next-line tldraw/no-setter-getter get bounds() { if (!this._bounds) { this._bounds = this.getBounds(); } return this._bounds; } // eslint-disable-next-line tldraw/no-setter-getter get center() { return this.bounds.center; } _area; // eslint-disable-next-line tldraw/no-setter-getter 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; } _length; // eslint-disable-next-line tldraw/no-setter-getter get length() { if (this._length) return this._length; this._length = this.getLength(Geometry2dFilters.EXCLUDE_LABELS); return this._length; } getLength(_filters) { 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; } /** * Called after a hit test succeeds. Return `true` to reject the hit and allow * shapes behind this one to be selected instead (e.g. transparent image pixels). */ ignoreHit(_point) { return false; } } class TransformedGeometry2d extends Geometry2d { constructor(geometry, matrix, opts) { super(geometry); this.geometry = geometry; this.matrix = matrix; 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" ); } geometry; matrix; inverse; decomposed; getVertices(filters) { return this.geometry.getVertices(filters).map((v) => Mat.applyToPoint(this.matrix, v)); } getBoundsVertices() { return this.geometry.getBoundsVertices().map((v) => Mat.applyToPoint(this.matrix, v)); } nearestPoint(point, filters) { return Mat.applyToPoint( this.matrix, this.geometry.nearestPoint(Mat.applyToPoint(this.inverse, point), filters) ); } hitTestPoint(point, margin = 0, hitInside, filters) { return this.geometry.hitTestPoint( Mat.applyToPoint(this.inverse, point), margin / this.decomposed.scaleX, hitInside, filters ); } distanceToPoint(point, hitInside = false, filters) { return this.geometry.distanceToPoint(Mat.applyToPoint(this.inverse, point), hitInside, filters) * this.decomposed.scaleX; } distanceToLineSegment(A, B, filters) { return this.geometry.distanceToLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), filters ) * this.decomposed.scaleX; } hitTestLineSegment(A, B, distance = 0, filters) { return this.geometry.hitTestLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), distance / this.decomposed.scaleX, filters ); } intersectLineSegment(A, B, filters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectLineSegment( Mat.applyToPoint(this.inverse, A), Mat.applyToPoint(this.inverse, B), filters ) ); } intersectCircle(center, radius, filters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectCircle( Mat.applyToPoint(this.inverse, center), radius / this.decomposed.scaleX, filters ) ); } intersectPolygon(polygon, filters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectPolygon(Mat.applyToPoints(this.inverse, polygon), filters) ); } intersectPolyline(polyline, filters) { return Mat.applyToPoints( this.matrix, this.geometry.intersectPolyline(Mat.applyToPoints(this.inverse, polyline), filters) ); } ignoreHit(point) { return this.geometry.ignoreHit(Mat.applyToPoint(this.inverse, point)); } transform(transform, opts) { 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() { throw new Error("Cannot get SVG path data for transformed geometry."); } } export { Geometry2d, Geometry2dFilters, TransformedGeometry2d }; //# sourceMappingURL=Geometry2d.mjs.map