diff --git a/apps/examples/e2e/tests/test-shapes.spec.ts b/apps/examples/e2e/tests/test-shapes.spec.ts index 4d1a827fc..b1f7b7dff 100644 --- a/apps/examples/e2e/tests/test-shapes.spec.ts +++ b/apps/examples/e2e/tests/test-shapes.spec.ts @@ -19,9 +19,10 @@ const clickableShapeCreators = [ { tool: 'hexagon', shape: 'geo' }, // { tool: 'octagon', shape: 'geo' }, { tool: 'star', shape: 'geo' }, + { tool: 'heart', shape: 'geo' }, { tool: 'rhombus', shape: 'geo' }, { tool: 'oval', shape: 'geo' }, - { tool: 'trapezoid', shape: 'geo' }, + // { tool: 'trapezoid', shape: 'geo' }, { tool: 'arrow-right', shape: 'geo' }, { tool: 'arrow-left', shape: 'geo' }, { tool: 'arrow-up', shape: 'geo' }, @@ -47,7 +48,8 @@ const draggableShapeCreators = [ { tool: 'star', shape: 'geo' }, { tool: 'rhombus', shape: 'geo' }, { tool: 'oval', shape: 'geo' }, - { tool: 'trapezoid', shape: 'geo' }, + { tool: 'heart', shape: 'geo' }, + // { tool: 'trapezoid', shape: 'geo' }, { tool: 'arrow-right', shape: 'geo' }, { tool: 'arrow-left', shape: 'geo' }, { tool: 'arrow-up', shape: 'geo' }, diff --git a/assets/icons/icon/geo-heart.svg b/assets/icons/icon/geo-heart.svg new file mode 100644 index 000000000..1a015ffdb --- /dev/null +++ b/assets/icons/icon/geo-heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/assets/imports.js b/packages/assets/imports.js index 2f12c6252..8b42b83b5 100644 --- a/packages/assets/imports.js +++ b/packages/assets/imports.js @@ -92,6 +92,7 @@ import iconsGeoCheckBox from './icons/icon/geo-check-box.svg' import iconsGeoCloud from './icons/icon/geo-cloud.svg' import iconsGeoDiamond from './icons/icon/geo-diamond.svg' import iconsGeoEllipse from './icons/icon/geo-ellipse.svg' +import iconsGeoHeart from './icons/icon/geo-heart.svg' import iconsGeoHexagon from './icons/icon/geo-hexagon.svg' import iconsGeoOctagon from './icons/icon/geo-octagon.svg' import iconsGeoOval from './icons/icon/geo-oval.svg' @@ -283,6 +284,7 @@ export function getAssetUrlsByImport(opts) { 'geo-cloud': formatAssetUrl(iconsGeoCloud, opts), 'geo-diamond': formatAssetUrl(iconsGeoDiamond, opts), 'geo-ellipse': formatAssetUrl(iconsGeoEllipse, opts), + 'geo-heart': formatAssetUrl(iconsGeoHeart, opts), 'geo-hexagon': formatAssetUrl(iconsGeoHexagon, opts), 'geo-octagon': formatAssetUrl(iconsGeoOctagon, opts), 'geo-oval': formatAssetUrl(iconsGeoOval, opts), diff --git a/packages/assets/imports.vite.js b/packages/assets/imports.vite.js index ed5481cf1..e905a69de 100644 --- a/packages/assets/imports.vite.js +++ b/packages/assets/imports.vite.js @@ -92,6 +92,7 @@ import iconsGeoCheckBox from './icons/icon/geo-check-box.svg?url' import iconsGeoCloud from './icons/icon/geo-cloud.svg?url' import iconsGeoDiamond from './icons/icon/geo-diamond.svg?url' import iconsGeoEllipse from './icons/icon/geo-ellipse.svg?url' +import iconsGeoHeart from './icons/icon/geo-heart.svg?url' import iconsGeoHexagon from './icons/icon/geo-hexagon.svg?url' import iconsGeoOctagon from './icons/icon/geo-octagon.svg?url' import iconsGeoOval from './icons/icon/geo-oval.svg?url' @@ -283,6 +284,7 @@ export function getAssetUrlsByImport(opts) { 'geo-cloud': formatAssetUrl(iconsGeoCloud, opts), 'geo-diamond': formatAssetUrl(iconsGeoDiamond, opts), 'geo-ellipse': formatAssetUrl(iconsGeoEllipse, opts), + 'geo-heart': formatAssetUrl(iconsGeoHeart, opts), 'geo-hexagon': formatAssetUrl(iconsGeoHexagon, opts), 'geo-octagon': formatAssetUrl(iconsGeoOctagon, opts), 'geo-oval': formatAssetUrl(iconsGeoOval, opts), diff --git a/packages/assets/selfHosted.js b/packages/assets/selfHosted.js index 33e0eed3c..55f0bfa43 100644 --- a/packages/assets/selfHosted.js +++ b/packages/assets/selfHosted.js @@ -86,6 +86,7 @@ export function getAssetUrls(opts) { 'geo-cloud': formatAssetUrl('./icons/icon/geo-cloud.svg', opts), 'geo-diamond': formatAssetUrl('./icons/icon/geo-diamond.svg', opts), 'geo-ellipse': formatAssetUrl('./icons/icon/geo-ellipse.svg', opts), + 'geo-heart': formatAssetUrl('./icons/icon/geo-heart.svg', opts), 'geo-hexagon': formatAssetUrl('./icons/icon/geo-hexagon.svg', opts), 'geo-octagon': formatAssetUrl('./icons/icon/geo-octagon.svg', opts), 'geo-oval': formatAssetUrl('./icons/icon/geo-oval.svg', opts), diff --git a/packages/assets/types.d.ts b/packages/assets/types.d.ts index 8639d0856..4e04f4b74 100644 --- a/packages/assets/types.d.ts +++ b/packages/assets/types.d.ts @@ -76,6 +76,7 @@ export type AssetUrls = { 'geo-cloud': string 'geo-diamond': string 'geo-ellipse': string + 'geo-heart': string 'geo-hexagon': string 'geo-octagon': string 'geo-oval': string diff --git a/packages/assets/urls.js b/packages/assets/urls.js index 241c1b17d..3cdfc8de2 100644 --- a/packages/assets/urls.js +++ b/packages/assets/urls.js @@ -257,6 +257,10 @@ export function getAssetUrlsByMetaUrl(opts) { new URL('./icons/icon/geo-ellipse.svg', import.meta.url).href, opts ), + 'geo-heart': formatAssetUrl( + new URL('./icons/icon/geo-heart.svg', import.meta.url).href, + opts + ), 'geo-hexagon': formatAssetUrl( new URL('./icons/icon/geo-hexagon.svg', import.meta.url).href, opts diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 3c466dfb5..e15088226 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -112,7 +112,6 @@ export class Arc2d extends Geometry2d { center: Vec; end: Vec; largeArcFlag: number; - radius: number; start: Vec; sweepFlag: number; }); @@ -125,11 +124,15 @@ export class Arc2d extends Geometry2d { // (undocumented) end: Vec; // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(first?: boolean): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec): boolean; // (undocumented) - length: number; + largeArcFlag: number; // (undocumented) measure: number; // (undocumented) @@ -138,6 +141,8 @@ export class Arc2d extends Geometry2d { radius: number; // (undocumented) start: Vec; + // (undocumented) + sweepFlag: number; } // @public @@ -392,6 +397,9 @@ export const CAMERA_SLIDE_FRICTION = 0.09; // @public (undocumented) export function canonicalizeRotation(a: number): number; +// @public +export function centerOfCircleFromThreePoints(a: VecLike, b: VecLike, c: VecLike): Vec; + // @public (undocumented) export class Circle2d extends Geometry2d { constructor(config: Omit & { @@ -412,6 +420,8 @@ export class Circle2d extends Geometry2d { // (undocumented) getBounds(): Box; // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean; @@ -481,6 +491,12 @@ export class CubicBezier2d extends Polyline2d { // (undocumented) d: Vec; // (undocumented) + static GetAtT(segment: CubicBezier2d, t: number): Vec; + // (undocumented) + getLength(precision?: number): number; + // (undocumented) + getSvgPathData(first?: boolean): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) midPoint(): Vec; @@ -494,14 +510,14 @@ export class CubicSpline2d extends Geometry2d { points: Vec[]; }); // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec): boolean; // (undocumented) - get length(): number; - // (undocumented) - _length?: number; - // (undocumented) nearestPoint(A: Vec): Vec; // (undocumented) points: Vec[]; @@ -646,14 +662,14 @@ export class Edge2d extends Geometry2d { // (undocumented) end: Vec; // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(first?: boolean): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean; // (undocumented) - get length(): number; - // (undocumented) - _length?: number; - // (undocumented) midPoint(): Vec; // (undocumented) nearestPoint(point: Vec): Vec; @@ -1104,6 +1120,10 @@ export class Ellipse2d extends Geometry2d { // (undocumented) getBounds(): Box; // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(first?: boolean): string; + // (undocumented) getVertices(): any[]; // (undocumented) h: number; @@ -1180,6 +1200,10 @@ export abstract class Geometry2d { // (undocumented) getBounds(): Box; // (undocumented) + getLength(): number; + // (undocumented) + abstract getSvgPathData(first: boolean): string; + // (undocumented) abstract getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean; @@ -1196,6 +1220,8 @@ export abstract class Geometry2d { // (undocumented) isPointInBounds(point: Vec, margin?: number): boolean; // (undocumented) + get length(): number; + // (undocumented) abstract nearestPoint(point: Vec): Vec; // (undocumented) nearestPointOnLineSegment(A: Vec, B: Vec): Vec; @@ -1238,6 +1264,9 @@ export function getPointInArcT(mAB: number, A: number, B: number, P: number): nu // @public export function getPointOnCircle(center: VecLike, r: number, a: number): Vec; +// @public (undocumented) +export function getPointsOnArc(startPoint: VecLike, endPoint: VecLike, center: null | VecLike, radius: number, numPoints: number): Vec[]; + // @public (undocumented) export function getPolygonVertices(width: number, height: number, sides: number): Vec[]; @@ -1271,6 +1300,10 @@ export class Group2d extends Geometry2d { // (undocumented) getArea(): number; // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; @@ -1599,6 +1632,8 @@ export class Point2d extends Geometry2d { point: Vec; }); // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, margin: number): boolean; @@ -1640,14 +1675,14 @@ export class Polyline2d extends Geometry2d { points: Vec[]; }); // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean; // (undocumented) - get length(): number; - // (undocumented) - _length?: number; - // (undocumented) nearestPoint(A: Vec): Vec; // (undocumented) points: Vec[]; @@ -1705,6 +1740,8 @@ export class Rectangle2d extends Polygon2d { // (undocumented) getBounds(): Box; // (undocumented) + getSvgPathData(): string; + // (undocumented) h: number; // (undocumented) w: number; @@ -1917,18 +1954,40 @@ export class SnapManager { } // @public (undocumented) -export class Stadium2d extends Ellipse2d { +export class Stadium2d extends Geometry2d { constructor(config: Omit & { height: number; width: number; }); // (undocumented) + a: Arc2d; + // (undocumented) + b: Edge2d; + // (undocumented) + c: Arc2d; + // (undocumented) config: Omit & { height: number; width: number; }; // (undocumented) + d: Edge2d; + // (undocumented) + getBounds(): Box; + // (undocumented) + getLength(): number; + // (undocumented) + getSvgPathData(): string; + // (undocumented) getVertices(): Vec[]; + // (undocumented) + h: number; + // (undocumented) + hitTestLineSegment(A: Vec, B: Vec): boolean; + // (undocumented) + nearestPoint(A: Vec): Vec; + // (undocumented) + w: number; } // @public (undocumented) @@ -3154,10 +3213,14 @@ export class Vec { // (undocumented) toArray(): number[]; // (undocumented) - static ToFixed(A: VecLike, n?: number): Vec; + static ToCss(A: VecLike): string; + // (undocumented) + static ToFixed(A: VecLike): Vec; // (undocumented) toFixed(): Vec; // (undocumented) + static ToInt(A: VecLike): Vec; + // (undocumented) static ToJson(A: VecLike): { x: number; y: number; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 14e07eaed..2258cdf54 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -297,6 +297,7 @@ export { areAnglesCompatible, average, canonicalizeRotation, + centerOfCircleFromThreePoints, clamp, clampRadians, clockwiseAngleDist, @@ -305,6 +306,7 @@ export { getArcMeasure, getPointInArcT, getPointOnCircle, + getPointsOnArc, getPolygonVertices, isSafeFloat, perimeterOfEllipse, diff --git a/packages/editor/src/lib/primitives/Vec.test.ts b/packages/editor/src/lib/primitives/Vec.test.ts index f7b56ede2..d8ae5f5cd 100644 --- a/packages/editor/src/lib/primitives/Vec.test.ts +++ b/packages/editor/src/lib/primitives/Vec.test.ts @@ -253,8 +253,7 @@ describe('Vec.IsClockwise', () => { describe('Vec.ToFixed', () => { it('Rounds a vector to the a given precision.', () => { - expect(Vec.ToFixed(new Vec(1.2345, 5.678), 1)).toMatchObject(new Vec(1.2, 5.7)) - expect(Vec.ToFixed(new Vec(1.2345, 5.678), 2)).toMatchObject(new Vec(1.23, 5.68)) + expect(Vec.ToFixed(new Vec(1.2345, 5.678))).toMatchObject(new Vec(1.23, 5.68)) }) }) diff --git a/packages/editor/src/lib/primitives/Vec.ts b/packages/editor/src/lib/primitives/Vec.ts index 898e0578f..391edc9f7 100644 --- a/packages/editor/src/lib/primitives/Vec.ts +++ b/packages/editor/src/lib/primitives/Vec.ts @@ -1,5 +1,6 @@ import { VecModel } from '@tldraw/tlschema' import { EASINGS } from './easings' +import { toFixed } from './utils' /** @public */ export type VecLike = Vec | VecModel @@ -504,8 +505,20 @@ export class Vec { return Vec.Sub(A, origin).mul(scale).add(origin) } - static ToFixed(A: VecLike, n = 2) { - return new Vec(+A.x.toFixed(n), +A.y.toFixed(n), +A.z!.toFixed(n)) + static ToFixed(A: VecLike) { + return new Vec(toFixed(A.x), toFixed(A.y)) + } + + static ToInt(A: VecLike) { + return new Vec( + parseInt(A.x.toFixed(0)), + parseInt(A.y.toFixed(0)), + parseInt((A.z ?? 0).toFixed(0)) + ) + } + + static ToCss(A: VecLike) { + return `${A.x},${A.y}` } static Nudge(A: VecLike, B: VecLike, distance: number) { diff --git a/packages/editor/src/lib/primitives/geometry/Arc2d.ts b/packages/editor/src/lib/primitives/geometry/Arc2d.ts index f3c5eeabf..8d600a69b 100644 --- a/packages/editor/src/lib/primitives/geometry/Arc2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Arc2d.ts @@ -10,16 +10,16 @@ export class Arc2d extends Geometry2d { radius: number start: Vec end: Vec + largeArcFlag: number + sweepFlag: number measure: number - length: number angleStart: number angleEnd: number constructor( config: Omit & { center: Vec - radius: number start: Vec end: Vec sweepFlag: number @@ -27,20 +27,21 @@ export class Arc2d extends Geometry2d { } ) { super({ ...config, isFilled: false, isClosed: false }) - const { center, radius, sweepFlag, largeArcFlag, start, end } = config + const { center, sweepFlag, largeArcFlag, start, end } = config if (start.equals(end)) throw Error(`Arc must have different start and end points.`) // ensure that the start and end are clockwise this.angleStart = Vec.Angle(center, start) this.angleEnd = Vec.Angle(center, end) + this.radius = Vec.Dist(center, start) this.measure = getArcMeasure(this.angleStart, this.angleEnd, sweepFlag, largeArcFlag) - this.length = this.measure * radius this.start = start this.end = end + this.sweepFlag = sweepFlag + this.largeArcFlag = largeArcFlag this._center = center - this.radius = radius } nearestPoint(point: Vec): Vec { @@ -80,13 +81,20 @@ export class Arc2d extends Geometry2d { getVertices(): Vec[] { const { _center, measure, length, radius, angleStart } = this const vertices: Vec[] = [] - for (let i = 0, n = getVerticesCountForLength(Math.abs(length)); i < n + 1; i++) { const t = (i / n) * measure const angle = angleStart + t vertices.push(getPointOnCircle(_center, radius, angle)) } - return vertices } + + getSvgPathData(first = true) { + const { start, end, radius, largeArcFlag, sweepFlag } = this + return `${first ? `M${start.toFixed()}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.toFixed()}` + } + + override getLength() { + return this.measure * this.radius + } } diff --git a/packages/editor/src/lib/primitives/geometry/Circle2d.ts b/packages/editor/src/lib/primitives/geometry/Circle2d.ts index c4abacefc..eaf69a229 100644 --- a/packages/editor/src/lib/primitives/geometry/Circle2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Circle2d.ts @@ -53,4 +53,9 @@ export class Circle2d extends Geometry2d { const { _center, radius } = this return intersectLineSegmentCircle(A, B, _center, radius + distance) !== null } + + getSvgPathData(): string { + const { _center, radius } = this + return `M${_center.x + radius},${_center.y} a${radius},${radius} 0 1,0 ${radius * 2},0a${radius},${radius} 0 1,0 -${radius * 2},0` + } } diff --git a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts index 912144378..a056cfc3f 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts @@ -49,7 +49,7 @@ export class CubicBezier2d extends Polyline2d { } midPoint() { - return getAtT(this, 0.5) + return CubicBezier2d.GetAtT(this, 0.5) } nearestPoint(A: Vec): Vec { @@ -69,18 +69,35 @@ export class CubicBezier2d extends Polyline2d { if (!nearest) throw Error('nearest point not found') return nearest } -} -function getAtT(segment: CubicBezier2d, t: number) { - const { a, b, c, d } = segment - return new Vec( - (1 - t) * (1 - t) * (1 - t) * a.x + - 3 * ((1 - t) * (1 - t)) * t * b.x + - 3 * (1 - t) * (t * t) * c.x + - t * t * t * d.x, - (1 - t) * (1 - t) * (1 - t) * a.y + - 3 * ((1 - t) * (1 - t)) * t * b.y + - 3 * (1 - t) * (t * t) * c.y + - t * t * t * d.y - ) + getSvgPathData(first = true) { + const { a, b, c, d } = this + return `${first ? `M ${a.toFixed()} ` : ``} C${b.toFixed()} ${c.toFixed()} ${d.toFixed()}` + } + + static GetAtT(segment: CubicBezier2d, t: number) { + const { a, b, c, d } = segment + return new Vec( + (1 - t) * (1 - t) * (1 - t) * a.x + + 3 * ((1 - t) * (1 - t)) * t * b.x + + 3 * (1 - t) * (t * t) * c.x + + t * t * t * d.x, + (1 - t) * (1 - t) * (1 - t) * a.y + + 3 * ((1 - t) * (1 - t)) * t * b.y + + 3 * (1 - t) * (t * t) * c.y + + t * t * t * d.y + ) + } + + override getLength(precision = 32) { + let n1: Vec, + p1 = this.a, + length = 0 + for (let i = 1; i <= precision; i++) { + n1 = CubicBezier2d.GetAtT(this, i / precision) + length += Vec.Dist(p1, n1) + p1 = n1 + } + return length + } } diff --git a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts index 1033c31f8..590a4f888 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts @@ -46,14 +46,8 @@ export class CubicSpline2d extends Geometry2d { return this._segments } - _length?: number - - // eslint-disable-next-line no-restricted-syntax - get length() { - if (!this._length) { - this._length = this.segments.reduce((acc, segment) => acc + segment.length, 0) - } - return this._length + override getLength() { + return this.segments.reduce((acc, segment) => acc + segment.length, 0) } getVertices() { @@ -84,4 +78,16 @@ export class CubicSpline2d extends Geometry2d { hitTestLineSegment(A: Vec, B: Vec): boolean { return this.segments.some((segment) => segment.hitTestLineSegment(A, B)) } + + getSvgPathData() { + let d = this.segments.reduce((d, segment, i) => { + return d + segment.getSvgPathData(i === 0) + }, '') + + if (this.isClosed) { + d += 'Z' + } + + return d + } } diff --git a/packages/editor/src/lib/primitives/geometry/Edge2d.ts b/packages/editor/src/lib/primitives/geometry/Edge2d.ts index 11af4d580..c53ebb8bf 100644 --- a/packages/editor/src/lib/primitives/geometry/Edge2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Edge2d.ts @@ -22,14 +22,8 @@ export class Edge2d extends Geometry2d { this.ul = this.u.len() // the length of the unit vector } - _length?: number - - // eslint-disable-next-line no-restricted-syntax - get length() { - if (!this._length) { - return this.d.len() - } - return this._length + override getLength() { + return this.d.len() } midPoint(): Vec { @@ -58,4 +52,9 @@ export class Edge2d extends Geometry2d { linesIntersect(A, B, this.start, this.end) || this.distanceToLineSegment(A, B) <= distance ) } + + getSvgPathData(first = true) { + const { start, end } = this + return `${first ? `M${start.toFixed()}` : ``} L${end.toFixed()}` + } } diff --git a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts index acd7241e3..750025a2a 100644 --- a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts @@ -1,6 +1,6 @@ import { Box } from '../Box' import { Vec } from '../Vec' -import { PI, PI2 } from '../utils' +import { PI, PI2, perimeterOfEllipse } from '../utils' import { Edge2d } from './Edge2d' import { Geometry2d, Geometry2dOptions } from './Geometry2d' import { getVerticesCountForLength } from './geometry-constants' @@ -97,4 +97,22 @@ export class Ellipse2d extends Geometry2d { getBounds() { return new Box(0, 0, this.w, this.h) } + + getLength(): number { + const { w, h } = this + const cx = w / 2 + const cy = h / 2 + const rx = Math.max(0, cx) + const ry = Math.max(0, cy) + return perimeterOfEllipse(rx, ry) + } + + getSvgPathData(first = false) { + const { w, h } = this + const cx = w / 2 + const cy = h / 2 + const rx = Math.max(0, cx) + const ry = Math.max(0, cy) + return `${first ? `M${cx - rx},${cy}` : ``} a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` + } } diff --git a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts index 01b989d0f..d0979326c 100644 --- a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts @@ -178,4 +178,28 @@ export abstract class Geometry2d { return path } + + private _length?: number + + // eslint-disable-next-line no-restricted-syntax + get length() { + if (this._length) return this._length + this._length = this.getLength() + return this._length + } + + getLength() { + const { vertices } = this + let n1: Vec, + p1 = vertices[0], + length = 0 + for (let i = 1; i < vertices.length; i++) { + n1 = vertices[i] + length += Vec.Dist2(p1, n1) + p1 = n1 + } + return Math.sqrt(length) + } + + abstract getSvgPathData(first: boolean): string } diff --git a/packages/editor/src/lib/primitives/geometry/Group2d.ts b/packages/editor/src/lib/primitives/geometry/Group2d.ts index b98f1ea99..24d6bf896 100644 --- a/packages/editor/src/lib/primitives/geometry/Group2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Group2d.ts @@ -96,4 +96,12 @@ export class Group2d extends Geometry2d { } return path } + + getLength(): number { + return this.children.reduce((a, c) => (c.isLabel ? a : a + c.length), 0) + } + + getSvgPathData(): string { + return this.children.map((c, i) => (c.isLabel ? '' : c.getSvgPathData(i === 0))).join(' ') + } } diff --git a/packages/editor/src/lib/primitives/geometry/Point2d.ts b/packages/editor/src/lib/primitives/geometry/Point2d.ts index e03bccbea..28a6f5077 100644 --- a/packages/editor/src/lib/primitives/geometry/Point2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Point2d.ts @@ -25,4 +25,9 @@ export class Point2d extends Geometry2d { hitTestLineSegment(A: Vec, B: Vec, margin: number): boolean { return Vec.DistanceToLineSegment(A, B, this.point) < margin } + + getSvgPathData() { + const { point } = this + return `M${point.toFixed()}` + } } diff --git a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts index 59ca3e25a..bb119cb83 100644 --- a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts @@ -33,14 +33,8 @@ export class Polyline2d extends Geometry2d { return this._segments } - _length?: number - - // eslint-disable-next-line no-restricted-syntax - get length() { - if (!this._length) { - this._length = this.segments.reduce((acc, segment) => acc + segment.length, 0) - } - return this._length + override getLength() { + return this.segments.reduce((acc, segment) => acc + segment.length, 0) } getVertices() { @@ -74,4 +68,13 @@ export class Polyline2d extends Geometry2d { } return false } + + getSvgPathData(): string { + const { vertices } = this + if (vertices.length < 2) return '' + return vertices.reduce((acc, vertex, i) => { + if (i === 0) return `M ${vertex.x} ${vertex.y}` + return `${acc} L ${vertex.x} ${vertex.y}` + }, '') + } } diff --git a/packages/editor/src/lib/primitives/geometry/Rectangle2d.ts b/packages/editor/src/lib/primitives/geometry/Rectangle2d.ts index e5c7b1de3..d28c4ace3 100644 --- a/packages/editor/src/lib/primitives/geometry/Rectangle2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Rectangle2d.ts @@ -37,4 +37,9 @@ export class Rectangle2d extends Polygon2d { getBounds() { return new Box(this.x, this.y, this.w, this.h) } + + getSvgPathData(): string { + const { x, y, w, h } = this + return `M${x},${y} h${w} v${h} h-${w}z` + } } diff --git a/packages/editor/src/lib/primitives/geometry/Stadium2d.ts b/packages/editor/src/lib/primitives/geometry/Stadium2d.ts index 9de5b1eb3..77f8920be 100644 --- a/packages/editor/src/lib/primitives/geometry/Stadium2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Stadium2d.ts @@ -1,47 +1,114 @@ +import { Box } from '../Box' import { Vec } from '../Vec' -import { HALF_PI, PI } from '../utils' -import { Ellipse2d } from './Ellipse2d' -import { Geometry2dOptions } from './Geometry2d' - -const STADIUM_VERTICES_LENGTH = 18 +import { PI } from '../utils' +import { Arc2d } from './Arc2d' +import { Edge2d } from './Edge2d' +import { Geometry2d, Geometry2dOptions } from './Geometry2d' /** @public */ -export class Stadium2d extends Ellipse2d { +export class Stadium2d extends Geometry2d { + w: number + h: number + + a: Arc2d + b: Edge2d + c: Arc2d + d: Edge2d + constructor( - public config: Omit & { width: number; height: number } + public config: Omit & { + width: number + height: number + } ) { - super({ ...config }) + super({ ...config, isClosed: true }) + const { width: w, height: h } = config + this.w = w + this.h = h + + if (h > w) { + const r = w / 2 + this.a = new Arc2d({ + start: new Vec(0, r), + end: new Vec(w, r), + center: new Vec(w / 2, r), + sweepFlag: 1, + largeArcFlag: 1, + }) + this.b = new Edge2d({ start: new Vec(w, r), end: new Vec(w, h - r) }) + this.c = new Arc2d({ + start: new Vec(w, h - r), + end: new Vec(0, h - r), + center: new Vec(w / 2, h - r), + sweepFlag: 1, + largeArcFlag: 1, + }) + this.d = new Edge2d({ start: new Vec(0, h - r), end: new Vec(0, r) }) + } else { + const r = h / 2 + this.a = new Arc2d({ + start: new Vec(r, h), + end: new Vec(r, 0), + center: new Vec(r, r), + sweepFlag: 1, + largeArcFlag: 1, + }) + this.b = new Edge2d({ start: new Vec(r, 0), end: new Vec(w - r, 0) }) + this.c = new Arc2d({ + start: new Vec(w - r, 0), + end: new Vec(w - r, h), + center: new Vec(w - r, r), + sweepFlag: 1, + largeArcFlag: 1, + }) + this.d = new Edge2d({ start: new Vec(w - r, h), end: new Vec(r, h) }) + } + } + + nearestPoint(A: Vec): Vec { + let nearest: Vec | undefined + let dist = Infinity + let _d: number + let p: Vec + + const { a, b, c, d } = this + for (const part of [a, b, c, d]) { + p = part.nearestPoint(A) + _d = Vec.Dist2(p, A) + if (_d < dist) { + nearest = p + dist = _d + } + } + if (!nearest) throw Error('nearest point not found') + return nearest + } + + hitTestLineSegment(A: Vec, B: Vec): boolean { + const { a, b, c, d } = this + return [a, b, c, d].some((edge) => edge.hitTestLineSegment(A, B)) } getVertices() { - const w = Math.max(1, this.w) - const h = Math.max(1, this.h) - const cx = w / 2 - const cy = h / 2 - const points: Vec[] = Array(STADIUM_VERTICES_LENGTH) - let t1: number, t2: number - if (h > w) { - for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) { - t1 = -PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 2) - t2 = (PI * i) / (STADIUM_VERTICES_LENGTH - 2) - points[i] = new Vec(cx + cx * Math.cos(t1), cx + cx * Math.sin(t1)) - points[i + (STADIUM_VERTICES_LENGTH - 1)] = new Vec( - cx + cx * Math.cos(t2), - h - cx + cx * Math.sin(t2) - ) - } - } else { - for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) { - t1 = -HALF_PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 2) - t2 = HALF_PI + (PI * -i) / (STADIUM_VERTICES_LENGTH - 2) - points[i] = new Vec(w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1)) - points[i + (STADIUM_VERTICES_LENGTH - 1)] = new Vec( - cy - cy * Math.cos(t2), - h - cy + cy * Math.sin(t2) - ) - } - } + const { a, b, c, d } = this + return [a, b, c, d].reduce((a, p) => { + a.push(...p.vertices) + return a + }, []) + } - return points + getBounds() { + return new Box(0, 0, this.w, this.h) + } + + getLength() { + const { h, w } = this + if (h > w) return (PI * (w / 2) + (h - w)) * 2 + else return (PI * (h / 2) + (w - h)) * 2 + } + + getSvgPathData() { + const { a, b, c, d } = this + return [a, b, c, d].map((p, i) => p.getSvgPathData(i === 0)).join(' ') + ' Z' } } diff --git a/packages/editor/src/lib/primitives/utils.ts b/packages/editor/src/lib/primitives/utils.ts index bea8c6fd3..9726abbdf 100644 --- a/packages/editor/src/lib/primitives/utils.ts +++ b/packages/editor/src/lib/primitives/utils.ts @@ -86,8 +86,7 @@ export function approximately(a: number, b: number, precision = 0.000001) { */ export function perimeterOfEllipse(rx: number, ry: number): number { const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2) - const p = PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) - return p + return PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h))) } /** @@ -427,3 +426,52 @@ export function getArcMeasure(A: number, B: number, sweepFlag: number, largeArcF if (!largeArcFlag) return m return (PI2 - Math.abs(m)) * (sweepFlag ? 1 : -1) } + +/** + * Get the center of a circle from three points. + * + * @param a - The first point + * @param b - The second point + * @param c - The third point + * + * @returns The center of the circle + * + * @public + */ +export function centerOfCircleFromThreePoints(a: VecLike, b: VecLike, c: VecLike) { + const u = -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y) + return new Vec( + ((a.x * a.x + a.y * a.y) * (c.y - b.y) + + (b.x * b.x + b.y * b.y) * (a.y - c.y) + + (c.x * c.x + c.y * c.y) * (b.y - a.y)) / + u, + ((a.x * a.x + a.y * a.y) * (b.x - c.x) + + (b.x * b.x + b.y * b.y) * (c.x - a.x) + + (c.x * c.x + c.y * c.y) * (a.x - b.x)) / + u + ) +} + +/** @public */ +export function getPointsOnArc( + startPoint: VecLike, + endPoint: VecLike, + center: VecLike | null, + radius: number, + numPoints: number +): Vec[] { + if (center === null) { + return [Vec.From(startPoint), Vec.From(endPoint)] + } + const results: Vec[] = [] + const startAngle = Vec.Angle(center, startPoint) + const endAngle = Vec.Angle(center, endPoint) + const l = clockwiseAngleDist(startAngle, endAngle) + for (let i = 0; i < numPoints; i++) { + const t = i / (numPoints - 1) + const angle = startAngle + l * t + const point = getPointOnCircle(center, radius, angle) + results.push(point) + } + return results +} diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index ccb5a5f54..84effa3ea 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -702,7 +702,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { dash: "dashed" | "dotted" | "draw" | "solid"; fill: "none" | "pattern" | "semi" | "solid"; font: "draw" | "mono" | "sans" | "serif"; - geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box"; + geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "heart" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box"; growY: number; h: number; labelColor: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow"; @@ -732,7 +732,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { dash: "dashed" | "dotted" | "draw" | "solid"; fill: "none" | "pattern" | "semi" | "solid"; font: "draw" | "mono" | "sans" | "serif"; - geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box"; + geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "heart" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box"; growY: number; h: number; labelColor: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow"; @@ -791,7 +791,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">; fill: EnumStyleProp<"none" | "pattern" | "semi" | "solid">; font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">; - geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; + geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "heart" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; growY: Validator; h: Validator; labelColor: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">; @@ -2261,7 +2261,7 @@ export interface TLUiIconProps extends React.HTMLProps { } // @public (undocumented) -export type TLUiIconType = 'align-bottom' | 'align-center-horizontal' | 'align-center-vertical' | 'align-left' | 'align-right' | 'align-top' | 'arrow-left' | 'arrowhead-arrow' | 'arrowhead-bar' | 'arrowhead-diamond' | 'arrowhead-dot' | 'arrowhead-none' | 'arrowhead-square' | 'arrowhead-triangle-inverted' | 'arrowhead-triangle' | 'blob' | 'bring-forward' | 'bring-to-front' | 'broken' | 'check-circle' | 'check' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'chevrons-ne' | 'chevrons-sw' | 'clipboard-copied' | 'clipboard-copy' | 'color' | 'cross-2' | 'cross-circle' | 'dash-dashed' | 'dash-dotted' | 'dash-draw' | 'dash-solid' | 'disconnected' | 'discord' | 'distribute-horizontal' | 'distribute-vertical' | 'dot' | 'dots-horizontal' | 'dots-vertical' | 'drag-handle-dots' | 'duplicate' | 'edit' | 'external-link' | 'fill-none' | 'fill-pattern' | 'fill-semi' | 'fill-solid' | 'follow' | 'following' | 'font-draw' | 'font-mono' | 'font-sans' | 'font-serif' | 'geo-arrow-down' | 'geo-arrow-left' | 'geo-arrow-right' | 'geo-arrow-up' | 'geo-check-box' | 'geo-cloud' | 'geo-diamond' | 'geo-ellipse' | 'geo-hexagon' | 'geo-octagon' | 'geo-oval' | 'geo-pentagon' | 'geo-rectangle' | 'geo-rhombus-2' | 'geo-rhombus' | 'geo-star' | 'geo-trapezoid' | 'geo-triangle' | 'geo-x-box' | 'github' | 'group' | 'horizontal-align-end' | 'horizontal-align-middle' | 'horizontal-align-start' | 'info-circle' | 'leading' | 'link' | 'lock' | 'menu' | 'minus' | 'mixed' | 'pack' | 'plus' | 'question-mark-circle' | 'question-mark' | 'redo' | 'reset-zoom' | 'rotate-ccw' | 'rotate-cw' | 'send-backward' | 'send-to-back' | 'share-1' | 'size-extra-large' | 'size-large' | 'size-medium' | 'size-small' | 'spline-cubic' | 'spline-line' | 'stack-horizontal' | 'stack-vertical' | 'stretch-horizontal' | 'stretch-vertical' | 'text-align-center' | 'text-align-left' | 'text-align-right' | 'toggle-off' | 'toggle-on' | 'tool-arrow' | 'tool-eraser' | 'tool-frame' | 'tool-hand' | 'tool-highlight' | 'tool-laser' | 'tool-line' | 'tool-media' | 'tool-note' | 'tool-pencil' | 'tool-pointer' | 'tool-screenshot' | 'tool-text' | 'trash' | 'twitter' | 'undo' | 'ungroup' | 'unlock' | 'vertical-align-end' | 'vertical-align-middle' | 'vertical-align-start' | 'warning-triangle' | 'zoom-in' | 'zoom-out'; +export type TLUiIconType = 'align-bottom' | 'align-center-horizontal' | 'align-center-vertical' | 'align-left' | 'align-right' | 'align-top' | 'arrow-left' | 'arrowhead-arrow' | 'arrowhead-bar' | 'arrowhead-diamond' | 'arrowhead-dot' | 'arrowhead-none' | 'arrowhead-square' | 'arrowhead-triangle-inverted' | 'arrowhead-triangle' | 'blob' | 'bring-forward' | 'bring-to-front' | 'broken' | 'check-circle' | 'check' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'chevrons-ne' | 'chevrons-sw' | 'clipboard-copied' | 'clipboard-copy' | 'color' | 'cross-2' | 'cross-circle' | 'dash-dashed' | 'dash-dotted' | 'dash-draw' | 'dash-solid' | 'disconnected' | 'discord' | 'distribute-horizontal' | 'distribute-vertical' | 'dot' | 'dots-horizontal' | 'dots-vertical' | 'drag-handle-dots' | 'duplicate' | 'edit' | 'external-link' | 'fill-none' | 'fill-pattern' | 'fill-semi' | 'fill-solid' | 'follow' | 'following' | 'font-draw' | 'font-mono' | 'font-sans' | 'font-serif' | 'geo-arrow-down' | 'geo-arrow-left' | 'geo-arrow-right' | 'geo-arrow-up' | 'geo-check-box' | 'geo-cloud' | 'geo-diamond' | 'geo-ellipse' | 'geo-heart' | 'geo-hexagon' | 'geo-octagon' | 'geo-oval' | 'geo-pentagon' | 'geo-rectangle' | 'geo-rhombus-2' | 'geo-rhombus' | 'geo-star' | 'geo-trapezoid' | 'geo-triangle' | 'geo-x-box' | 'github' | 'group' | 'horizontal-align-end' | 'horizontal-align-middle' | 'horizontal-align-start' | 'info-circle' | 'leading' | 'link' | 'lock' | 'menu' | 'minus' | 'mixed' | 'pack' | 'plus' | 'question-mark-circle' | 'question-mark' | 'redo' | 'reset-zoom' | 'rotate-ccw' | 'rotate-cw' | 'send-backward' | 'send-to-back' | 'share-1' | 'size-extra-large' | 'size-large' | 'size-medium' | 'size-small' | 'spline-cubic' | 'spline-line' | 'stack-horizontal' | 'stack-vertical' | 'stretch-horizontal' | 'stretch-vertical' | 'text-align-center' | 'text-align-left' | 'text-align-right' | 'toggle-off' | 'toggle-on' | 'tool-arrow' | 'tool-eraser' | 'tool-frame' | 'tool-hand' | 'tool-highlight' | 'tool-laser' | 'tool-line' | 'tool-media' | 'tool-note' | 'tool-pencil' | 'tool-pointer' | 'tool-screenshot' | 'tool-text' | 'trash' | 'twitter' | 'undo' | 'ungroup' | 'unlock' | 'vertical-align-end' | 'vertical-align-middle' | 'vertical-align-start' | 'warning-triangle' | 'zoom-in' | 'zoom-out'; // @public (undocumented) export interface TLUiInputProps { @@ -2613,6 +2613,11 @@ export function ToggleTransparentBgMenuItem(): JSX_2.Element; // @public (undocumented) export function ToggleWrapModeItem(): JSX_2.Element; +// @public (undocumented) +export function ToolbarItem({ tool }: { + tool: string; +}): JSX_2.Element; + // @public (undocumented) export function TrapezoidToolbarItem(): JSX_2.Element; diff --git a/packages/tldraw/src/index.ts b/packages/tldraw/src/index.ts index b0b50a468..8db7bcab1 100644 --- a/packages/tldraw/src/index.ts +++ b/packages/tldraw/src/index.ts @@ -407,6 +407,7 @@ export { SelectToolbarItem, StarToolbarItem, TextToolbarItem, + ToolbarItem, TrapezoidToolbarItem, TriangleToolbarItem, XBoxToolbarItem, diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx index f18ee01d3..65847e430 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx @@ -121,7 +121,6 @@ export class ArrowShapeUtil extends ShapeUtil { }) : new Arc2d({ center: Vec.Cast(info.handleArc.center), - radius: info.handleArc.radius, start: Vec.Cast(info.start.point), end: Vec.Cast(info.end.point), sweepFlag: info.bodyArc.sweepFlag, diff --git a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts index 6dabf2a83..ee5ed3f9d 100644 --- a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts +++ b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts @@ -43,7 +43,6 @@ function getArrowLabelSize(editor: Editor, shape: TLArrowShape) { }) : new Arc2d({ center: Vec.Cast(info.handleArc.center), - radius: info.handleArc.radius, start: Vec.Cast(info.start.point), end: Vec.Cast(info.end.point), sweepFlag: info.bodyArc.sweepFlag, diff --git a/packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts b/packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts index 2451ca6b3..d30a8361c 100644 --- a/packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts +++ b/packages/tldraw/src/lib/shapes/arrow/curved-arrow.ts @@ -6,6 +6,7 @@ import { TLArrowShape, Vec, VecLike, + centerOfCircleFromThreePoints, clockwiseAngleDist, counterClockwiseAngleDist, intersectCirclePolygon, @@ -373,20 +374,7 @@ export function getCurvedArrowInfo( */ function getArcInfo(a: VecLike, b: VecLike, c: VecLike): TLArcInfo { // find a circle from the three points - const u = -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y) - - const center = { - x: - ((a.x * a.x + a.y * a.y) * (c.y - b.y) + - (b.x * b.x + b.y * b.y) * (a.y - c.y) + - (c.x * c.x + c.y * c.y) * (b.y - a.y)) / - u, - y: - ((a.x * a.x + a.y * a.y) * (b.x - c.x) + - (b.x * b.x + b.y * b.y) * (c.x - a.x) + - (c.x * c.x + c.y * c.y) * (a.x - b.x)) / - u, - } + const center = centerOfCircleFromThreePoints(a, b, c) const radius = Vec.Dist(center, a) diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx index 420811938..746dd77e1 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx @@ -43,11 +43,16 @@ import { getFillDefForExport, getFontDefForExport, } from '../shared/defaultStyleDefs' -import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../shared/polygon-helpers' -import { cloudOutline, cloudSvgPath } from './cloudOutline' -import { getEllipseIndicatorPath } from './components/DrawStyleEllipse' import { GeoShapeBody } from './components/GeoShapeBody' -import { getOvalIndicatorPath } from './components/SolidStyleOval' +import { + cloudOutline, + getCloudPath, + getEllipseDrawIndicatorPath, + getHeartParts, + getHeartPath, + getRoundedInkyPolygonPath, + getRoundedPolygonPoints, +} from './geo-shape-helpers' import { getLines } from './getLines' const MIN_SIZE_WITH_LABEL = 17 * 3 @@ -292,6 +297,23 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { }) break } + case 'heart': { + // kind of expensive (creating the primitives to create a different primitive) but hearts are rare and beautiful things + const parts = getHeartParts(w, h) + const points = parts.reduce((acc, part) => { + acc.push(...part.vertices) + return acc + }, []) + + body = new Polygon2d({ + points, + isFilled, + }) + break + } + default: { + exhaustiveSwitchError(shape.props.geo) + } } const labelSize = getLabelSize(this.editor, shape) @@ -359,6 +381,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { return { outline: outline, points: [...outline.getVertices(), geometry.bounds.center] } case 'cloud': case 'ellipse': + case 'heart': case 'oval': // blobby shapes only have a snap point in their center return { outline: outline, points: [geometry.bounds.center] } @@ -438,19 +461,24 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { const strokeWidth = STROKE_SIZES[size] + const geometry = this.editor.getShapeGeometry(shape) + switch (props.geo) { case 'ellipse': { if (props.dash === 'draw') { - return + return } - return + return + } + case 'heart': { + return } case 'oval': { - return + return } case 'cloud': { - return + return } default: { diff --git a/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts b/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts deleted file mode 100644 index 036d24682..000000000 --- a/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { - PI, - TLDefaultSizeStyle, - Vec, - VecModel, - clockwiseAngleDist, - getPointOnCircle, - rng, - toDomPrecision, -} from '@tldraw/editor' - -function getPillCircumference(width: number, height: number) { - const radius = Math.min(width, height) / 2 - const longSide = Math.max(width, height) - radius * 2 - - return Math.PI * (radius * 2) + 2 * longSide -} - -type PillSection = - | { - type: 'straight' - start: VecModel - delta: VecModel - } - | { - type: 'arc' - center: VecModel - startAngle: number - } - -function getPillPoints(width: number, height: number, numPoints: number) { - const radius = Math.min(width, height) / 2 - const longSide = Math.max(width, height) - radius * 2 - - const circumference = Math.PI * (radius * 2) + 2 * longSide - - const spacing = circumference / numPoints - - const sections: PillSection[] = - width > height - ? [ - { - type: 'straight', - start: new Vec(radius, 0), - delta: new Vec(1, 0), - }, - { - type: 'arc', - center: new Vec(width - radius, radius), - startAngle: -PI / 2, - }, - { - type: 'straight', - start: new Vec(width - radius, height), - delta: new Vec(-1, 0), - }, - { - type: 'arc', - center: new Vec(radius, radius), - startAngle: PI / 2, - }, - ] - : [ - { - type: 'straight', - start: new Vec(width, radius), - delta: new Vec(0, 1), - }, - { - type: 'arc', - center: new Vec(radius, height - radius), - startAngle: 0, - }, - { - type: 'straight', - start: new Vec(0, height - radius), - delta: new Vec(0, -1), - }, - { - type: 'arc', - center: new Vec(radius, radius), - startAngle: PI, - }, - ] - - let sectionOffset = 0 - - const points: Vec[] = [] - for (let i = 0; i < numPoints; i++) { - const section = sections[0] - if (section.type === 'straight') { - points.push(Vec.Add(section.start, Vec.Mul(section.delta, sectionOffset))) - } else { - points.push( - getPointOnCircle(section.center, radius, section.startAngle + sectionOffset / radius) - ) - } - sectionOffset += spacing - let sectionLength = section.type === 'straight' ? longSide : PI * radius - while (sectionOffset > sectionLength) { - sectionOffset -= sectionLength - sections.push(sections.shift()!) - sectionLength = sections[0].type === 'straight' ? longSide : PI * radius - } - } - - return points -} - -const switchSize = (size: TLDefaultSizeStyle, s: T, m: T, l: T, xl: T) => { - switch (size) { - case 's': - return s - case 'm': - return m - case 'l': - return l - case 'xl': - return xl - } -} - -export function getCloudArcs( - width: number, - height: number, - seed: string, - size: TLDefaultSizeStyle -) { - const getRandom = rng(seed) - const pillCircumference = getPillCircumference(width, height) - const numBumps = Math.max( - Math.ceil(pillCircumference / switchSize(size, 50, 70, 100, 130)), - 6, - Math.ceil(pillCircumference / Math.min(width, height)) - ) - const targetBumpProtrusion = (pillCircumference / numBumps) * 0.2 - - // if the aspect ratio is high, innerWidth should be smaller - const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1) - const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1) - const paddingX = (width - innerWidth) / 2 - const paddingY = (height - innerHeight) / 2 - - const distanceBetweenPointsOnPerimeter = getPillCircumference(innerWidth, innerHeight) / numBumps - - const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => { - return p.addXY(paddingX, paddingY) - }) - - const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3 - const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3 - - // wiggle the points from either end so that the bumps 'pop' - // in at the bottom-right and the top-left looks relatively stable - const wiggledPoints = bumpPoints.slice(0) - for (let i = 0; i < Math.floor(numBumps / 2); i++) { - wiggledPoints[i] = Vec.AddXY( - wiggledPoints[i], - getRandom() * maxWiggleX, - getRandom() * maxWiggleY - ) - wiggledPoints[numBumps - i - 1] = Vec.AddXY( - wiggledPoints[numBumps - i - 1], - getRandom() * maxWiggleX, - getRandom() * maxWiggleY - ) - } - - const arcs: Arc[] = [] - - for (let i = 0; i < wiggledPoints.length; i++) { - const j = i === wiggledPoints.length - 1 ? 0 : i + 1 - const leftWigglePoint = wiggledPoints[i] - const rightWigglePoint = wiggledPoints[j] - const leftPoint = bumpPoints[i] - const rightPoint = bumpPoints[j] - - const midPoint = Vec.Average([leftPoint, rightPoint]) - const offsetAngle = Vec.Angle(leftPoint, rightPoint) - Math.PI / 2 - // when the points are on the curvy part of a pill, there is a natural arc that we need to extends past - // otherwise it looks like the bumps get less bumpy on the curvy parts - const distanceBetweenOriginalPoints = Vec.Dist(leftPoint, rightPoint) - const curvatureOffset = distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints - const distanceBetweenWigglePoints = Vec.Dist(leftWigglePoint, rightWigglePoint) - const relativeSize = distanceBetweenWigglePoints / distanceBetweenOriginalPoints - const finalDistance = (Math.max(paddingX, paddingY) + curvatureOffset) * relativeSize - - const arcPoint = Vec.Add(midPoint, Vec.FromAngle(offsetAngle, finalDistance)) - if (arcPoint.x < 0) { - arcPoint.x = 0 - } else if (arcPoint.x > width) { - arcPoint.x = width - } - if (arcPoint.y < 0) { - arcPoint.y = 0 - } else if (arcPoint.y > height) { - arcPoint.y = height - } - - const center = getCenterOfCircleGivenThreePoints(leftWigglePoint, rightWigglePoint, arcPoint) - const radius = Vec.Dist( - center ? center : Vec.Average([leftWigglePoint, rightWigglePoint]), - leftWigglePoint - ) - - arcs.push({ - leftPoint: leftWigglePoint, - rightPoint: rightWigglePoint, - arcPoint, - center, - radius, - }) - } - - return arcs -} - -interface Arc { - leftPoint: Vec - rightPoint: Vec - arcPoint: Vec - center: Vec | null - radius: number -} - -function getCenterOfCircleGivenThreePoints(a: Vec, b: Vec, c: Vec) { - const A = a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y - const B = - (a.x * a.x + a.y * a.y) * (c.y - b.y) + - (b.x * b.x + b.y * b.y) * (a.y - c.y) + - (c.x * c.x + c.y * c.y) * (b.y - a.y) - const C = - (a.x * a.x + a.y * a.y) * (b.x - c.x) + - (b.x * b.x + b.y * b.y) * (c.x - a.x) + - (c.x * c.x + c.y * c.y) * (a.x - b.x) - - const x = -B / (2 * A) - const y = -C / (2 * A) - - // handle situations where the points are colinear (this happens when the cloud is very small) - if (!Number.isFinite(x) || !Number.isFinite(y)) { - return null - } - - return new Vec(x, y) -} - -export function cloudOutline( - width: number, - height: number, - seed: string, - size: TLDefaultSizeStyle -) { - const path: Vec[] = [] - - const arcs = getCloudArcs(width, height, seed, size) - - for (const { center, radius, leftPoint, rightPoint } of arcs) { - path.push(...pointsOnArc(leftPoint, rightPoint, center, radius, 10)) - } - - return path -} - -export function cloudSvgPath( - width: number, - height: number, - seed: string, - size: TLDefaultSizeStyle -) { - // const points = cloudOutline(width, height, seed, size) - // { - // let path = `M${toDomPrecision(points[0].x)},${toDomPrecision(points[0].y)}` - // for (const point of points.slice(1)) { - // path += ` L${toDomPrecision(point.x)},${toDomPrecision(point.y)}` - // } - // return path - // } - - const arcs = getCloudArcs(width, height, seed, size) - let path = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}` - - // now draw arcs for each circle, starting where it intersects the previous circle, and ending where it intersects the next circle - for (const { leftPoint, rightPoint, radius, center } of arcs) { - if (center === null) { - // draw a line to rightPoint instead - path += ` L${toDomPrecision(rightPoint.x)},${toDomPrecision(rightPoint.y)}` - continue - } - // use the large arc if the center of the circle is to the left of the line between the two points - const arc = isLeft(leftPoint, rightPoint, center) ? '0' : '1' - path += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${toDomPrecision( - rightPoint.x - )},${toDomPrecision(rightPoint.y)}` - } - - path += ' Z' - return path -} - -export function inkyCloudSvgPath( - width: number, - height: number, - seed: string, - size: TLDefaultSizeStyle -) { - const getRandom = rng(seed) - const mutMultiplier = size === 's' ? 0.5 : size === 'm' ? 0.7 : size === 'l' ? 0.9 : 1.6 - const mut = (n: number) => { - return n + getRandom() * mutMultiplier * 2 - } - const arcs = getCloudArcs(width, height, seed, size) - const avgArcLength = - arcs.reduce((sum, arc) => sum + Vec.Dist2(arc.leftPoint, arc.rightPoint), 0) / arcs.length - const shouldMutatePoints = avgArcLength > (mutMultiplier * 15) ** 2 - - const mutPoint = shouldMutatePoints ? (p: Vec) => new Vec(mut(p.x), mut(p.y)) : (p: Vec) => p - let pathA = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}` - let leftMutPoint = mutPoint(arcs[0].leftPoint) - let pathB = `M${toDomPrecision(leftMutPoint.x)},${toDomPrecision(leftMutPoint.y)}` - - for (const { leftPoint, center, rightPoint, radius, arcPoint } of arcs) { - if (center === null) { - // draw a line to rightPoint instead - pathA += ` L${toDomPrecision(rightPoint.x)},${toDomPrecision(rightPoint.y)}` - const rightMutPoint = mutPoint(rightPoint) - pathB += ` L${toDomPrecision(rightMutPoint.x)},${toDomPrecision(rightMutPoint.y)}` - leftMutPoint = rightMutPoint - continue - } - const arc = isLeft(leftPoint, rightPoint, center) ? '0' : '1' - pathA += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${toDomPrecision( - rightPoint.x - )},${toDomPrecision(rightPoint.y)}` - const rightMutPoint = mutPoint(rightPoint) - const mutArcPoint = mutPoint(arcPoint) - const mutCenter = getCenterOfCircleGivenThreePoints(leftMutPoint, rightMutPoint, mutArcPoint) - if (!mutCenter) { - // draw a line to rightMutPoint instead - pathB += ` L${toDomPrecision(rightMutPoint.x)},${toDomPrecision(rightMutPoint.y)}` - leftMutPoint = rightMutPoint - continue - } - const mutRadius = Math.abs(Vec.Dist(mutCenter, leftMutPoint)) - - pathB += ` A${toDomPrecision(mutRadius)},${toDomPrecision( - mutRadius - )} 0 ${arc},1 ${toDomPrecision(rightMutPoint.x)},${toDomPrecision(rightMutPoint.y)}` - leftMutPoint = rightMutPoint - } - - return pathA + pathB + ' Z' -} - -function pointsOnArc( - startPoint: VecModel, - endPoint: VecModel, - center: VecModel | null, - radius: number, - numPoints: number -): Vec[] { - if (center === null) { - return [Vec.From(startPoint), Vec.From(endPoint)] - } - const results: Vec[] = [] - - const startAngle = Vec.Angle(center, startPoint) - const endAngle = Vec.Angle(center, endPoint) - - const l = clockwiseAngleDist(startAngle, endAngle) - - for (let i = 0; i < numPoints; i++) { - const t = i / (numPoints - 1) - const angle = startAngle + l * t - const point = getPointOnCircle(center, radius, angle) - results.push(point) - } - - return results -} - -function isLeft(a: Vec, b: Vec, c: Vec) { - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0 -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx deleted file mode 100644 index d2d0817fa..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleCloud.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { TLGeoShape, TLShapeId, Vec, canonicalizeRotation } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { getPerfectDashProps } from '../../shared/getPerfectDashProps' -import { cloudSvgPath, getCloudArcs } from '../cloudOutline' - -export const DashStyleCloud = React.memo(function DashStylePolygon({ - dash, - fill, - color, - strokeWidth, - w, - h, - id, - size, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const innerPath = cloudSvgPath(w, h, id, size) - const arcs = getCloudArcs(w, h, id, size) - - return ( - <> - - - {arcs.map(({ leftPoint, rightPoint, center, radius }, i) => { - const arcLength = center - ? radius * - canonicalizeRotation( - canonicalizeRotation(Vec.Angle(center, rightPoint)) - - canonicalizeRotation(Vec.Angle(center, leftPoint)) - ) - : Vec.Dist(leftPoint, rightPoint) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - arcLength, - strokeWidth, - { - style: dash, - start: 'outset', - end: 'outset', - } - ) - - return ( - - ) - })} - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx deleted file mode 100644 index 2e8c99ca5..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleEllipse.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { TLGeoShape, TLShapeId, perimeterOfEllipse, toDomPrecision } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { getPerfectDashProps } from '../../shared/getPerfectDashProps' - -export const DashStyleEllipse = React.memo(function DashStyleEllipse({ - w, - h, - strokeWidth: sw, - dash, - color, - fill, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const cx = w / 2 - const cy = h / 2 - const rx = Math.max(0, cx) - const ry = Math.max(0, cy) - - const perimeter = perimeterOfEllipse(rx, ry) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - perimeter < 64 ? perimeter * 2 : perimeter, - sw, - { - style: dash, - snap: 4, - closed: true, - } - ) - - const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx deleted file mode 100644 index 1c787fadb..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStyleOval.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { TLGeoShape, TLShapeId, toDomPrecision } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { getPerfectDashProps } from '../../shared/getPerfectDashProps' -import { getOvalPerimeter, getOvalSolidPath } from '../helpers' - -export const DashStyleOval = React.memo(function DashStyleOval({ - w, - h, - strokeWidth: sw, - dash, - color, - fill, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const d = getOvalSolidPath(w, h) - const perimeter = getOvalPerimeter(w, h) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( - perimeter < 64 ? perimeter * 2 : perimeter, - sw, - { - style: dash, - snap: 4, - start: 'outset', - end: 'outset', - closed: true, - } - ) - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx deleted file mode 100644 index 76f2c67a1..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DashStylePolygon.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { TLGeoShape, Vec, VecLike } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { getPerfectDashProps } from '../../shared/getPerfectDashProps' - -export const DashStylePolygon = React.memo(function DashStylePolygon({ - dash, - fill, - color, - strokeWidth, - outline, - lines, -}: Pick & { - strokeWidth: number - outline: VecLike[] - lines?: VecLike[][] -}) { - const theme = useDefaultColorTheme() - const innerPath = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' - - return ( - <> - - - {Array.from(Array(outline.length)).map((_, i) => { - const A = outline[i] - const B = outline[(i + 1) % outline.length] - - const dist = Vec.Dist(A, B) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, { - style: dash, - start: 'outset', - end: 'outset', - }) - - return ( - - ) - })} - {lines && - lines.map(([A, B], i) => { - const dist = Vec.Dist(A, B) - - const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(dist, strokeWidth, { - style: dash, - start: 'skip', - end: 'outset', - snap: dash === 'dotted' ? 4 : undefined, - }) - - return ( - - ) - })} - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx deleted file mode 100644 index 51464c7e1..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleCloud.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { TLGeoShape, TLShapeId } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { inkyCloudSvgPath } from '../cloudOutline' - -export const DrawStyleCloud = React.memo(function StyleCloud({ - fill, - color, - strokeWidth, - w, - h, - id, - size, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const path = inkyCloudSvgPath(w, h, id, size) - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx deleted file mode 100644 index dcdad3e40..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStyleEllipse.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { EASINGS, HALF_PI, PI2, Vec, perimeterOfEllipse, rng } from '@tldraw/editor' -import { getStrokePoints } from '../../shared/freehand/getStrokePoints' -import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg' - -function getEllipseStrokeOptions(strokeWidth: number) { - return { - size: 1 + strokeWidth, - thinning: 0.25, - end: { taper: strokeWidth }, - start: { taper: strokeWidth }, - streamline: 0, - smoothing: 1, - simulatePressure: false, - } -} - -function getEllipseStrokePoints(id: string, width: number, height: number, strokeWidth: number) { - const getRandom = rng(id) - - const rx = width / 2 - const ry = height / 2 - const perimeter = perimeterOfEllipse(rx, ry) - - const points: Vec[] = [] - - const start = PI2 * getRandom() - const length = PI2 + HALF_PI / 2 + Math.abs(getRandom()) * HALF_PI - const count = Math.max(16, perimeter / 10) - - for (let i = 0; i < count; i++) { - const t = i / (count - 1) - const r = start + t * length - const c = Math.cos(r) - const s = Math.sin(r) - points.push( - new Vec( - rx * c + width * 0.5 + 0.05 * getRandom(), - ry * s + height / 2 + 0.05 * getRandom(), - Math.min( - 1, - 0.5 + - Math.abs(0.5 - (getRandom() > 0 ? EASINGS.easeInOutSine(t) : EASINGS.easeInExpo(t))) / 2 - ) - ) - ) - } - - return getStrokePoints(points, getEllipseStrokeOptions(strokeWidth)) -} - -export function getEllipseIndicatorPath( - id: string, - width: number, - height: number, - strokeWidth: number -) { - return getSvgPathFromStrokePoints(getEllipseStrokePoints(id, width, height, strokeWidth)) -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx deleted file mode 100644 index bd73caefc..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/DrawStylePolygon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { TLGeoShape, VecLike } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../../shared/polygon-helpers' - -export const DrawStylePolygon = React.memo(function DrawStylePolygon({ - id, - outline, - lines, - fill, - color, - strokeWidth, -}: Pick & { - id: TLGeoShape['id'] - outline: VecLike[] - strokeWidth: number - lines?: VecLike[][] -}) { - const theme = useDefaultColorTheme() - const polygonPoints = getRoundedPolygonPoints(id, outline, strokeWidth / 3, strokeWidth * 2, 2) - let strokePathData = getRoundedInkyPolygonPath(polygonPoints) - - if (lines) { - for (const [A, B] of lines) { - strokePathData += `M${A.x},${A.y}L${B.x},${B.y}` - } - } - - const innerPolygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1) - const innerPathData = getRoundedInkyPolygonPath(innerPolygonPoints) - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx b/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx index e5fe7e959..1f0d6415a 100644 --- a/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx +++ b/packages/tldraw/src/lib/shapes/geo/components/GeoShapeBody.tsx @@ -1,19 +1,22 @@ -import { Group2d, TLGeoShape, useEditor } from '@tldraw/editor' +import { Group2d, TLGeoShape, Vec, canonicalizeRotation, useEditor } from '@tldraw/editor' +import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' import { STROKE_SIZES } from '../../shared/default-shape-constants' +import { getPerfectDashProps } from '../../shared/getPerfectDashProps' +import { + getCloudArcs, + getCloudPath, + getDrawHeartPath, + getHeartParts, + getHeartPath, + getRoundedInkyPolygonPath, + getRoundedPolygonPoints, + inkyCloudSvgPath, +} from '../geo-shape-helpers' import { getLines } from '../getLines' -import { DashStyleCloud } from './DashStyleCloud' -import { DashStyleEllipse } from './DashStyleEllipse' -import { DashStyleOval } from './DashStyleOval' -import { DashStylePolygon } from './DashStylePolygon' -import { DrawStyleCloud } from './DrawStyleCloud' -import { DrawStylePolygon } from './DrawStylePolygon' -import { SolidStyleCloud } from './SolidStyleCloud' -import { SolidStyleEllipse } from './SolidStyleEllipse' -import { SolidStyleOval } from './SolidStyleOval' -import { SolidStylePolygon } from './SolidStylePolygon' export function GeoShapeBody({ shape }: { shape: TLGeoShape }) { const editor = useEditor() + const theme = useDefaultColorTheme() const { id, props } = shape const { w, color, fill, dash, growY, size } = props const strokeWidth = STROKE_SIZES[size] @@ -22,85 +25,194 @@ export function GeoShapeBody({ shape }: { shape: TLGeoShape }) { switch (props.geo) { case 'cloud': { if (dash === 'solid') { + const d = getCloudPath(w, h, id, size) return ( - - ) - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - + <> + + + ) } else if (dash === 'draw') { + const d = inkyCloudSvgPath(w, h, id, size) return ( - + <> + + + + ) + } else { + const innerPath = getCloudPath(w, h, id, size) + const arcs = getCloudArcs(w, h, id, size) + + return ( + <> + + + {arcs.map(({ leftPoint, rightPoint, center, radius }, i) => { + const arcLength = center + ? radius * + canonicalizeRotation( + canonicalizeRotation(Vec.Angle(center, rightPoint)) - + canonicalizeRotation(Vec.Angle(center, leftPoint)) + ) + : Vec.Dist(leftPoint, rightPoint) + + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + arcLength, + strokeWidth, + { + style: dash, + start: 'outset', + end: 'outset', + } + ) + + return ( + + ) + })} + + ) } - - break } case 'ellipse': { - if (dash === 'solid') { - return - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - + const geometry = editor.getShapeGeometry(shape) + const d = geometry.getSvgPathData(true) + + if (dash === 'dashed' || dash === 'dotted') { + const perimeter = geometry.length + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + perimeter < 64 ? perimeter * 2 : perimeter, + strokeWidth, + { + style: dash, + snap: 4, + closed: true, + } + ) + + return ( + <> + + + + ) + } else { + const geometry = editor.getShapeGeometry(shape) + const d = geometry.getSvgPathData(true) + return ( + <> + + + ) - } else if (dash === 'draw') { - return } - break } case 'oval': { - if (dash === 'solid') { - return - } else if (dash === 'dashed' || dash === 'dotted') { - return ( - + const geometry = editor.getShapeGeometry(shape) + const d = geometry.getSvgPathData(true) + if (dash === 'dashed' || dash === 'dotted') { + const perimeter = geometry.getLength() + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + perimeter < 64 ? perimeter * 2 : perimeter, + strokeWidth, + { + style: dash, + snap: 4, + start: 'outset', + end: 'outset', + closed: true, + } + ) + + return ( + <> + + + + ) + } else { + return ( + <> + + + + ) + } + } + case 'heart': { + if (dash === 'dashed' || dash === 'dotted') { + const d = getHeartPath(w, h) + const curves = getHeartParts(w, h) + + return ( + <> + + {curves.map((c, i) => { + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + c.length, + strokeWidth, + { + style: dash, + snap: 1, + start: 'outset', + end: 'outset', + closed: true, + } + ) + return ( + + ) + })} + + ) + } else { + const d = getDrawHeartPath(w, h, strokeWidth, shape.id) + return ( + <> + + + ) - } else if (dash === 'draw') { - return } - break } default: { const geometry = editor.getShapeGeometry(shape) @@ -109,36 +221,112 @@ export function GeoShapeBody({ shape }: { shape: TLGeoShape }) { const lines = getLines(shape.props, strokeWidth) if (dash === 'solid') { + let d = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' + + if (lines) { + for (const [A, B] of lines) { + d += `M${A.x},${A.y}L${B.x},${B.y}` + } + } + return ( - + <> + + + ) } else if (dash === 'dashed' || dash === 'dotted') { + const innerPath = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' + return ( - + <> + + + {Array.from(Array(outline.length)).map((_, i) => { + const A = Vec.ToFixed(outline[i]) + const B = Vec.ToFixed(outline[(i + 1) % outline.length]) + const dist = Vec.Dist(A, B) + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + dist, + strokeWidth, + { + style: dash, + start: 'outset', + end: 'outset', + } + ) + + return ( + + ) + })} + {lines && + lines.map(([A, B], i) => { + const dist = Vec.Dist(A, B) + + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + dist, + strokeWidth, + { + style: dash, + start: 'skip', + end: 'skip', + snap: dash === 'dotted' ? 4 : undefined, + } + ) + + return ( + + ) + })} + + ) } else if (dash === 'draw') { + const polygonPoints = getRoundedPolygonPoints( + id, + outline, + strokeWidth / 3, + strokeWidth * 2, + 2 + ) + let d = getRoundedInkyPolygonPath(polygonPoints) + + if (lines) { + for (const [A, B] of lines) { + d += `M${A.toFixed()}L${B.toFixed()}` + } + } + + const innerPolygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1) + const innerPathData = getRoundedInkyPolygonPath(innerPolygonPoints) + return ( - + <> + + + ) } } diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx deleted file mode 100644 index 3f36a4317..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleCloud.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { TLGeoShape, TLShapeId } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' -import { cloudSvgPath } from '../cloudOutline' - -export const SolidStyleCloud = React.memo(function SolidStyleCloud({ - fill, - color, - strokeWidth, - w, - h, - id, - size, -}: Pick & { - strokeWidth: number - id: TLShapeId -}) { - const theme = useDefaultColorTheme() - const path = cloudSvgPath(w, h, id, size) - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx deleted file mode 100644 index e9f30b170..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleEllipse.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { TLGeoShape } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' - -export const SolidStyleEllipse = React.memo(function SolidStyleEllipse({ - w, - h, - strokeWidth: sw, - fill, - color, -}: Pick & { strokeWidth: number }) { - const theme = useDefaultColorTheme() - const cx = w / 2 - const cy = h / 2 - const rx = Math.max(0, cx) - const ry = Math.max(0, cy) - - const d = `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx deleted file mode 100644 index 9c7a1a20c..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStyleOval.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { TLGeoShape } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' - -export const SolidStyleOval = React.memo(function SolidStyleOval({ - w, - h, - strokeWidth: sw, - fill, - color, -}: Pick & { - strokeWidth: number -}) { - const theme = useDefaultColorTheme() - const d = getOvalIndicatorPath(w, h) - return ( - <> - - - - ) -}) - -export function getOvalIndicatorPath(w: number, h: number) { - let d: string - - if (h > w) { - const offset = w / 2 - d = ` - M0,${offset} - a${offset},${offset},0,1,1,${offset * 2},0 - L${w},${h - offset} - a${offset},${offset},0,1,1,-${offset * 2},0 - Z` - } else { - const offset = h / 2 - d = ` - M${offset},0 - L${w - offset},0 - a${offset},${offset},0,1,1,0,${offset * 2} - L${offset},${h} - a${offset},${offset},0,1,1,0,${-offset * 2} - Z` - } - - return d -} diff --git a/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx b/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx deleted file mode 100644 index 0b156d032..000000000 --- a/packages/tldraw/src/lib/shapes/geo/components/SolidStylePolygon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { TLGeoShape, VecLike } from '@tldraw/editor' -import * as React from 'react' -import { ShapeFill, useDefaultColorTheme } from '../../shared/ShapeFill' - -export const SolidStylePolygon = React.memo(function SolidStylePolygon({ - outline, - lines, - fill, - color, - strokeWidth, -}: Pick & { - outline: VecLike[] - lines?: VecLike[][] - strokeWidth: number -}) { - const theme = useDefaultColorTheme() - let path = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' - - if (lines) { - for (const [A, B] of lines) { - path += `M${A.x},${A.y}L${B.x},${B.y}` - } - } - - return ( - <> - - - - ) -}) diff --git a/packages/tldraw/src/lib/shapes/geo/geo-shape-helpers.ts b/packages/tldraw/src/lib/shapes/geo/geo-shape-helpers.ts new file mode 100644 index 000000000..861bd24df --- /dev/null +++ b/packages/tldraw/src/lib/shapes/geo/geo-shape-helpers.ts @@ -0,0 +1,588 @@ +import { + CubicBezier2d, + EASINGS, + HALF_PI, + PI, + PI2, + TLDefaultSizeStyle, + Vec, + VecModel, + centerOfCircleFromThreePoints, + getPointOnCircle, + getPointsOnArc, + perimeterOfEllipse, + rng, + toDomPrecision, +} from '@tldraw/editor' +import { getStrokePoints } from '../shared/freehand/getStrokePoints' +import { getSvgPathFromStrokePoints } from '../shared/freehand/svg' + +/* ---------------------- Oval ---------------------- */ + +export function getOvalPerimeter(h: number, w: number) { + if (h > w) return (PI * (w / 2) + (h - w)) * 2 + else return (PI * (h / 2) + (w - h)) * 2 +} + +/* ---------------------- Heart --------------------- */ + +export function getHeartPath(w: number, h: number) { + return ( + getHeartParts(w, h) + .map((c, i) => c.getSvgPathData(i === 0)) + .join(' ') + ' Z' + ) +} + +export function getDrawHeartPath(w: number, h: number, sw: number, id: string) { + const o = w / 4 + const k = h / 4 + const random = rng(id) + const mutDistance = sw * 0.75 + const mut = (v: Vec) => v.addXY(random() * mutDistance, random() * mutDistance) + + const A = new Vec(w / 2, h) + const B = new Vec(0, k * 1.2) + const C = new Vec(w / 2, k * 0.9) + const D = new Vec(w, k * 1.2) + + const Am = mut(new Vec(w / 2, h)) + const Bm = mut(new Vec(0, k * 1.2)) + const Cm = mut(new Vec(w / 2, k * 0.9)) + const Dm = mut(new Vec(w, k * 1.2)) + + const parts = [ + new CubicBezier2d({ + start: A, + cp1: new Vec(o * 1.5, k * 3), + cp2: new Vec(0, k * 2.5), + end: B, + }), + new CubicBezier2d({ + start: B, + cp1: new Vec(0, -k * 0.32), + cp2: new Vec(o * 1.85, -k * 0.32), + end: C, + }), + new CubicBezier2d({ + start: C, + cp1: new Vec(o * 2.15, -k * 0.32), + cp2: new Vec(w, -k * 0.32), + end: D, + }), + new CubicBezier2d({ + start: D, + cp1: new Vec(w, k * 2.5), + cp2: new Vec(o * 2.5, k * 3), + end: Am, + }), + new CubicBezier2d({ + start: Am, + cp1: new Vec(o * 1.5, k * 3), + cp2: new Vec(0, k * 2.5), + end: Bm, + }), + new CubicBezier2d({ + start: Bm, + cp1: new Vec(0, -k * 0.32), + cp2: new Vec(o * 1.85, -k * 0.32), + end: Cm, + }), + new CubicBezier2d({ + start: Cm, + cp1: new Vec(o * 2.15, -k * 0.32), + cp2: new Vec(w, -k * 0.32), + end: Dm, + }), + new CubicBezier2d({ + start: Dm, + cp1: new Vec(w, k * 2.5), + cp2: new Vec(o * 2.5, k * 3), + end: A, + }), + ] + + return parts.map((c, i) => c.getSvgPathData(i === 0)).join(' ') + ' Z' +} + +export function getHeartPoints(w: number, h: number) { + const points = [] as Vec[] + const curves = getHeartParts(w, h) + for (let i = 0; i < curves.length; i++) { + for (let j = 0; j < 20; j++) { + points.push(CubicBezier2d.GetAtT(curves[i], j / 20)) + } + if (i === curves.length - 1) { + points.push(CubicBezier2d.GetAtT(curves[i], 1)) + } + } +} + +export function getHeartParts(w: number, h: number) { + const o = w / 4 + const k = h / 4 + return [ + new CubicBezier2d({ + start: new Vec(w / 2, h), + cp1: new Vec(o * 1.5, k * 3), + cp2: new Vec(0, k * 2.5), + end: new Vec(0, k * 1.2), + }), + new CubicBezier2d({ + start: new Vec(0, k * 1.2), + cp1: new Vec(0, -k * 0.32), + cp2: new Vec(o * 1.85, -k * 0.32), + end: new Vec(w / 2, k * 0.9), + }), + new CubicBezier2d({ + start: new Vec(w / 2, k * 0.9), + cp1: new Vec(o * 2.15, -k * 0.32), + cp2: new Vec(w, -k * 0.32), + end: new Vec(w, k * 1.2), + }), + new CubicBezier2d({ + start: new Vec(w, k * 1.2), + cp1: new Vec(w, k * 2.5), + cp2: new Vec(o * 2.5, k * 3), + end: new Vec(w / 2, h), + }), + ] +} + +/* --------------------- Ellipse -------------------- */ + +function getEllipseStrokeOptions(strokeWidth: number) { + return { + size: 1 + strokeWidth, + thinning: 0.25, + end: { taper: strokeWidth }, + start: { taper: strokeWidth }, + streamline: 0, + smoothing: 1, + simulatePressure: false, + } +} + +function getEllipseStrokePoints(id: string, width: number, height: number, strokeWidth: number) { + const getRandom = rng(id) + + const rx = width / 2 + const ry = height / 2 + const perimeter = perimeterOfEllipse(rx, ry) + + const points: Vec[] = [] + + const start = PI2 * getRandom() + const length = PI2 + HALF_PI / 2 + Math.abs(getRandom()) * HALF_PI + const count = Math.max(16, perimeter / 10) + + for (let i = 0; i < count; i++) { + const t = i / (count - 1) + const r = start + t * length + const c = Math.cos(r) + const s = Math.sin(r) + points.push( + new Vec( + rx * c + width * 0.5 + 0.05 * getRandom(), + ry * s + height / 2 + 0.05 * getRandom(), + Math.min( + 1, + 0.5 + + Math.abs(0.5 - (getRandom() > 0 ? EASINGS.easeInOutSine(t) : EASINGS.easeInExpo(t))) / 2 + ) + ) + ) + } + + return getStrokePoints(points, getEllipseStrokeOptions(strokeWidth)) +} + +export function getEllipseDrawIndicatorPath( + id: string, + width: number, + height: number, + strokeWidth: number +) { + return getSvgPathFromStrokePoints(getEllipseStrokePoints(id, width, height, strokeWidth)) +} + +export function getEllipsePath(w: number, h: number) { + const cx = w / 2 + const cy = h / 2 + const rx = Math.max(0, cx) + const ry = Math.max(0, cy) + return `M${cx - rx},${cy}a${rx},${ry},0,1,1,${rx * 2},0a${rx},${ry},0,1,1,-${rx * 2},0` +} + +/* --------------------- Polygon -------------------- */ + +import { VecLike, precise } from '@tldraw/editor' + +/** @public */ +export function getRoundedInkyPolygonPath(points: VecLike[]) { + let polylineA = `M` + + const len = points.length + + let p0: VecLike + let p1: VecLike + let p2: VecLike + + for (let i = 0, n = len; i < n; i += 3) { + p0 = points[i] + p1 = points[i + 1] + p2 = points[i + 2] + + polylineA += `${precise(p0)}L${precise(p1)}Q${precise(p2)}` + } + + polylineA += `${precise(points[0])}` + + return polylineA +} + +/** @public */ +export function getRoundedPolygonPoints( + id: string, + outline: VecLike[], + offset: number, + roundness: number, + passes: number +) { + const results: VecLike[] = [] + + const random = rng(id) + let p0 = outline[0] + let p1: VecLike + + const len = outline.length + + for (let i = 0, n = len * passes; i < n; i++) { + p1 = Vec.AddXY(outline[(i + 1) % len], random() * offset, random() * offset) + + const delta = Vec.Sub(p1, p0) + const distance = Vec.Len(delta) + const vector = Vec.Div(delta, distance).mul(Math.min(distance / 4, roundness)) + results.push(Vec.Add(p0, vector), Vec.Add(p1, vector.neg()), p1) + + p0 = p1 + } + + return results +} + +/* ---------------------- Cloud --------------------- */ + +type PillSection = + | { + type: 'straight' + start: VecModel + delta: VecModel + } + | { + type: 'arc' + center: VecModel + startAngle: number + } + +function getPillPoints(width: number, height: number, numPoints: number) { + const radius = Math.min(width, height) / 2 + const longSide = Math.max(width, height) - radius * 2 + const circumference = Math.PI * (radius * 2) + 2 * longSide + const spacing = circumference / numPoints + + const sections: PillSection[] = + width > height + ? [ + { + type: 'straight', + start: new Vec(radius, 0), + delta: new Vec(1, 0), + }, + { + type: 'arc', + center: new Vec(width - radius, radius), + startAngle: -PI / 2, + }, + { + type: 'straight', + start: new Vec(width - radius, height), + delta: new Vec(-1, 0), + }, + { + type: 'arc', + center: new Vec(radius, radius), + startAngle: PI / 2, + }, + ] + : [ + { + type: 'straight', + start: new Vec(width, radius), + delta: new Vec(0, 1), + }, + { + type: 'arc', + center: new Vec(radius, height - radius), + startAngle: 0, + }, + { + type: 'straight', + start: new Vec(0, height - radius), + delta: new Vec(0, -1), + }, + { + type: 'arc', + center: new Vec(radius, radius), + startAngle: PI, + }, + ] + + let sectionOffset = 0 + + const points: Vec[] = [] + for (let i = 0; i < numPoints; i++) { + const section = sections[0] + if (section.type === 'straight') { + points.push(Vec.Add(section.start, Vec.Mul(section.delta, sectionOffset))) + } else { + points.push( + getPointOnCircle(section.center, radius, section.startAngle + sectionOffset / radius) + ) + } + sectionOffset += spacing + let sectionLength = section.type === 'straight' ? longSide : PI * radius + while (sectionOffset > sectionLength) { + sectionOffset -= sectionLength + sections.push(sections.shift()!) + sectionLength = sections[0].type === 'straight' ? longSide : PI * radius + } + } + + return points +} + +const SIZES: Record = { + s: 50, + m: 70, + l: 100, + xl: 130, +} + +const BUMP_PROTRUSION = 0.2 + +export function getCloudArcs( + width: number, + height: number, + seed: string, + size: TLDefaultSizeStyle +) { + const getRandom = rng(seed) + const pillCircumference = getOvalPerimeter(width, height) + const numBumps = Math.max( + Math.ceil(pillCircumference / SIZES[size]), + 6, + Math.ceil(pillCircumference / Math.min(width, height)) + ) + const targetBumpProtrusion = (pillCircumference / numBumps) * BUMP_PROTRUSION + + // if the aspect ratio is high, innerWidth should be smaller + const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1) + const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1) + const innerCircumference = getOvalPerimeter(innerWidth, innerHeight) + + const distanceBetweenPointsOnPerimeter = innerCircumference / numBumps + + const paddingX = (width - innerWidth) / 2 + const paddingY = (height - innerHeight) / 2 + const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => { + return p.addXY(paddingX, paddingY) + }) + const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3 + const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3 + + // wiggle the points from either end so that the bumps 'pop' + // in at the bottom-right and the top-left looks relatively stable + // note: it's important that we don't mutate here! these points are also the bump points + const wiggledPoints = bumpPoints.slice(0) + for (let i = 0; i < Math.floor(numBumps / 2); i++) { + wiggledPoints[i] = Vec.AddXY( + wiggledPoints[i], + getRandom() * maxWiggleX, + getRandom() * maxWiggleY + ) + wiggledPoints[numBumps - i - 1] = Vec.AddXY( + wiggledPoints[numBumps - i - 1], + getRandom() * maxWiggleX, + getRandom() * maxWiggleY + ) + } + + const arcs: Arc[] = [] + + for (let i = 0; i < wiggledPoints.length; i++) { + const j = i === wiggledPoints.length - 1 ? 0 : i + 1 + const leftWigglePoint = wiggledPoints[i] + const rightWigglePoint = wiggledPoints[j] + const leftPoint = bumpPoints[i] + const rightPoint = bumpPoints[j] + + // when the points are on the curvy part of a pill, there is a natural arc that we need to extends past + // otherwise it looks like the bumps get less bumpy on the curvy parts + const distanceBetweenOriginalPoints = Vec.Dist(leftPoint, rightPoint) + const curvatureOffset = distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints + const distanceBetweenWigglePoints = Vec.Dist(leftWigglePoint, rightWigglePoint) + const relativeSize = distanceBetweenWigglePoints / distanceBetweenOriginalPoints + const finalDistance = (Math.max(paddingX, paddingY) + curvatureOffset) * relativeSize + + const arcPoint = Vec.Lrp(leftPoint, rightPoint, 0.5).add( + Vec.Sub(rightPoint, leftPoint).uni().per().mul(finalDistance) + ) + if (arcPoint.x < 0) { + arcPoint.x = 0 + } else if (arcPoint.x > width) { + arcPoint.x = width + } + if (arcPoint.y < 0) { + arcPoint.y = 0 + } else if (arcPoint.y > height) { + arcPoint.y = height + } + + const center = centerOfCircleFromThreePoints(leftWigglePoint, rightWigglePoint, arcPoint) + const radius = Vec.Dist( + center ? center : Vec.Average([leftWigglePoint, rightWigglePoint]), + leftWigglePoint + ) + + // todo: could use Arc2d here + + arcs.push({ + leftPoint: leftWigglePoint, + rightPoint: rightWigglePoint, + arcPoint, + center, + radius, + }) + } + + return arcs +} + +interface Arc { + leftPoint: Vec + rightPoint: Vec + arcPoint: Vec + center: Vec | null + radius: number +} + +export function cloudOutline( + width: number, + height: number, + seed: string, + size: TLDefaultSizeStyle +) { + const path: Vec[] = [] + + const arcs = getCloudArcs(width, height, seed, size) + + for (const { center, radius, leftPoint, rightPoint } of arcs) { + path.push(...getPointsOnArc(leftPoint, rightPoint, center, radius, 10)) + } + + return path +} + +export function getCloudPath( + width: number, + height: number, + seed: string, + size: TLDefaultSizeStyle +) { + // const points = cloudOutline(width, height, seed, size) + // { + // let path = `M${toDomPrecision(points[0].x)},${toDomPrecision(points[0].y)}` + // for (const point of points.slice(1)) { + // path += ` L${toDomPrecision(point.x)},${toDomPrecision(point.y)}` + // } + // return path + // } + + const arcs = getCloudArcs(width, height, seed, size) + let path = `M${arcs[0].leftPoint.toFixed()}` + + // now draw arcs for each circle, starting where it intersects the previous circle, and ending where it intersects the next circle + for (const { leftPoint, rightPoint, radius, center } of arcs) { + if (center === null) { + // draw a line to rightPoint instead + path += ` L${rightPoint.toFixed()}` + continue + } + // use the large arc if the center of the circle is to the left of the line between the two points + const arc = Vec.Clockwise(leftPoint, rightPoint, center) ? '0' : '1' + path += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${rightPoint.toFixed()}` + } + + path += ' Z' + return path +} + +const DRAW_OFFSETS: Record = { + s: 0.5, + m: 0.7, + l: 0.9, + xl: 1.6, +} + +export function inkyCloudSvgPath( + width: number, + height: number, + seed: string, + size: TLDefaultSizeStyle +) { + const getRandom = rng(seed) + const mutMultiplier = DRAW_OFFSETS[size] + const arcs = getCloudArcs(width, height, seed, size) + const avgArcLengthSquared = + arcs.reduce((sum, arc) => sum + Vec.Dist2(arc.leftPoint, arc.rightPoint), 0) / arcs.length + const shouldMutatePoints = avgArcLengthSquared > (mutMultiplier * 15) ** 2 + const mutPoint = shouldMutatePoints + ? (p: Vec) => p.addXY(getRandom() * mutMultiplier * 2, getRandom() * mutMultiplier * 2) + : (p: Vec) => p + let pathA = `M${arcs[0].leftPoint.toFixed()}` + let leftMutPoint = mutPoint(arcs[0].leftPoint) + let pathB = `M${leftMutPoint.toFixed()}` + + for (const { leftPoint, center, rightPoint, radius, arcPoint } of arcs) { + if (center === null) { + // draw a line to rightPoint instead + pathA += ` L${rightPoint.toFixed()}` + const rightMutPoint = mutPoint(rightPoint) + pathB += ` L${rightMutPoint.toFixed()}` + leftMutPoint = rightMutPoint + continue + } + const arc = Vec.Clockwise(leftPoint, rightPoint, center) ? '0' : '1' + pathA += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 ${arc},1 ${rightPoint.toFixed()}` + const rightMutPoint = mutPoint(rightPoint) + const mutArcPoint = mutPoint(arcPoint) + const mutCenter = centerOfCircleFromThreePoints(leftMutPoint, rightMutPoint, mutArcPoint) + + // handle situations where the points are colinear (this happens when the cloud is very small) + if (!Number.isFinite(mutCenter.x) || !Number.isFinite(mutCenter.y)) { + // draw a line to rightMutPoint instead + pathB += ` L${rightMutPoint.toFixed()}` + leftMutPoint = rightMutPoint + continue + } + + const mutRadius = Math.abs(Vec.Dist(mutCenter, leftMutPoint)) + pathB += ` A${toDomPrecision(mutRadius)},${toDomPrecision( + mutRadius + )} 0 ${arc},1 ${rightMutPoint.toFixed()}` + leftMutPoint = rightMutPoint + } + + return pathA + pathB + ' Z' +} diff --git a/packages/tldraw/src/lib/shapes/geo/helpers.ts b/packages/tldraw/src/lib/shapes/geo/helpers.ts deleted file mode 100644 index 46c2d79ff..000000000 --- a/packages/tldraw/src/lib/shapes/geo/helpers.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { perimeterOfEllipse } from '@tldraw/editor' - -export function getOvalSolidPath(w: number, h: number) { - if (h > w) { - const offset = w / 2 - - return ` - M0,${offset} - a${offset},${offset},0,1,1,${offset * 2},0 - L${w},${h - offset} - a${offset},${offset},0,1,1,-${offset * 2},0 - Z` - } - - const offset = h / 2 - - return ` - M${offset},0 - L${w - offset},0 - a${offset},${offset},0,1,1,0,${offset * 2} - L${offset},${h} - a${offset},${offset},0,1,1,0,${-offset * 2} - Z` -} - -export function getOvalPerimeter(h: number, w: number) { - if (h > w) { - const offset = w / 2 - return perimeterOfEllipse(offset, offset) + (h - offset * 2) * 2 - } - - const offset = h / 2 - return perimeterOfEllipse(offset, offset) + (w - offset * 2) * 2 -} diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx index fd1f970a6..a4ac1f0dd 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx @@ -22,13 +22,8 @@ import { import { ShapeFill, useDefaultColorTheme } from '../shared/ShapeFill' import { STROKE_SIZES } from '../shared/default-shape-constants' import { getPerfectDashProps } from '../shared/getPerfectDashProps' -import { getDrawLinePathData } from '../shared/polygon-helpers' import { getLineDrawPath, getLineIndicatorPath } from './components/getLinePath' -import { - getSvgPathForBezierCurve, - getSvgPathForEdge, - getSvgPathForLineGeometry, -} from './components/svg' +import { getDrawLinePathData } from './line-helpers' const handlesCache = new WeakCache() @@ -254,7 +249,7 @@ function LineShapeSvg({ shape }: { shape: TLLineShape }) { key={i} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} - d={getSvgPathForEdge(segment as any, true)} + d={segment.getSvgPathData(true)} fill="none" /> ) @@ -283,7 +278,7 @@ function LineShapeSvg({ shape }: { shape: TLLineShape }) { } // Cubic style spline if (shape.props.spline === 'cubic') { - const splinePath = getSvgPathForLineGeometry(spline) + const splinePath = spline.getSvgPathData() if (dash === 'solid') { return ( <> @@ -314,7 +309,7 @@ function LineShapeSvg({ shape }: { shape: TLLineShape }) { key={i} strokeDasharray={strokeDasharray} strokeDashoffset={strokeDashoffset} - d={getSvgPathForBezierCurve(segment as any, true)} + d={segment.getSvgPathData()} fill="none" /> ) diff --git a/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts b/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts index 54a31269f..ce8b00717 100644 --- a/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts +++ b/packages/tldraw/src/lib/shapes/line/components/getLinePath.ts @@ -3,7 +3,6 @@ import { getStrokeOutlinePoints } from '../../shared/freehand/getStrokeOutlinePo import { getStrokePoints } from '../../shared/freehand/getStrokePoints' import { setStrokePointRadii } from '../../shared/freehand/setStrokePointRadii' import { getSvgPathFromStrokePoints } from '../../shared/freehand/svg' -import { getSvgPathForLineGeometry } from './svg' function getLineDrawFreehandOptions(strokeWidth: number) { return { @@ -58,5 +57,5 @@ export function getLineIndicatorPath( return getSvgPathFromStrokePoints(strokePoints) } - return getSvgPathForLineGeometry(spline) + return spline.getSvgPathData() } diff --git a/packages/tldraw/src/lib/shapes/line/components/svg.ts b/packages/tldraw/src/lib/shapes/line/components/svg.ts deleted file mode 100644 index 78183cb6f..000000000 --- a/packages/tldraw/src/lib/shapes/line/components/svg.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - CubicBezier2d, - CubicSpline2d, - Edge2d, - Polyline2d, - Vec, - toDomPrecision, -} from '@tldraw/editor' - -export function getSvgPathForEdge(edge: Edge2d, first: boolean) { - const { start, end } = edge - if (first) { - return `M${toDomPrecision(start.x)},${toDomPrecision(start.y)} L${toDomPrecision( - end.x - )},${toDomPrecision(end.y)} ` - } - return `${toDomPrecision(end.x)},${toDomPrecision(end.y)} ` -} - -export function getSvgPathForBezierCurve(curve: CubicBezier2d, first: boolean) { - const { a, b, c, d } = curve - - if (Vec.Equals(a, d)) return '' - - return `${first ? `M${toDomPrecision(a.x)},${toDomPrecision(a.y)}` : ``}C${toDomPrecision( - b.x - )},${toDomPrecision(b.y)} ${toDomPrecision(c.x)},${toDomPrecision(c.y)} ${toDomPrecision( - d.x - )},${toDomPrecision(d.y)}` -} - -function getSvgPathForCubicSpline(spline: CubicSpline2d, isClosed: boolean) { - let d = spline.segments.reduce((d, segment, i) => { - return d + getSvgPathForBezierCurve(segment, i === 0) - }, '') - - if (isClosed) { - d += 'Z' - } - - return d -} - -function getSvgPathForPolylineSpline(spline: Polyline2d, isClosed: boolean) { - let d = spline.segments.reduce((d, segment, i) => { - return d + getSvgPathForEdge(segment, i === 0) - }, '') - - if (isClosed) { - d += 'Z' - } - - return d -} - -export function getSvgPathForLineGeometry(spline: CubicSpline2d | Polyline2d, isClosed = false) { - if (spline instanceof Polyline2d) { - return getSvgPathForPolylineSpline(spline, isClosed) - } else { - return getSvgPathForCubicSpline(spline, isClosed) - } -} diff --git a/packages/tldraw/src/lib/shapes/shared/polygon-helpers.ts b/packages/tldraw/src/lib/shapes/line/line-helpers.ts similarity index 57% rename from packages/tldraw/src/lib/shapes/shared/polygon-helpers.ts rename to packages/tldraw/src/lib/shapes/line/line-helpers.ts index 66aba67fe..e9b6c21fd 100644 --- a/packages/tldraw/src/lib/shapes/shared/polygon-helpers.ts +++ b/packages/tldraw/src/lib/shapes/line/line-helpers.ts @@ -1,58 +1,5 @@ import { Vec, VecLike, precise, rng } from '@tldraw/editor' -/** @public */ -export function getRoundedInkyPolygonPath(points: VecLike[]) { - let polylineA = `M` - - const len = points.length - - let p0: VecLike - let p1: VecLike - let p2: VecLike - - for (let i = 0, n = len; i < n; i += 3) { - p0 = points[i] - p1 = points[i + 1] - p2 = points[i + 2] - - polylineA += `${precise(p0)}L${precise(p1)}Q${precise(p2)}` - } - - polylineA += `${precise(points[0])}` - - return polylineA -} - -/** @public */ -export function getRoundedPolygonPoints( - id: string, - outline: VecLike[], - offset: number, - roundness: number, - passes: number -) { - const results: VecLike[] = [] - - const random = rng(id) - let p0 = outline[0] - let p1: VecLike - - const len = outline.length - - for (let i = 0, n = len * passes; i < n; i++) { - p1 = Vec.AddXY(outline[(i + 1) % len], random() * offset, random() * offset) - - const delta = Vec.Sub(p1, p0) - const distance = Vec.Len(delta) - const vector = Vec.Div(delta, distance).mul(Math.min(distance / 4, roundness)) - results.push(Vec.Add(p0, vector), Vec.Add(p1, vector.neg()), p1) - - p0 = p1 - } - - return results -} - /** @public */ export function getDrawLinePathData(id: string, outline: VecLike[], strokeWidth: number) { let innerPathData = `M ${precise(outline[0])}L` diff --git a/packages/tldraw/src/lib/shapes/shared/getPerfectDashProps.ts b/packages/tldraw/src/lib/shapes/shared/getPerfectDashProps.ts index d3a36eeeb..676dace0b 100644 --- a/packages/tldraw/src/lib/shapes/shared/getPerfectDashProps.ts +++ b/packages/tldraw/src/lib/shapes/shared/getPerfectDashProps.ts @@ -70,16 +70,15 @@ export function getPerfectDashProps( dashCount -= dashCount % snap if (dashCount < 3 && style === 'dashed') { - if (totalLength / strokeWidth < 5) { + if (totalLength / strokeWidth < 4) { dashLength = totalLength dashCount = 1 gapLength = 0 } else { - dashLength = totalLength * 0.333 - gapLength = totalLength * 0.333 + dashLength = totalLength * (1 / 3) + gapLength = totalLength * (1 / 3) } } else { - dashCount = Math.max(dashCount, 3) dashLength = totalLength / dashCount / (2 * ratio) if (closed) { diff --git a/packages/tldraw/src/lib/styles.tsx b/packages/tldraw/src/lib/styles.tsx index 5b63b1215..f20ef457f 100644 --- a/packages/tldraw/src/lib/styles.tsx +++ b/packages/tldraw/src/lib/styles.tsx @@ -78,6 +78,7 @@ export const STYLES = { { value: 'cloud', icon: 'geo-cloud' }, { value: 'x-box', icon: 'geo-x-box' }, { value: 'check-box', icon: 'geo-check-box' }, + { value: 'heart', icon: 'geo-heart' }, ], arrowheadStart: [ { value: 'none', icon: 'arrowhead-none' }, diff --git a/packages/tldraw/src/lib/ui/components/DefaultDebugPanel.tsx b/packages/tldraw/src/lib/ui/components/DefaultDebugPanel.tsx index 030a0719c..d6bdb7d6b 100644 --- a/packages/tldraw/src/lib/ui/components/DefaultDebugPanel.tsx +++ b/packages/tldraw/src/lib/ui/components/DefaultDebugPanel.tsx @@ -41,13 +41,12 @@ const CurrentState = track(function CurrentState() { shape && path.includes('select.') ? ` / ${shape.type || ''}${ 'geo' in shape.props ? ' / ' + shape.props.geo : '' - } / [${Vec.ToFixed(editor.getPointInShapeSpace(shape, editor.inputs.currentPagePoint), 0)}]` + } / [${Vec.ToInt(editor.getPointInShapeSpace(shape, editor.inputs.currentPagePoint))}]` : '' const ruler = path.startsWith('select.') && !path.includes('.idle') - ? ` / [${Vec.ToFixed(editor.inputs.originPagePoint, 0)}] → [${Vec.ToFixed( - editor.inputs.currentPagePoint, - 0 + ? ` / [${Vec.ToInt(editor.inputs.originPagePoint)}] → [${Vec.ToInt( + editor.inputs.currentPagePoint )}] = ${Vec.Dist(editor.inputs.originPagePoint, editor.inputs.currentPagePoint).toFixed(0)}` : '' diff --git a/packages/tldraw/src/lib/ui/components/Toolbar/DefaultToolbarContent.tsx b/packages/tldraw/src/lib/ui/components/Toolbar/DefaultToolbarContent.tsx index 5a896f567..b39067455 100644 --- a/packages/tldraw/src/lib/ui/components/Toolbar/DefaultToolbarContent.tsx +++ b/packages/tldraw/src/lib/ui/components/Toolbar/DefaultToolbarContent.tsx @@ -18,12 +18,12 @@ export function DefaultToolbarContent() { - - - + + + @@ -54,204 +54,159 @@ export function useIsToolSelected(tool: TLUiToolItem) { } /** @public */ -export function SelectToolbarItem() { +export function ToolbarItem({ tool }: { tool: string }) { const tools = useTools() - const isSelected = useIsToolSelected(tools['select']) - return + const isSelected = useIsToolSelected(tools[tool]) + return +} + +/** @public */ +export function SelectToolbarItem() { + return } /** @public */ export function HandToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['hand']) - return + return } /** @public */ export function DrawToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['draw']) - return + return } /** @public */ export function EraserToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['eraser']) - return + return } /** @public */ export function ArrowToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['arrow']) - return + return } /** @public */ export function TextToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['text']) - return + return } /** @public */ export function NoteToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['note']) - return + return } /** @public */ export function AssetToolbarItem() { const tools = useTools() - const isSelected = useIsToolSelected(tools['asset']) - return + return } /** @public */ export function RectangleToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['rectangle']) - return + return } /** @public */ export function EllipseToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['ellipse']) - return + return } /** @public */ export function DiamondToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['diamond']) - return + return } /** @public */ export function TriangleToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['triangle']) - return + return } /** @public */ export function TrapezoidToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['trapezoid']) - return + return } /** @public */ export function RhombusToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['rhombus']) - return -} - -/** @public */ -export function HexagonToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['hexagon']) - return -} - -/** @public */ -export function CloudToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['cloud']) - return + return } /** @public */ export function PentagonToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['pentagon']) - return + return +} + +/** @public */ +export function HeartToolbarItem() { + return +} + +/** @public */ +export function HexagonToolbarItem() { + return +} + +/** @public */ +export function CloudToolbarItem() { + return } /** @public */ export function StarToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['star']) - return + return } /** @public */ export function OvalToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['oval']) - return + return } /** @public */ export function XBoxToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['x-box']) - return + return } /** @public */ export function CheckBoxToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['check-box']) - return + return } /** @public */ export function ArrowLeftToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['arrow-left']) - return + return } /** @public */ export function ArrowUpToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['arrow-up']) - return + return } /** @public */ export function ArrowDownToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['arrow-down']) - return + return } /** @public */ export function ArrowRightToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['arrow-right']) - return + return } /** @public */ export function LineToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['line']) - return + return } /** @public */ export function HighlightToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['highlight']) - return + return } /** @public */ export function FrameToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['frame']) - return + return } /** @public */ export function LaserToolbarItem() { - const tools = useTools() - const isSelected = useIsToolSelected(tools['laser']) - return + return } diff --git a/packages/tldraw/src/lib/ui/icon-types.ts b/packages/tldraw/src/lib/ui/icon-types.ts index 4a2902624..8b7c83862 100644 --- a/packages/tldraw/src/lib/ui/icon-types.ts +++ b/packages/tldraw/src/lib/ui/icon-types.ts @@ -68,6 +68,7 @@ export type TLUiIconType = | 'geo-cloud' | 'geo-diamond' | 'geo-ellipse' + | 'geo-heart' | 'geo-hexagon' | 'geo-octagon' | 'geo-oval' @@ -209,6 +210,7 @@ export const iconTypes = [ 'geo-cloud', 'geo-diamond', 'geo-ellipse', + 'geo-heart', 'geo-hexagon', 'geo-octagon', 'geo-oval', diff --git a/packages/tlschema/api-report.md b/packages/tlschema/api-report.md index b185fa019..f28222a5c 100644 --- a/packages/tlschema/api-report.md +++ b/packages/tlschema/api-report.md @@ -512,7 +512,7 @@ export const frameShapeProps: { }; // @public (undocumented) -export const GeoShapeGeoStyle: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; +export const GeoShapeGeoStyle: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "heart" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; // @public (undocumented) export const geoShapeMigrations: TLPropsMigrations; @@ -524,7 +524,7 @@ export const geoShapeProps: { dash: EnumStyleProp<"dashed" | "dotted" | "draw" | "solid">; fill: EnumStyleProp<"none" | "pattern" | "semi" | "solid">; font: EnumStyleProp<"draw" | "mono" | "sans" | "serif">; - geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; + geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "heart" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">; growY: T.Validator; h: T.Validator; labelColor: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">; diff --git a/packages/tlschema/src/shapes/TLGeoShape.ts b/packages/tlschema/src/shapes/TLGeoShape.ts index 174e0d405..cd7159734 100644 --- a/packages/tlschema/src/shapes/TLGeoShape.ts +++ b/packages/tlschema/src/shapes/TLGeoShape.ts @@ -37,6 +37,7 @@ export const GeoShapeGeoStyle = StyleProp.defineEnum('tldraw:geo', { 'arrow-down', 'x-box', 'check-box', + 'heart', ], })