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:
parent
6c9ead0309
commit
ef44d71ee2
56 changed files with 1403 additions and 1379 deletions
|
@ -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' },
|
||||
|
|
3
assets/icons/icon/geo-heart.svg
Normal file
3
assets/icons/icon/geo-heart.svg
Normal 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 |
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
1
packages/assets/types.d.ts
vendored
1
packages/assets/types.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -297,6 +297,7 @@ export {
|
|||
areAnglesCompatible,
|
||||
average,
|
||||
canonicalizeRotation,
|
||||
centerOfCircleFromThreePoints,
|
||||
clamp,
|
||||
clampRadians,
|
||||
clockwiseAngleDist,
|
||||
|
@ -305,6 +306,7 @@ export {
|
|||
getArcMeasure,
|
||||
getPointInArcT,
|
||||
getPointOnCircle,
|
||||
getPointsOnArc,
|
||||
getPolygonVertices,
|
||||
isSafeFloat,
|
||||
perimeterOfEllipse,
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()}`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(' ')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()}`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`
|
||||
}, '')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -407,6 +407,7 @@ export {
|
|||
SelectToolbarItem,
|
||||
StarToolbarItem,
|
||||
TextToolbarItem,
|
||||
ToolbarItem,
|
||||
TrapezoidToolbarItem,
|
||||
TriangleToolbarItem,
|
||||
XBoxToolbarItem,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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))
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
)
|
||||
})
|
588
packages/tldraw/src/lib/shapes/geo/geo-shape-helpers.ts
Normal file
588
packages/tldraw/src/lib/shapes/geo/geo-shape-helpers.ts
Normal 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'
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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`
|
|
@ -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) {
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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)}`
|
||||
: ''
|
||||
|
||||
|
|
|
@ -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" />
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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">;
|
||||
|
|
|
@ -37,6 +37,7 @@ export const GeoShapeGeoStyle = StyleProp.defineEnum('tldraw:geo', {
|
|||
'arrow-down',
|
||||
'x-box',
|
||||
'check-box',
|
||||
'heart',
|
||||
],
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue