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',
],
})