@tldraw/editor 3.13.0-canary.6b583349a15f → 3.13.0-canary.70e156551c5c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist-cjs/index.d.ts +84 -97
  2. package/dist-cjs/index.js +7 -22
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/components/Shape.js +2 -1
  5. package/dist-cjs/lib/components/Shape.js.map +2 -2
  6. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js +17 -11
  7. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  8. package/dist-cjs/lib/editor/Editor.js +32 -13
  9. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/TextManager.js +10 -0
  12. package/dist-cjs/lib/editor/managers/TextManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  14. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  15. package/dist-cjs/lib/primitives/Box.js +16 -0
  16. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  17. package/dist-cjs/lib/primitives/Mat.js +1 -1
  18. package/dist-cjs/lib/primitives/Mat.js.map +2 -2
  19. package/dist-cjs/lib/primitives/Vec.js +20 -0
  20. package/dist-cjs/lib/primitives/Vec.js.map +2 -2
  21. package/dist-cjs/lib/primitives/geometry/Arc2d.js +2 -2
  22. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  23. package/dist-cjs/lib/primitives/geometry/Circle2d.js +1 -1
  24. package/dist-cjs/lib/primitives/geometry/Circle2d.js.map +2 -2
  25. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  26. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  27. package/dist-cjs/lib/primitives/geometry/CubicSpline2d.js.map +2 -2
  28. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  29. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  30. package/dist-cjs/lib/primitives/geometry/Ellipse2d.js.map +2 -2
  31. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +91 -20
  32. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  33. package/dist-cjs/lib/primitives/geometry/Group2d.js +55 -2
  34. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  35. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  36. package/dist-cjs/lib/primitives/geometry/Polyline2d.js.map +2 -2
  37. package/dist-cjs/lib/primitives/geometry/Stadium2d.js.map +2 -2
  38. package/dist-cjs/lib/utils/areShapesContentEqual.js +25 -0
  39. package/dist-cjs/lib/utils/areShapesContentEqual.js.map +7 -0
  40. package/dist-cjs/lib/utils/debug-flags.js +5 -2
  41. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  42. package/dist-cjs/version.js +3 -3
  43. package/dist-cjs/version.js.map +1 -1
  44. package/dist-esm/index.d.mts +84 -97
  45. package/dist-esm/index.mjs +9 -41
  46. package/dist-esm/index.mjs.map +2 -2
  47. package/dist-esm/lib/components/Shape.mjs +2 -1
  48. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  49. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs +17 -11
  50. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  51. package/dist-esm/lib/editor/Editor.mjs +32 -13
  52. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  54. package/dist-esm/lib/editor/managers/TextManager.mjs +10 -0
  55. package/dist-esm/lib/editor/managers/TextManager.mjs.map +2 -2
  56. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  57. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  58. package/dist-esm/lib/primitives/Box.mjs +16 -0
  59. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  60. package/dist-esm/lib/primitives/Mat.mjs +1 -1
  61. package/dist-esm/lib/primitives/Mat.mjs.map +2 -2
  62. package/dist-esm/lib/primitives/Vec.mjs +20 -0
  63. package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
  64. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +2 -2
  65. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  66. package/dist-esm/lib/primitives/geometry/Circle2d.mjs +1 -1
  67. package/dist-esm/lib/primitives/geometry/Circle2d.mjs.map +2 -2
  68. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  69. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  70. package/dist-esm/lib/primitives/geometry/CubicSpline2d.mjs.map +2 -2
  71. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  72. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  73. package/dist-esm/lib/primitives/geometry/Ellipse2d.mjs.map +2 -2
  74. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +92 -21
  75. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  76. package/dist-esm/lib/primitives/geometry/Group2d.mjs +55 -2
  77. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  78. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  79. package/dist-esm/lib/primitives/geometry/Polyline2d.mjs.map +2 -2
  80. package/dist-esm/lib/primitives/geometry/Stadium2d.mjs.map +2 -2
  81. package/dist-esm/lib/utils/areShapesContentEqual.mjs +5 -0
  82. package/dist-esm/lib/utils/areShapesContentEqual.mjs.map +7 -0
  83. package/dist-esm/lib/utils/debug-flags.mjs +5 -2
  84. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  85. package/dist-esm/version.mjs +3 -3
  86. package/dist-esm/version.mjs.map +1 -1
  87. package/editor.css +29 -0
  88. package/package.json +7 -7
  89. package/src/index.ts +16 -31
  90. package/src/lib/components/Shape.tsx +2 -4
  91. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +17 -8
  92. package/src/lib/editor/Editor.test.ts +1 -1
  93. package/src/lib/editor/Editor.ts +32 -13
  94. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +0 -1
  95. package/src/lib/editor/managers/TextManager.ts +12 -0
  96. package/src/lib/editor/shapes/ShapeUtil.ts +9 -1
  97. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +9 -9
  98. package/src/lib/primitives/Box.ts +20 -0
  99. package/src/lib/primitives/Mat.ts +5 -4
  100. package/src/lib/primitives/Vec.ts +23 -0
  101. package/src/lib/primitives/geometry/Arc2d.ts +5 -5
  102. package/src/lib/primitives/geometry/Circle2d.ts +4 -4
  103. package/src/lib/primitives/geometry/CubicBezier2d.ts +4 -4
  104. package/src/lib/primitives/geometry/CubicSpline2d.ts +3 -3
  105. package/src/lib/primitives/geometry/Edge2d.ts +3 -3
  106. package/src/lib/primitives/geometry/Ellipse2d.ts +3 -3
  107. package/src/lib/primitives/geometry/Geometry2d.test.ts +42 -0
  108. package/src/lib/primitives/geometry/Geometry2d.ts +123 -35
  109. package/src/lib/primitives/geometry/Group2d.ts +70 -7
  110. package/src/lib/primitives/geometry/Point2d.ts +2 -2
  111. package/src/lib/primitives/geometry/Polyline2d.ts +3 -3
  112. package/src/lib/primitives/geometry/Stadium2d.ts +3 -3
  113. package/src/lib/test/currentToolIdMask.test.ts +1 -1
  114. package/src/lib/test/user.test.ts +1 -1
  115. package/src/lib/utils/areShapesContentEqual.ts +4 -0
  116. package/src/lib/utils/debug-flags.ts +7 -2
  117. package/src/lib/utils/sync/LocalIndexedDb.test.ts +1 -1
  118. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +1 -1
  119. package/src/version.ts +3 -3
