Add heart geo shape (#3787)

This PR adds a heart geo shape. ❤️

It also:
- adds `toSvgPathData` to geometry2d
- uses geometry2d in places where previously we recalculated things like
perimeter of ellipse
- flattens geo shape util components

- [x] Calculate the path length for the DashStyleHeart 

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Release Notes

- Adds a heart shape to the geo shape set.
This commit is contained in:
Steve Ruiz 2024-05-24 14:04:28 +01:00 committed by GitHub
parent 6c9ead0309
commit ef44d71ee2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1403 additions and 1379 deletions

View file

@ -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' },

View file

@ -0,0 +1,3 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10.1181C2 0.953581 14.025 0.953581 15 8.30928C15.975 0.953581 28 0.953581 28 10.1181C28 17.9561 18.25 20.9707 15 27C11.75 20.9707 2 17.9561 2 10.1181Z" stroke="#1D1D1D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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<Geometry2dOptions, 'isClosed'> & {
@ -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<Geometry2dOptions, 'isClosed'> & {
height: number;
width: number;
});
// (undocumented)
a: Arc2d;
// (undocumented)
b: Edge2d;
// (undocumented)
c: Arc2d;
// (undocumented)
config: Omit<Geometry2dOptions, 'isClosed'> & {
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;

View file

@ -297,6 +297,7 @@ export {
areAnglesCompatible,
average,
canonicalizeRotation,
centerOfCircleFromThreePoints,
clamp,
clampRadians,
clockwiseAngleDist,
@ -305,6 +306,7 @@ export {
getArcMeasure,
getPointInArcT,
getPointOnCircle,
getPointsOnArc,
getPolygonVertices,
isSafeFloat,
perimeterOfEllipse,

View file

@ -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))
})
})

View file

@ -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) {

View file

@ -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<Geometry2dOptions, 'isFilled' | 'isClosed'> & {
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
}
}

View file

@ -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`
}
}

View file

@ -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,9 +69,13 @@ export class CubicBezier2d extends Polyline2d {
if (!nearest) throw Error('nearest point not found')
return nearest
}
getSvgPathData(first = true) {
const { a, b, c, d } = this
return `${first ? `M ${a.toFixed()} ` : ``} C${b.toFixed()} ${c.toFixed()} ${d.toFixed()}`
}
function getAtT(segment: CubicBezier2d, t: number) {
static GetAtT(segment: CubicBezier2d, t: number) {
const { a, b, c, d } = segment
return new Vec(
(1 - t) * (1 - t) * (1 - t) * a.x +
@ -84,3 +88,16 @@ function getAtT(segment: CubicBezier2d, t: number) {
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
}
}

View file

@ -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
}
}

View file

@ -22,15 +22,9 @@ 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) {
override getLength() {
return this.d.len()
}
return this._length
}
midPoint(): Vec {
return this.start.lrp(this.end, 0.5)
@ -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()}`
}
}

View file

@ -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`
}
}

View file

@ -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
}

View file

@ -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(' ')
}
}

View file

@ -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()}`
}
}

View file

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

View file

@ -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`
}
}

View file

@ -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<Geometry2dOptions, 'isClosed'> & { width: number; height: number }
public config: Omit<Geometry2dOptions, 'isClosed'> & {
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<Vec[]>((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'
}
}

View file

@ -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
}

View file

@ -702,7 +702,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
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<TLGeoShape> {
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<TLGeoShape> {
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<number>;
h: Validator<number>;
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<HTMLDivElement> {
}
// @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;

View file

@ -407,6 +407,7 @@ export {
SelectToolbarItem,
StarToolbarItem,
TextToolbarItem,
ToolbarItem,
TrapezoidToolbarItem,
TriangleToolbarItem,
XBoxToolbarItem,

View file

@ -121,7 +121,6 @@ export class ArrowShapeUtil extends ShapeUtil<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,

View file

@ -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,

View file

@ -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)

View file

@ -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<TLGeoShape> {
})
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<Vec[]>((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<TLGeoShape> {
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<TLGeoShape> {
const strokeWidth = STROKE_SIZES[size]
const geometry = this.editor.getShapeGeometry(shape)
switch (props.geo) {
case 'ellipse': {
if (props.dash === 'draw') {
return <path d={getEllipseIndicatorPath(id, w, h, strokeWidth)} />
return <path d={getEllipseDrawIndicatorPath(id, w, h, strokeWidth)} />
}
return <ellipse cx={w / 2} cy={h / 2} rx={w / 2} ry={h / 2} />
return <path d={geometry.getSvgPathData(true)} />
}
case 'heart': {
return <path d={getHeartPath(w, h)} />
}
case 'oval': {
return <path d={getOvalIndicatorPath(w, h)} />
return <path d={geometry.getSvgPathData(true)} />
}
case 'cloud': {
return <path d={cloudSvgPath(w, h, id, size)} />
return <path d={getCloudPath(w, h, id, size)} />
}
default: {

View file

@ -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 = <T>(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
}

View file

@ -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<TLGeoShape['props'], 'dash' | 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const innerPath = cloudSvgPath(w, h, id, size)
const arcs = getCloudArcs(w, h, id, size)
return (
<>
<ShapeFill theme={theme} d={innerPath} fill={fill} color={color} />
<g strokeWidth={strokeWidth} stroke={theme[color].solid} fill="none" pointerEvents="all">
{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 (
<path
key={i}
d={
center
? `M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}`
: `M${leftPoint.x},${leftPoint.y}L${rightPoint.x},${rightPoint.y}`
}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</>
)
})

View file

@ -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<TLGeoShape['props'], 'w' | 'h' | 'dash' | 'color' | 'fill'> & {
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 (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path
d={d}
strokeWidth={sw}
width={toDomPrecision(w)}
height={toDomPrecision(h)}
fill="none"
stroke={theme[color].solid}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all"
/>
</>
)
})

View file

@ -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<TLGeoShape['props'], 'w' | 'h' | 'dash' | 'color' | 'fill'> & {
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 (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path
d={d}
strokeWidth={sw}
width={toDomPrecision(w)}
height={toDomPrecision(h)}
fill="none"
stroke={theme[color].solid}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all"
/>
</>
)
})

View file

@ -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<TLGeoShape['props'], 'dash' | 'fill' | 'color'> & {
strokeWidth: number
outline: VecLike[]
lines?: VecLike[][]
}) {
const theme = useDefaultColorTheme()
const innerPath = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z'
return (
<>
<ShapeFill theme={theme} d={innerPath} fill={fill} color={color} />
<g strokeWidth={strokeWidth} stroke={theme[color].solid} fill="none" pointerEvents="all">
{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 (
<line
key={i}
x1={A.x}
y1={A.y}
x2={B.x}
y2={B.y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
{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 (
<path
key={`line_fg_${i}`}
d={`M${A.x},${A.y}L${B.x},${B.y}`}
stroke={theme[color].solid}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</>
)
})

View file

@ -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<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const path = inkyCloudSvgPath(w, h, id, size)
return (
<>
<ShapeFill theme={theme} d={path} fill={fill} color={color} />
<path d={path} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})

View file

@ -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))
}

View file

@ -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<TLGeoShape['props'], 'fill' | 'color'> & {
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 (
<>
<ShapeFill d={innerPathData} fill={fill} color={color} theme={theme} />
<path d={strokePathData} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})

View file

@ -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 (
<SolidStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
dash={dash}
/>
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
} else if (dash === 'draw') {
const d = inkyCloudSvgPath(w, h, id, size)
return (
<DrawStyleCloud
color={color}
fill={fill}
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
} else {
const innerPath = getCloudPath(w, h, id, size)
const arcs = getCloudArcs(w, h, id, size)
return (
<>
<ShapeFill theme={theme} d={innerPath} color={color} fill={fill} />
<g
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
stroke={theme[color].solid}
fill="none"
pointerEvents="all"
>
{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 (
<path
key={i}
d={
center
? `M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}`
: `M${leftPoint.x},${leftPoint.y}L${rightPoint.x},${rightPoint.y}`
}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</>
)
}
break
}
case 'ellipse': {
if (dash === 'solid') {
return <SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleEllipse
id={id}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
/>
)
} else if (dash === 'draw') {
return <SolidStyleEllipse strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
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 (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path
d={d}
strokeWidth={strokeWidth}
fill="none"
stroke={theme[color].solid}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
</>
)
} else {
const geometry = editor.getShapeGeometry(shape)
const d = geometry.getSvgPathData(true)
return (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
}
break
}
case 'oval': {
if (dash === 'solid') {
return <SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
} else if (dash === 'dashed' || dash === 'dotted') {
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 (
<DashStyleOval
id={id}
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path
d={d}
strokeWidth={strokeWidth}
w={w}
h={h}
dash={dash}
color={color}
fill={fill}
fill="none"
stroke={theme[color].solid}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
</>
)
} else {
return (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
}
}
case 'heart': {
if (dash === 'dashed' || dash === 'dotted') {
const d = getHeartPath(w, h)
const curves = getHeartParts(w, h)
return (
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
{curves.map((c, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
c.length,
strokeWidth,
{
style: dash,
snap: 1,
start: 'outset',
end: 'outset',
closed: true,
}
)
return (
<path
key={`curve_${i}`}
d={c.getSvgPathData()}
strokeWidth={strokeWidth}
fill="none"
stroke={theme[color].solid}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all"
/>
)
} else if (dash === 'draw') {
return <SolidStyleOval strokeWidth={strokeWidth} w={w} h={h} color={color} fill={fill} />
})}
</>
)
} else {
const d = getDrawHeartPath(w, h, strokeWidth, shape.id)
return (
<>
<ShapeFill d={d} color={color} fill={fill} theme={theme} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
}
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 (
<SolidStylePolygon
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
<>
<ShapeFill theme={theme} d={d} color={color} fill={fill} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
} else if (dash === 'dashed' || dash === 'dotted') {
const innerPath = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z'
return (
<DashStylePolygon
dash={dash}
fill={fill}
color={color}
<>
<ShapeFill theme={theme} d={innerPath} fill={fill} color={color} />
<g
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
stroke={theme[color].solid}
fill="none"
pointerEvents="all"
>
{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 (
<line
key={i}
x1={A.x}
y1={A.y}
x2={B.x}
y2={B.y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
{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 (
<path
key={`line_fg_${i}`}
d={`M${A.x},${A.y}L${B.x},${B.y}`}
stroke={theme[color].solid}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</>
)
} 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 (
<DrawStylePolygon
id={id}
fill={fill}
color={color}
strokeWidth={strokeWidth}
outline={outline}
lines={lines}
/>
<>
<ShapeFill d={innerPathData} fill={fill} color={color} theme={theme} />
<path d={d} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
}
}

View file

@ -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<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const path = cloudSvgPath(w, h, id, size)
return (
<>
<ShapeFill theme={theme} d={path} fill={fill} color={color} />
<path d={path} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})

View file

@ -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<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & { 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 (
<>
<ShapeFill d={d} color={color} fill={fill} theme={theme} />
<path d={d} stroke={theme[color].solid} strokeWidth={sw} fill="none" />
</>
)
})

View file

@ -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<TLGeoShape['props'], 'w' | 'h' | 'fill' | 'color'> & {
strokeWidth: number
}) {
const theme = useDefaultColorTheme()
const d = getOvalIndicatorPath(w, h)
return (
<>
<ShapeFill d={d} color={color} fill={fill} theme={theme} />
<path d={d} stroke={theme[color].solid} strokeWidth={sw} fill="none" />
</>
)
})
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
}

View file

@ -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<TLGeoShape['props'], 'fill' | 'color'> & {
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 (
<>
<ShapeFill d={path} fill={fill} color={color} theme={theme} />
<path d={path} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})

View file

@ -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<TLDefaultSizeStyle, number> = {
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<TLDefaultSizeStyle, number> = {
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'
}

View file

@ -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
}

View file

@ -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<TLLineShape['props'], TLHandle[]>()
@ -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"
/>
)

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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`

View file

@ -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) {

View file

@ -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' },

View file

@ -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)}`
: ''

View file

@ -18,12 +18,12 @@ export function DefaultToolbarContent() {
<EllipseToolbarItem />
<TriangleToolbarItem />
<DiamondToolbarItem />
<CloudToolbarItem />
<StarToolbarItem />
<HexagonToolbarItem />
<OvalToolbarItem />
<TrapezoidToolbarItem />
<RhombusToolbarItem />
<StarToolbarItem />
<CloudToolbarItem />
<HeartToolbarItem />
<XBoxToolbarItem />
<CheckBoxToolbarItem />
<ArrowLeftToolbarItem />
@ -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 <TldrawUiMenuItem {...tools['select']} isSelected={isSelected} />
const isSelected = useIsToolSelected(tools[tool])
return <TldrawUiMenuItem {...tools[tool]} isSelected={isSelected} />
}
/** @public */
export function SelectToolbarItem() {
return <ToolbarItem tool="select" />
}
/** @public */
export function HandToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['hand'])
return <TldrawUiMenuItem {...tools['hand']} isSelected={isSelected} />
return <ToolbarItem tool="hand" />
}
/** @public */
export function DrawToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['draw'])
return <TldrawUiMenuItem {...tools['draw']} isSelected={isSelected} />
return <ToolbarItem tool="draw" />
}
/** @public */
export function EraserToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['eraser'])
return <TldrawUiMenuItem {...tools['eraser']} isSelected={isSelected} />
return <ToolbarItem tool="eraser" />
}
/** @public */
export function ArrowToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['arrow'])
return <TldrawUiMenuItem {...tools['arrow']} isSelected={isSelected} />
return <ToolbarItem tool="arrow" />
}
/** @public */
export function TextToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['text'])
return <TldrawUiMenuItem {...tools['text']} isSelected={isSelected} />
return <ToolbarItem tool="text" />
}
/** @public */
export function NoteToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['note'])
return <TldrawUiMenuItem {...tools['note']} isSelected={isSelected} />
return <ToolbarItem tool="note" />
}
/** @public */
export function AssetToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['asset'])
return <TldrawUiMenuItem {...tools['asset']} isSelected={isSelected} />
return <TldrawUiMenuItem {...tools['asset']} />
}
/** @public */
export function RectangleToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['rectangle'])
return <TldrawUiMenuItem {...tools['rectangle']} isSelected={isSelected} />
return <ToolbarItem tool="rectangle" />
}
/** @public */
export function EllipseToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['ellipse'])
return <TldrawUiMenuItem {...tools['ellipse']} isSelected={isSelected} />
return <ToolbarItem tool="ellipse" />
}
/** @public */
export function DiamondToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['diamond'])
return <TldrawUiMenuItem {...tools['diamond']} isSelected={isSelected} />
return <ToolbarItem tool="diamond" />
}
/** @public */
export function TriangleToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['triangle'])
return <TldrawUiMenuItem {...tools['triangle']} isSelected={isSelected} />
return <ToolbarItem tool="triangle" />
}
/** @public */
export function TrapezoidToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['trapezoid'])
return <TldrawUiMenuItem {...tools['trapezoid']} isSelected={isSelected} />
return <ToolbarItem tool="trapezoid" />
}
/** @public */
export function RhombusToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['rhombus'])
return <TldrawUiMenuItem {...tools['rhombus']} isSelected={isSelected} />
}
/** @public */
export function HexagonToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['hexagon'])
return <TldrawUiMenuItem {...tools['hexagon']} isSelected={isSelected} />
}
/** @public */
export function CloudToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['cloud'])
return <TldrawUiMenuItem {...tools['cloud']} isSelected={isSelected} />
return <ToolbarItem tool="rhombus" />
}
/** @public */
export function PentagonToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['pentagon'])
return <TldrawUiMenuItem {...tools['pentagon']} isSelected={isSelected} />
return <ToolbarItem tool="pentagon" />
}
/** @public */
export function HeartToolbarItem() {
return <ToolbarItem tool="heart" />
}
/** @public */
export function HexagonToolbarItem() {
return <ToolbarItem tool="hexagon" />
}
/** @public */
export function CloudToolbarItem() {
return <ToolbarItem tool="cloud" />
}
/** @public */
export function StarToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['star'])
return <TldrawUiMenuItem {...tools['star']} isSelected={isSelected} />
return <ToolbarItem tool="star" />
}
/** @public */
export function OvalToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['oval'])
return <TldrawUiMenuItem {...tools['oval']} isSelected={isSelected} />
return <ToolbarItem tool="oval" />
}
/** @public */
export function XBoxToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['x-box'])
return <TldrawUiMenuItem {...tools['x-box']} isSelected={isSelected} />
return <ToolbarItem tool="x-box" />
}
/** @public */
export function CheckBoxToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['check-box'])
return <TldrawUiMenuItem {...tools['check-box']} isSelected={isSelected} />
return <ToolbarItem tool="check-box" />
}
/** @public */
export function ArrowLeftToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['arrow-left'])
return <TldrawUiMenuItem {...tools['arrow-left']} isSelected={isSelected} />
return <ToolbarItem tool="arrow-left" />
}
/** @public */
export function ArrowUpToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['arrow-up'])
return <TldrawUiMenuItem {...tools['arrow-up']} isSelected={isSelected} />
return <ToolbarItem tool="arrow-up" />
}
/** @public */
export function ArrowDownToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['arrow-down'])
return <TldrawUiMenuItem {...tools['arrow-down']} isSelected={isSelected} />
return <ToolbarItem tool="arrow-down" />
}
/** @public */
export function ArrowRightToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['arrow-right'])
return <TldrawUiMenuItem {...tools['arrow-right']} isSelected={isSelected} />
return <ToolbarItem tool="arrow-right" />
}
/** @public */
export function LineToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['line'])
return <TldrawUiMenuItem {...tools['line']} isSelected={isSelected} />
return <ToolbarItem tool="line" />
}
/** @public */
export function HighlightToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['highlight'])
return <TldrawUiMenuItem {...tools['highlight']} isSelected={isSelected} />
return <ToolbarItem tool="highlight" />
}
/** @public */
export function FrameToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['frame'])
return <TldrawUiMenuItem {...tools['frame']} isSelected={isSelected} />
return <ToolbarItem tool="frame" />
}
/** @public */
export function LaserToolbarItem() {
const tools = useTools()
const isSelected = useIsToolSelected(tools['laser'])
return <TldrawUiMenuItem {...tools['laser']} isSelected={isSelected} />
return <ToolbarItem tool="laser" />
}

View file

@ -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',

View file

@ -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<number>;
h: T.Validator<number>;
labelColor: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "white" | "yellow">;

View file

@ -37,6 +37,7 @@ export const GeoShapeGeoStyle = StyleProp.defineEnum('tldraw:geo', {
'arrow-down',
'x-box',
'check-box',
'heart',
],
})