@@ -9,13 +9,21 @@ import { useEditorComponents } from '../../hooks/useEditorComponents'
9
9
  import { OptionalErrorBoundary } from '../ErrorBoundary'
10
10
 
11
11
  // need an extra layer of indirection here to allow hooks to be used inside the indicator render
12
- const EvenInnererIndicator = memo(({ shape, util }: { shape: TLShape; util: ShapeUtil<any> }) => {
13
- return useStateTracking('Indicator: ' + shape.type, () =>
14
- // always fetch the latest shape from the store even if the props/meta have not changed, to avoid
15
- // calling the render method with stale data.
16
- util.indicator(util.editor.store.unsafeGetWithoutCapture(shape.id) as TLShape)
17
- )
18
- })
12
+ const EvenInnererIndicator = memo(
13
+ ({ shape, util }: { shape: TLShape; util: ShapeUtil<any> }) => {
14
+ return useStateTracking('Indicator: ' + shape.type, () =>
15
+ // always fetch the latest shape from the store even if the props/meta have not changed, to avoid
16
+ // calling the render method with stale data.
17
+ util.indicator(util.editor.store.unsafeGetWithoutCapture(shape.id) as TLShape)
18
+ )
19
+ },
20
+ (prevProps, nextProps) => {
21
+ return (
22
+ prevProps.shape.props === nextProps.shape.props &&
23
+ prevProps.shape.meta === nextProps.shape.meta
24
+ )
25
+ }
26
+ )
19
27
 
20
28
  const InnerIndicator = memo(({ editor, id }: { editor: Editor; id: TLShapeId }) => {
21
29
  const shape = useValue('shape for indicator', () => editor.store.get(id), [editor, id])
@@ -61,13 +69,14 @@ export const DefaultShapeIndicator = memo(function DefaultShapeIndicator({
61
69
  useQuickReactor(
62
70
  'indicator transform',
63
71
  () => {
72
+ if (hidden) return
64
73
  const elm = rIndicator.current
65
74
  if (!elm) return
66
75
  const pageTransform = editor.getShapePageTransform(shapeId)
67
76
  if (!pageTransform) return
68
77
  elm.style.setProperty('transform', pageTransform.toCssString())
69
78
  },
70
- [editor, shapeId]
79
+ [editor, shapeId, hidden]
71
80
  )
72
81
 
73
82
  useLayoutEffect(() => {
@@ -52,7 +52,7 @@ beforeEach(() => {
52
52
  shapeUtils: [CustomShape],
53
53
  bindingUtils: [],
54
54
  tools: [],
55
- store: createTLStore({ shapeUtils: [CustomShape] }),
55
+ store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
56
56
  getContainer: () => document.body,
57
57
  })
58
58
  editor.setCameraOptions({ isLocked: true })
@@ -129,6 +129,7 @@ import { Group2d } from '../primitives/geometry/Group2d'
129
129
  import { intersectPolygonPolygon } from '../primitives/intersect'
130
130
  import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils'
131
131
  import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap'
132
+ import { areShapesContentEqual } from '../utils/areShapesContentEqual'
132
133
  import { dataUrlToFile } from '../utils/assets'
133
134
  import { debugFlags } from '../utils/debug-flags'
134
135
  import {
@@ -2275,13 +2276,15 @@ export class Editor extends EventEmitter<TLEventMap> {
2275
2276
  setEditingShape(shape: TLShapeId | TLShape | null): this {
2276
2277
  const id = typeof shape === 'string' ? shape : (shape?.id ?? null)
2277
2278
  this.setRichTextEditor(null)
2278
- if (id !== this.getEditingShapeId()) {
2279
+ const prevEditingShapeId = this.getEditingShapeId()
2280
+ if (id !== prevEditingShapeId) {
2279
2281
  if (id) {
2280
2282
  const shape = this.getShape(id)
2281
2283
  if (shape && this.getShapeUtil(shape).canEdit(shape)) {
2282
2284
  this.run(
2283
2285
  () => {
2284
2286
  this._updateCurrentPageState({ editingShapeId: id })
2287
+ this.getShapeUtil(shape).onEditStart?.(shape)
2285
2288
  },
2286
2289
  { history: 'ignore' }
2287
2290
  )
@@ -2294,6 +2297,12 @@ export class Editor extends EventEmitter<TLEventMap> {
2294
2297
  () => {
2295
2298
  this._updateCurrentPageState({ editingShapeId: null })
2296
2299
  this._currentRichTextEditor.set(null)
2300
+ if (prevEditingShapeId) {
2301
+ const prevEditingShape = this.getShape(prevEditingShapeId)
2302
+ if (prevEditingShape) {
2303
+ this.getShapeUtil(prevEditingShape).onEditEnd?.(prevEditingShape)
2304
+ }
2305
+ }
2297
2306
  },
2298
2307
  { history: 'ignore' }
2299
2308
  )
@@ -4574,7 +4583,7 @@ export class Editor extends EventEmitter<TLEventMap> {
4574
4583
  this.fonts.trackFontsForShape(shape)
4575
4584
  return this.getShapeUtil(shape).getGeometry(shape, opts)
4576
4585
  },
4577
- { areRecordsEqual: (a, b) => a.props === b.props }
4586
+ { areRecordsEqual: areShapesContentEqual }
4578
4587
  )
4579
4588
  }
4580
4589
  return this._shapeGeometryCaches[context].get(
@@ -4622,9 +4631,15 @@ export class Editor extends EventEmitter<TLEventMap> {
4622
4631
 
4623
4632
  /** @internal */
4624
4633
  @computed private _getShapeHandlesCache(): ComputedCache<TLHandle[] | undefined, TLShape> {
4625
- return this.store.createComputedCache('handles', (shape) => {
4626
- return this.getShapeUtil(shape).getHandles?.(shape)
4627
- })
4634
+ return this.store.createComputedCache(
4635
+ 'handles',
4636
+ (shape) => {
4637
+ return this.getShapeUtil(shape).getHandles?.(shape)
4638
+ },
4639
+ {
4640
+ areRecordsEqual: areShapesContentEqual,
4641
+ }
4642
+ )
4628
4643
  }
4629
4644
 
4630
4645
  /**
@@ -5845,9 +5860,15 @@ export class Editor extends EventEmitter<TLEventMap> {
5845
5860
  @computed
5846
5861
  private _getBindingsIndexCache() {
5847
5862
  const index = bindingsIndex(this)
5848
- return this.store.createComputedCache<TLBinding[], TLShape>('bindingsIndex', (shape) => {
5849
- return index.get().get(shape.id)
5850
- })
5863
+ return this.store.createComputedCache<TLBinding[], TLShape>(
5864
+ 'bindingsIndex',
5865
+ (shape) => {
5866
+ return index.get().get(shape.id)
5867
+ },
5868
+ // we can ignore the shape equality check here because the index is
5869
+ // computed incrementally based on what bindings are in the store
5870
+ { areRecordsEqual: () => true }
5871
+ )
5851
5872
  }
5852
5873
 
5853
5874
  /**
@@ -10330,12 +10351,10 @@ export class Editor extends EventEmitter<TLEventMap> {
10330
10351
  if (this.inputs.isPanning && this.inputs.isPointing) {
10331
10352
  // Handle spacebar / middle mouse button panning
10332
10353
  const { currentScreenPoint, previousScreenPoint } = this.inputs
10333
- const { panSpeed } = cameraOptions
10334
10354
  const offset = Vec.Sub(currentScreenPoint, previousScreenPoint)
10335
- this.setCamera(
10336
- new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz),
10337
- { immediate: true }
10338
- )
10355
+ this.setCamera(new Vec(cx + offset.x / cz, cy + offset.y / cz, cz), {
10356
+ immediate: true,
10357
+ })
10339
10358
  this.maybeTrackPerformance('Panning')
10340
10359
  return
10341
10360
  }
@@ -45,7 +45,6 @@ export interface HandleSnapGeometry {
45
45
 
46
46
  const defaultGetSelfSnapOutline = () => null
47
47
  const defaultGetSelfSnapPoints = () => []
48
-
49
48
  /** @public */
50
49
  export class HandleSnaps {
51
50
  readonly editor: Editor
@@ -32,6 +32,7 @@ export interface TLMeasureTextSpanOpts {
32
32
  fontStyle: string
33
33
  lineHeight: number
34
34
  textAlign: TLDefaultHorizontalAlignStyle
35
+ otherStyles?: Record<string, string>
35
36
  }
36
37
 
37
38
  const spaceCharacterRegex = /\s/
@@ -86,6 +87,7 @@ export class TextManager {
86
87
  */
87
88
  maxWidth: null | number
88
89
  minWidth?: null | number
90
+ otherStyles?: Record<string, string>
89
91
  padding: string
90
92
  disableOverflowWrapBreaking?: boolean
91
93
  }
@@ -112,6 +114,11 @@ export class TextManager {
112
114
  'overflow-wrap',
113
115
  opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
114
116
  )
117
+ if (opts.otherStyles) {
118
+ for (const [key, value] of Object.entries(opts.otherStyles)) {
119
+ wrapperElm.style.setProperty(key, value)
120
+ }
121
+ }
115
122
 
116
123
  const scrollWidth = wrapperElm.scrollWidth
117
124
  const rect = wrapperElm.getBoundingClientRect()
@@ -256,6 +263,11 @@ export class TextManager {
256
263
  elm.style.setProperty('line-height', `${opts.lineHeight * opts.fontSize}px`)
257
264
  elm.style.setProperty('text-align', textAlignmentsForLtr[opts.textAlign])
258
265
  elm.style.setProperty('font-style', opts.fontStyle)
266
+ if (opts.otherStyles) {
267
+ for (const [key, value] of Object.entries(opts.otherStyles)) {
268
+ elm.style.setProperty(key, value)
269
+ }
270
+ }
259
271
 
260
272
  const shouldTruncateToFirstLine =
261
273
  opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
@@ -707,7 +707,15 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
707
707
  onClick?(shape: Shape): TLShapePartial<Shape> | void
708
708
 
709
709
  /**
710
- * A callback called when a shape finishes being editing.
710
+ * A callback called when a shape starts being edited.
711
+ *
712
+ * @param shape - The shape.
713
+ * @public
714
+ */
715
+ onEditStart?(shape: Shape): void
716
+
717
+ /**
718
+ * A callback called when a shape finishes being edited.
711
719
  *
712
720
  * @param shape - The shape.
713
721
  * @public
@@ -4,15 +4,15 @@ import { TLDefaultDashStyle } from '@tldraw/tlschema'
4
4
  export function getPerfectDashProps(
5
5
  totalLength: number,
6
6
  strokeWidth: number,
7
- opts = {} as Partial<{
8
- style: TLDefaultDashStyle
9
- snap: number
10
- end: 'skip' | 'outset' | 'none'
11
- start: 'skip' | 'outset' | 'none'
12
- lengthRatio: number
13
- closed: boolean
14
- forceSolid: boolean
15
- }>
7
+ opts: {
8
+ style?: TLDefaultDashStyle
9
+ snap?: number
10
+ end?: 'skip' | 'outset' | 'none'
11
+ start?: 'skip' | 'outset' | 'none'
12
+ lengthRatio?: number
13
+ closed?: boolean
14
+ forceSolid?: boolean
15
+ } = {}
16
16
  ): {
17
17
  strokeDasharray: string
18
18
  strokeDashoffset: string
@@ -57,6 +57,11 @@ export class Box {
57
57
  this.x = n
58
58
  }
59
59
 
60
+ // eslint-disable-next-line no-restricted-syntax
61
+ get left() {
62
+ return this.x
63
+ }
64
+
60
65
  // eslint-disable-next-line no-restricted-syntax
61
66
  get midX() {
62
67
  return this.x + this.w / 2
@@ -67,6 +72,11 @@ export class Box {
67
72
  return this.x + this.w
68
73
  }
69
74
 
75
+ // eslint-disable-next-line no-restricted-syntax
76
+ get right() {
77
+ return this.x + this.w
78
+ }
79
+
70
80
  // eslint-disable-next-line no-restricted-syntax
71
81
  get minY() {
72
82
  return this.y
@@ -77,6 +87,11 @@ export class Box {
77
87
  this.y = n
78
88
  }
79
89
 
90
+ // eslint-disable-next-line no-restricted-syntax
91
+ get top() {
92
+ return this.y
93
+ }
94
+
80
95
  // eslint-disable-next-line no-restricted-syntax
81
96
  get midY() {
82
97
  return this.y + this.h / 2
@@ -87,6 +102,11 @@ export class Box {
87
102
  return this.y + this.h
88
103
  }
89
104
 
105
+ // eslint-disable-next-line no-restricted-syntax
106
+ get bottom() {
107
+ return this.y + this.h
108
+ }
109
+
90
110
  // eslint-disable-next-line no-restricted-syntax
91
111
  get width() {
92
112
  return this.w
@@ -157,12 +157,13 @@ export class Mat {
157
157
  return Mat.Compose(Mat.Translate(cx, cy!), rotationMatrix, Mat.Translate(-cx, -cy!))
158
158
  }
159
159
 
160
- static Scale(x: number, y: number): MatModel
161
- static Scale(x: number, y: number, cx: number, cy: number): MatModel
162
- static Scale(x: number, y: number, cx?: number, cy?: number): MatModel {
160
+ static Scale(x: number, y: number): Mat
161
+ static Scale(x: number, y: number, cx: number, cy: number): Mat
162
+ static Scale(x: number, y: number, cx?: number, cy?: number): Mat {
163
163
  const scaleMatrix = new Mat(x, 0, 0, y, 0, 0)
164
164
  if (cx === undefined) return scaleMatrix
165
- return Mat.Compose(Mat.Translate(cx, cy!), scaleMatrix, Mat.Translate(-cx, -cy!))
165
+
166
+ return Mat.Translate(cx, cy!).multiply(scaleMatrix).translate(-cx, -cy!)
166
167
  }
167
168
  static Multiply(m1: MatModel, m2: MatModel): MatModel {
168
169
  return {
@@ -319,6 +319,11 @@ export class Vec {
319
319
  return ((A.y - B.y) ** 2 + (A.x - B.x) ** 2) ** 0.5
320
320
  }
321
321
 
322
+ // Get the Manhattan distance between two points.
323
+ static ManhattanDist(A: VecLike, B: VecLike): number {
324
+ return Math.abs(A.x - B.x) + Math.abs(A.y - B.y)
325
+ }
326
+
322
327
  // Get whether a distance between two points is less than a number. This is faster to calulate than using `Vec.Dist(a, b) < n`.
323
328
  static DistMin(A: VecLike, B: VecLike, n: number): boolean {
324
329
  return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y) < n ** 2
@@ -465,10 +470,28 @@ export class Vec {
465
470
  return isNaN(A.x) || isNaN(A.y)
466
471
  }
467
472
 
473
+ /**
474
+ * Get the angle from position A to position B.
475
+ */
468
476
  static Angle(A: VecLike, B: VecLike): number {
469
477
  return Math.atan2(B.y - A.y, B.x - A.x)
470
478
  }
471
479
 
480
+ /**
481
+ * Get the angle between vector A and vector B. This will return the smallest angle between the
482
+ * two vectors, between -π and π. The sign indicates direction of angle.
483
+ */
484
+ static AngleBetween(A: VecLike, B: VecLike): number {
485
+ const p = A.x * B.x + A.y * B.y
486
+ const n = Math.sqrt(
487
+ (Math.pow(A.x, 2) + Math.pow(A.y, 2)) * (Math.pow(B.x, 2) + Math.pow(B.y, 2))
488
+ )
489
+ const sign = A.x * B.y - A.y * B.x < 0 ? -1 : 1
490
+ const angle = sign * Math.acos(p / n)
491
+
492
+ return angle
493
+ }
494
+
472
495
  /**
473
496
  * Linearly interpolate between two points.
474
497
  * @param A - The first point.
@@ -1,4 +1,4 @@
1
- import { Vec } from '../Vec'
1
+ import { Vec, VecLike } from '../Vec'
2
2
  import { intersectLineSegmentCircle } from '../intersect'
3
3
  import { getArcMeasure, getPointInArcT, getPointOnCircle } from '../utils'
4
4
  import { Geometry2d, Geometry2dOptions } from './Geometry2d'
@@ -44,14 +44,14 @@ export class Arc2d extends Geometry2d {
44
44
  this._center = center
45
45
  }
46
46
 
47
- nearestPoint(point: Vec): Vec {
47
+ nearestPoint(point: VecLike): Vec {
48
48
  const { _center, measure, radius, angleEnd, angleStart, start: A, end: B } = this
49
49
  const t = getPointInArcT(measure, angleStart, angleEnd, _center.angle(point))
50
50
  if (t <= 0) return A
51
51
  if (t >= 1) return B
52
52
 
53
53
  // Get the point (P) on the arc, then pick the nearest of A, B, and P
54
- const P = _center.clone().add(point.clone().sub(_center).uni().mul(radius))
54
+ const P = Vec.Sub(point, _center).uni().mul(radius).add(_center)
55
55
 
56
56
  let nearest: Vec | undefined
57
57
  let dist = Infinity
@@ -67,7 +67,7 @@ export class Arc2d extends Geometry2d {
67
67
  return nearest
68
68
  }
69
69
 
70
- hitTestLineSegment(A: Vec, B: Vec): boolean {
70
+ hitTestLineSegment(A: VecLike, B: VecLike): boolean {
71
71
  const { _center, radius, measure, angleStart, angleEnd } = this
72
72
  const intersection = intersectLineSegmentCircle(A, B, _center, radius)
73
73
  if (intersection === null) return false
@@ -95,6 +95,6 @@ export class Arc2d extends Geometry2d {
95
95
  }
96
96
 
97
97
  override getLength() {
98
- return this.measure * this.radius
98
+ return Math.abs(this.measure * this.radius)
99
99
  }
100
100
  }
@@ -1,5 +1,5 @@
1
1
  import { Box } from '../Box'
2
- import { Vec } from '../Vec'
2
+ import { Vec, VecLike } from '../Vec'
3
3
  import { intersectLineSegmentCircle } from '../intersect'
4
4
  import { PI2, getPointOnCircle } from '../utils'
5
5
  import { Geometry2d, Geometry2dOptions } from './Geometry2d'
@@ -43,13 +43,13 @@ export class Circle2d extends Geometry2d {
43
43
  return vertices
44
44
  }
45
45
 
46
- nearestPoint(point: Vec): Vec {
46
+ nearestPoint(point: VecLike): Vec {
47
47
  const { _center, radius } = this
48
48
  if (_center.equals(point)) return Vec.AddXY(_center, radius, 0)
49
- return _center.clone().add(point.clone().sub(_center).uni().mul(radius))
49
+ return Vec.Sub(point, _center).uni().mul(radius).add(_center)
50
50
  }
51
51
 
52
- hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
52
+ hitTestLineSegment(A: VecLike, B: VecLike, distance = 0): boolean {
53
53
  const { _center, radius } = this
54
54
  return intersectLineSegmentCircle(A, B, _center, radius + distance) !== null
55
55
  }
@@ -1,5 +1,5 @@
1
- import { Vec } from '../Vec'
2
- import { Geometry2dOptions } from './Geometry2d'
1
+ import { Vec, VecLike } from '../Vec'
2
+ import { Geometry2dFilters, Geometry2dOptions } from './Geometry2d'
3
3
  import { Polyline2d } from './Polyline2d'
4
4
 
5
5
  /** @public */
@@ -52,7 +52,7 @@ export class CubicBezier2d extends Polyline2d {
52
52
  return CubicBezier2d.GetAtT(this, 0.5)
53
53
  }
54
54
 
55
- nearestPoint(A: Vec): Vec {
55
+ nearestPoint(A: VecLike): Vec {
56
56
  let nearest: Vec | undefined
57
57
  let dist = Infinity
58
58
  let d: number
@@ -89,7 +89,7 @@ export class CubicBezier2d extends Polyline2d {
89
89
  )
90
90
  }
91
91
 
92
- override getLength(precision = 32) {
92
+ override getLength(filters?: Geometry2dFilters, precision = 32) {
93
93
  let n1: Vec,
94
94
  p1 = this.a,
95
95
  length = 0
@@ -1,4 +1,4 @@
1
- import { Vec } from '../Vec'
1
+ import { Vec, VecLike } from '../Vec'
2
2
  import { CubicBezier2d } from './CubicBezier2d'
3
3
  import { Geometry2d, Geometry2dOptions } from './Geometry2d'
4
4
 
@@ -58,7 +58,7 @@ export class CubicSpline2d extends Geometry2d {
58
58
  return vertices
59
59
  }
60
60
 
61
- nearestPoint(A: Vec): Vec {
61
+ nearestPoint(A: VecLike): Vec {
62
62
  let nearest: Vec | undefined
63
63
  let dist = Infinity
64
64
  let d: number
@@ -75,7 +75,7 @@ export class CubicSpline2d extends Geometry2d {
75
75
  return nearest
76
76
  }
77
77
 
78
- hitTestLineSegment(A: Vec, B: Vec): boolean {
78
+ hitTestLineSegment(A: VecLike, B: VecLike): boolean {
79
79
  return this.segments.some((segment) => segment.hitTestLineSegment(A, B))
80
80
  }
81
81
 
@@ -1,5 +1,5 @@
1
- import { Vec } from '../Vec'
2
1
  import { linesIntersect } from '../intersect'
2
+ import { Vec, VecLike } from '../Vec'
3
3
  import { Geometry2d } from './Geometry2d'
4
4
 
5
5
  /** @public */
@@ -34,7 +34,7 @@ export class Edge2d extends Geometry2d {
34
34
  return [this.start, this.end]
35
35
  }
36
36
 
37
- override nearestPoint(point: Vec): Vec {
37
+ override nearestPoint(point: VecLike): Vec {
38
38
  const { start, end, d, u, ul: l } = this
39
39
  if (d.len() === 0) return start // start and end are the same
40
40
  if (l === 0) return start // no length in the unit vector
@@ -48,7 +48,7 @@ export class Edge2d extends Geometry2d {
48
48
  return new Vec(cx, cy)
49
49
  }
50
50
 
51
- override hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
51
+ override hitTestLineSegment(A: VecLike, B: VecLike, distance = 0): boolean {
52
52
  return (
53
53
  linesIntersect(A, B, this.start, this.end) || this.distanceToLineSegment(A, B) <= distance
54
54
  )
@@ -1,5 +1,5 @@
1
1
  import { Box } from '../Box'
2
- import { Vec } from '../Vec'
2
+ import { Vec, VecLike } from '../Vec'
3
3
  import { PI, PI2, perimeterOfEllipse } from '../utils'
4
4
  import { Edge2d } from './Edge2d'
5
5
  import { Geometry2d, Geometry2dOptions } from './Geometry2d'
@@ -73,7 +73,7 @@ export class Ellipse2d extends Geometry2d {
73
73
  return vertices
74
74
  }
75
75
 
76
- nearestPoint(A: Vec): Vec {
76
+ nearestPoint(A: VecLike): Vec {
77
77
  let nearest: Vec | undefined
78
78
  let dist = Infinity
79
79
  let d: number
@@ -90,7 +90,7 @@ export class Ellipse2d extends Geometry2d {
90
90
  return nearest
91
91
  }
92
92
 
93
- hitTestLineSegment(A: Vec, B: Vec): boolean {
93
+ hitTestLineSegment(A: VecLike, B: VecLike): boolean {
94
94
  return this.edges.some((edge) => edge.hitTestLineSegment(A, B))
95
95
  }
96
96
 
@@ -0,0 +1,42 @@
1
+ import { Mat } from '../Mat'
2
+ import { Vec, VecLike } from '../Vec'
3
+ import { Geometry2dFilters } from './Geometry2d'
4
+ import { Rectangle2d } from './Rectangle2d'
5
+
6
+ describe('TransformedGeometry2d', () => {
7
+ const rect = new Rectangle2d({ width: 100, height: 50, isFilled: true }).transform(
8
+ Mat.Translate(50, 100).scale(2, 2)
9
+ )
10
+
11
+ test('getVertices', () => {
12
+ expect(rect.getVertices(Geometry2dFilters.INCLUDE_ALL)).toMatchObject([
13
+ { x: 50, y: 100, z: 1 },
14
+ { x: 250, y: 100, z: 1 },
15
+ { x: 250, y: 200, z: 1 },
16
+ { x: 50, y: 200, z: 1 },
17
+ ])
18
+ })
19
+
20
+ test('nearestPoint', () => {
21
+ expectApproxMatch(rect.nearestPoint(new Vec(100, 300)), { x: 100, y: 200 })
22
+ })
23
+
24
+ test('hitTestPoint', () => {
25
+ // basic case - no margin / scaling:
26
+ expect(rect.hitTestPoint(new Vec(0, 0), 0, true)).toBe(false)
27
+ expect(rect.hitTestPoint(new Vec(50, 100), 0, true)).toBe(true)
28
+ expect(rect.hitTestPoint(new Vec(49, 100), 0, true)).toBe(false)
29
+ expect(rect.hitTestPoint(new Vec(100, 150), 0, true)).toBe(true)
30
+
31
+ // with margin:
32
+ // move away 8 px and test with 10px margin:
33
+ expect(rect.hitTestPoint(new Vec(42, 100), 10, true)).toBe(true)
34
+ // move away 12 px and test with 10px margin:
35
+ expect(rect.hitTestPoint(new Vec(38, 100), 10, true)).toBe(false)
36
+ })
37
+ })
38
+
39
+ function expectApproxMatch(a: VecLike, b: VecLike) {
40
+ expect(a.x).toBeCloseTo(b.x, 0.0001)
41
+ expect(a.y).toBeCloseTo(b.y, 0.0001)
42
+ }