js-draw 0.3.2 → 0.4.1

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 (104) hide show
  1. package/.github/pull_request_template.md +15 -0
  2. package/.github/workflows/firebase-hosting-merge.yml +7 -0
  3. package/.github/workflows/firebase-hosting-pull-request.yml +10 -0
  4. package/.github/workflows/github-pages.yml +2 -0
  5. package/CHANGELOG.md +16 -1
  6. package/README.md +1 -3
  7. package/dist/bundle.js +1 -1
  8. package/dist/src/Editor.d.ts +11 -0
  9. package/dist/src/Editor.js +107 -77
  10. package/dist/src/Pointer.d.ts +1 -1
  11. package/dist/src/Pointer.js +8 -3
  12. package/dist/src/Viewport.d.ts +1 -0
  13. package/dist/src/Viewport.js +14 -1
  14. package/dist/src/components/AbstractComponent.js +1 -0
  15. package/dist/src/components/ImageComponent.d.ts +2 -2
  16. package/dist/src/components/Stroke.js +15 -9
  17. package/dist/src/components/Text.d.ts +1 -1
  18. package/dist/src/components/Text.js +1 -1
  19. package/dist/src/components/builders/FreehandLineBuilder.d.ts +1 -0
  20. package/dist/src/components/builders/FreehandLineBuilder.js +34 -36
  21. package/dist/src/language/assertions.d.ts +1 -0
  22. package/dist/src/language/assertions.js +5 -0
  23. package/dist/src/math/Mat33.d.ts +38 -2
  24. package/dist/src/math/Mat33.js +30 -1
  25. package/dist/src/math/Path.d.ts +1 -1
  26. package/dist/src/math/Path.js +10 -8
  27. package/dist/src/math/Vec3.d.ts +12 -2
  28. package/dist/src/math/Vec3.js +16 -1
  29. package/dist/src/math/rounding.d.ts +1 -0
  30. package/dist/src/math/rounding.js +13 -6
  31. package/dist/src/rendering/renderers/AbstractRenderer.js +2 -1
  32. package/dist/src/testing/beforeEachFile.d.ts +1 -0
  33. package/dist/src/testing/beforeEachFile.js +3 -0
  34. package/dist/src/testing/createEditor.d.ts +1 -0
  35. package/dist/src/testing/createEditor.js +7 -1
  36. package/dist/src/testing/loadExpectExtensions.d.ts +0 -15
  37. package/dist/src/toolbar/HTMLToolbar.js +5 -4
  38. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  39. package/dist/src/tools/PasteHandler.js +3 -1
  40. package/dist/src/tools/Pen.js +1 -1
  41. package/dist/src/tools/SelectionTool/Selection.d.ts +54 -0
  42. package/dist/src/tools/SelectionTool/Selection.js +337 -0
  43. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +35 -0
  44. package/dist/src/tools/SelectionTool/SelectionHandle.js +75 -0
  45. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +31 -0
  46. package/dist/src/tools/SelectionTool/SelectionTool.js +284 -0
  47. package/dist/src/tools/SelectionTool/TransformMode.d.ts +34 -0
  48. package/dist/src/tools/SelectionTool/TransformMode.js +98 -0
  49. package/dist/src/tools/SelectionTool/types.d.ts +9 -0
  50. package/dist/src/tools/SelectionTool/types.js +11 -0
  51. package/dist/src/tools/ToolController.js +1 -1
  52. package/dist/src/tools/lib.d.ts +1 -1
  53. package/dist/src/tools/lib.js +1 -1
  54. package/dist/src/types.d.ts +1 -1
  55. package/jest.config.js +5 -0
  56. package/package.json +15 -14
  57. package/src/Editor.css +1 -0
  58. package/src/Editor.ts +147 -108
  59. package/src/Pointer.ts +8 -3
  60. package/src/Viewport.ts +17 -2
  61. package/src/components/AbstractComponent.ts +4 -6
  62. package/src/components/ImageComponent.ts +2 -6
  63. package/src/components/Stroke.test.ts +0 -3
  64. package/src/components/Stroke.ts +14 -7
  65. package/src/components/Text.test.ts +0 -3
  66. package/src/components/Text.ts +4 -8
  67. package/src/components/builders/FreehandLineBuilder.ts +37 -43
  68. package/src/language/assertions.ts +6 -0
  69. package/src/math/LineSegment2.test.ts +8 -10
  70. package/src/math/Mat33.test.ts +14 -2
  71. package/src/math/Mat33.ts +43 -2
  72. package/src/math/Path.toString.test.ts +12 -1
  73. package/src/math/Path.ts +11 -9
  74. package/src/math/Rect2.test.ts +0 -3
  75. package/src/math/Vec2.test.ts +0 -3
  76. package/src/math/Vec3.test.ts +0 -3
  77. package/src/math/Vec3.ts +23 -2
  78. package/src/math/rounding.test.ts +30 -5
  79. package/src/math/rounding.ts +16 -7
  80. package/src/rendering/renderers/AbstractRenderer.ts +3 -2
  81. package/src/testing/beforeEachFile.ts +3 -0
  82. package/src/testing/createEditor.ts +8 -1
  83. package/src/testing/global.d.ts +17 -0
  84. package/src/testing/loadExpectExtensions.ts +0 -15
  85. package/src/toolbar/HTMLToolbar.ts +5 -4
  86. package/src/toolbar/toolbar.css +3 -2
  87. package/src/toolbar/widgets/SelectionToolWidget.ts +1 -1
  88. package/src/tools/PasteHandler.ts +4 -1
  89. package/src/tools/Pen.test.ts +150 -0
  90. package/src/tools/Pen.ts +1 -1
  91. package/src/tools/SelectionTool/Selection.ts +455 -0
  92. package/src/tools/SelectionTool/SelectionHandle.ts +99 -0
  93. package/src/tools/SelectionTool/SelectionTool.css +22 -0
  94. package/src/tools/{SelectionTool.test.ts → SelectionTool/SelectionTool.test.ts} +21 -21
  95. package/src/tools/SelectionTool/SelectionTool.ts +344 -0
  96. package/src/tools/SelectionTool/TransformMode.ts +114 -0
  97. package/src/tools/SelectionTool/types.ts +11 -0
  98. package/src/tools/ToolController.ts +1 -1
  99. package/src/tools/lib.ts +1 -1
  100. package/src/types.ts +1 -1
  101. package/tsconfig.json +3 -1
  102. package/dist/src/tools/SelectionTool.d.ts +0 -65
  103. package/dist/src/tools/SelectionTool.js +0 -647
  104. package/src/tools/SelectionTool.ts +0 -797
@@ -173,7 +173,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
173
173
  }
174
174
 
175
175
  public build(): Stroke {
176
- if (this.lastPoint) {
176
+ if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) {
177
177
  this.finalizeCurrentCurve();
178
178
  }
179
179
  return this.previewStroke()!;
@@ -189,6 +189,19 @@ export default class FreehandLineBuilder implements ComponentBuilder {
189
189
  return Viewport.roundPoint(point, minFit);
190
190
  }
191
191
 
192
+ // Returns the distance between the start, control, and end points of the curve.
193
+ private approxCurrentCurveLength() {
194
+ if (!this.currentCurve) {
195
+ return 0;
196
+ }
197
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
198
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
199
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
200
+ const toControlDist = startPt.minus(controlPt).length();
201
+ const toEndDist = endPt.minus(controlPt).length();
202
+ return toControlDist + toEndDist;
203
+ }
204
+
192
205
  private finalizeCurrentCurve() {
193
206
  // Case where no points have been added
194
207
  if (!this.currentCurve) {
@@ -285,10 +298,8 @@ export default class FreehandLineBuilder implements ComponentBuilder {
285
298
  startVec = startVec.times(this.curveStartWidth / 2);
286
299
  endVec = endVec.times(this.curveEndWidth / 2);
287
300
 
288
- if (isNaN(startVec.magnitude())) {
289
- // TODO: This can happen when events are too close together. Find out why and
290
- // fix.
291
- console.error('startVec is NaN', startVec, endVec, this.currentCurve);
301
+ if (!isFinite(startVec.magnitude())) {
302
+ console.error('Warning: startVec is NaN or ∞', startVec, endVec, this.currentCurve);
292
303
  startVec = endVec;
293
304
  }
294
305
 
@@ -307,39 +318,22 @@ export default class FreehandLineBuilder implements ComponentBuilder {
307
318
  }
308
319
 
309
320
  const halfVecT = projectionT;
310
- let halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
321
+ const halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
311
322
  .normalized().times(
312
323
  this.curveStartWidth / 2 * halfVecT
313
324
  + this.curveEndWidth / 2 * (1 - halfVecT)
314
325
  );
315
326
 
316
- // Computes a boundary curve. [direction] should be either +1 or -1 (determines the side
317
- // of the center curve to place the boundary).
318
- const computeBoundaryCurve = (direction: number, halfVec: Vec2) => {
319
- return new Bezier(
320
- startPt.plus(startVec.times(direction)),
321
- controlPoint.plus(halfVec.times(direction)),
322
- endPt.plus(endVec.times(direction)),
323
- );
324
- };
325
-
326
- const boundariesIntersect = () => {
327
- const upperBoundary = computeBoundaryCurve(1, halfVec);
328
- const lowerBoundary = computeBoundaryCurve(-1, halfVec);
329
- return upperBoundary.intersects(lowerBoundary).length > 0;
330
- };
331
-
332
- // If the boundaries have intersections, increasing the half vector's length could fix this.
333
- if (boundariesIntersect()) {
334
- halfVec = halfVec.times(1.1);
335
- }
336
-
337
327
  // Each starts at startPt ± startVec
328
+ const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
329
+ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
330
+ const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
331
+ const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
338
332
 
339
333
  const lowerCurve: QuadraticBezierPathCommand = {
340
334
  kind: PathCommandType.QuadraticBezierTo,
341
- controlPoint: this.roundPoint(controlPoint.plus(halfVec)),
342
- endPoint: this.roundPoint(endPt.plus(endVec)),
335
+ controlPoint: lowerCurveControlPoint,
336
+ endPoint: lowerCurveEndPoint,
343
337
  };
344
338
 
345
339
  // From the end of the upperCurve to the start of the lowerCurve:
@@ -351,12 +345,12 @@ export default class FreehandLineBuilder implements ComponentBuilder {
351
345
  // From the end of lowerCurve to the start of upperCurve:
352
346
  const lowerToUpperConnector: LinePathCommand = {
353
347
  kind: PathCommandType.LineTo,
354
- point: this.roundPoint(endPt.minus(endVec))
348
+ point: upperCurveStartPoint,
355
349
  };
356
350
 
357
351
  const upperCurve: QuadraticBezierPathCommand = {
358
352
  kind: PathCommandType.QuadraticBezierTo,
359
- controlPoint: this.roundPoint(controlPoint.minus(halfVec)),
353
+ controlPoint: upperCurveControlPoint,
360
354
  endPoint: this.roundPoint(startPt.minus(startVec)),
361
355
  };
362
356
 
@@ -374,7 +368,6 @@ export default class FreehandLineBuilder implements ComponentBuilder {
374
368
  const fuzzEq = 1e-10;
375
369
  const deltaTime = newPoint.time - this.lastPoint.time;
376
370
  if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
377
- console.warn('Discarding identical point');
378
371
  return;
379
372
  } else if (isNaN(newPoint.pos.magnitude())) {
380
373
  console.warn('Discarding NaN point.', newPoint);
@@ -433,23 +426,23 @@ export default class FreehandLineBuilder implements ComponentBuilder {
433
426
  let exitingVec = this.computeExitingVec();
434
427
 
435
428
  // Find the intersection between the entering vector and the exiting vector
436
- const maxRelativeLength = 2;
429
+ const maxRelativeLength = 3;
437
430
  const segmentStart = this.buffer[0];
438
431
  const segmentEnd = newPoint.pos;
439
432
  const startEndDist = segmentEnd.minus(segmentStart).magnitude();
440
433
  const maxControlPointDist = maxRelativeLength * startEndDist;
441
434
 
442
435
  // Exit in cases where we would divide by zero
443
- if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || isNaN(exitingVec.magnitude())) {
436
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
444
437
  return;
445
438
  }
446
439
 
447
- console.assert(!isNaN(enteringVec.magnitude()));
440
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
448
441
 
449
442
  enteringVec = enteringVec.normalized();
450
443
  exitingVec = exitingVec.normalized();
451
444
 
452
- console.assert(!isNaN(enteringVec.magnitude()));
445
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
453
446
 
454
447
  const lineFromStart = new LineSegment2(
455
448
  segmentStart,
@@ -462,18 +455,20 @@ export default class FreehandLineBuilder implements ComponentBuilder {
462
455
  const intersection = lineFromEnd.intersection(lineFromStart);
463
456
 
464
457
  // Position the control point at this intersection
465
- let controlPoint: Point2;
458
+ let controlPoint: Point2|null = null;
466
459
  if (intersection) {
467
460
  controlPoint = intersection.point;
468
- } else {
461
+ }
462
+
463
+ // No intersection or the intersection is one of the end points?
464
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
469
465
  // Position the control point closer to the first -- the connecting
470
466
  // segment will be roughly a line.
471
467
  controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 4));
472
468
  }
473
469
 
474
- if (isNaN(controlPoint.magnitude()) || isNaN(segmentStart.magnitude())) {
475
- console.error('controlPoint is NaN', intersection, 'Start:', segmentStart, 'End:', segmentEnd, 'in:', enteringVec, 'out:', exitingVec);
476
- }
470
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
471
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
477
472
 
478
473
  const prevCurve = this.currentCurve;
479
474
  this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
@@ -502,8 +497,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
502
497
  return true;
503
498
  };
504
499
 
505
- const approxCurveLen = controlPoint.minus(segmentStart).magnitude() + segmentEnd.minus(controlPoint).magnitude();
506
- if (this.buffer.length > 3 && approxCurveLen > this.curveEndWidth / 3) {
500
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
507
501
  if (!curveMatchesPoints(this.currentCurve)) {
508
502
  // Use a curve that better fits the points
509
503
  this.currentCurve = prevCurve;
@@ -0,0 +1,6 @@
1
+
2
+ // Compile-time assertion that a branch of code is unreachable.
3
+ // See https://stackoverflow.com/a/39419171/17055750
4
+ export const assertUnreachable = (key: never): never => {
5
+ throw new Error(`Should be unreachable. Key: ${key}.`);
6
+ };
@@ -1,9 +1,7 @@
1
1
  import LineSegment2 from './LineSegment2';
2
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
3
2
  import { Vec2 } from './Vec2';
4
3
  import Mat33 from './Mat33';
5
4
 
6
- loadExpectExtensions();
7
5
 
8
6
  describe('Line2', () => {
9
7
  it('x and y axes should intersect at (0, 0)', () => {
@@ -14,13 +12,13 @@ describe('Line2', () => {
14
12
  });
15
13
 
16
14
  it('y = -2x + 2 and y = 2x - 2 should intersect at (1,0)', () => {
17
- // y = -2x + 2
15
+ // y = -4x + 2
18
16
  const line1 = new LineSegment2(Vec2.of(0, 2), Vec2.of(1, -2));
19
- // y = 2x - 2
17
+ // y = 4x - 2
20
18
  const line2 = new LineSegment2(Vec2.of(0, -2), Vec2.of(1, 2));
21
19
 
22
- expect(line1.intersection(line2)?.point).objEq(Vec2.of(1, 0));
23
- expect(line2.intersection(line1)?.point).objEq(Vec2.of(1, 0));
20
+ expect(line1.intersection(line2)?.point).objEq(Vec2.of(0.5, 0));
21
+ expect(line2.intersection(line1)?.point).objEq(Vec2.of(0.5, 0));
24
22
  });
25
23
 
26
24
  it('line from (10, 10) to (-100, 10) should intersect with the y-axis at t = 10', () => {
@@ -81,14 +79,14 @@ describe('Line2', () => {
81
79
  expect(line.closestPointTo(Vec2.zero)).objEq(Vec2.of(1, 0));
82
80
  });
83
81
 
84
- it('Closest point from (-1,2) to segment((1,1) -> (2,4)) should be (1,1)', () => {
82
+ it('Closest point from (-1,-2) to segment((1,1) -> (2,4)) should be (1,1)', () => {
85
83
  const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4));
86
- expect(line.closestPointTo(Vec2.of(-1, 2))).objEq(Vec2.of(1, 1));
84
+ expect(line.closestPointTo(Vec2.of(-1, -2))).objEq(Vec2.of(1, 1));
87
85
  });
88
86
 
89
- it('Closest point from (5,2) to segment((1,1) -> (2,4)) should be (2,4)', () => {
87
+ it('Closest point from (5,8) to segment((1,1) -> (2,4)) should be (2,4)', () => {
90
88
  const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4));
91
- expect(line.closestPointTo(Vec2.of(5, 2))).objEq(Vec2.of(2, 4));
89
+ expect(line.closestPointTo(Vec2.of(5, 8))).objEq(Vec2.of(2, 4));
92
90
  });
93
91
 
94
92
  it('Should translate when translated by a translation matrix', () => {
@@ -1,9 +1,7 @@
1
1
  import Mat33 from './Mat33';
2
2
  import { Vec2 } from './Vec2';
3
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
4
3
  import Vec3 from './Vec3';
5
4
 
6
- loadExpectExtensions();
7
5
 
8
6
  describe('Mat33 tests', () => {
9
7
  it('equality', () => {
@@ -142,6 +140,20 @@ describe('Mat33 tests', () => {
142
140
  ).objEq(Vec2.unitX, fuzz);
143
141
  });
144
142
 
143
+ it('should correctly apply a mapping to all components', () => {
144
+ expect(
145
+ new Mat33(
146
+ 1, 2, 3,
147
+ 4, 5, 6,
148
+ 7, 8, 9,
149
+ ).mapEntries(component => component - 1)
150
+ ).toMatchObject(new Mat33(
151
+ 0, 1, 2,
152
+ 3, 4, 5,
153
+ 6, 7, 8,
154
+ ));
155
+ });
156
+
145
157
  it('should convert CSS matrix(...) strings to matricies', () => {
146
158
  // From MDN:
147
159
  // ⎡ a c e ⎤
package/src/math/Mat33.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { Point2, Vec2 } from './Vec2';
2
2
  import Vec3 from './Vec3';
3
3
 
4
+ export type Mat33Array = [
5
+ number, number, number,
6
+ number, number, number,
7
+ number, number, number,
8
+ ];
9
+
4
10
  /**
5
11
  * Represents a three dimensional linear transformation or
6
12
  * a two-dimensional affine transformation. (An affine transformation scales/rotates/shears
@@ -239,7 +245,7 @@ export default class Mat33 {
239
245
  * ...
240
246
  * ```
241
247
  */
242
- public toArray(): number[] {
248
+ public toArray(): Mat33Array {
243
249
  return [
244
250
  this.a1, this.a2, this.a3,
245
251
  this.b1, this.b2, this.b3,
@@ -247,6 +253,27 @@ export default class Mat33 {
247
253
  ];
248
254
  }
249
255
 
256
+ /**
257
+ * @example
258
+ * ```
259
+ * new Mat33(
260
+ * 1, 2, 3,
261
+ * 4, 5, 6,
262
+ * 7, 8, 9,
263
+ * ).mapEntries(component => component - 1);
264
+ * // → ⎡ 0, 1, 2 ⎤
265
+ * // ⎢ 3, 4, 5 ⎥
266
+ * // ⎣ 6, 7, 8 ⎦
267
+ * ```
268
+ */
269
+ public mapEntries(mapping: (component: number)=>number): Mat33 {
270
+ return new Mat33(
271
+ mapping(this.a1), mapping(this.a2), mapping(this.a3),
272
+ mapping(this.b1), mapping(this.b2), mapping(this.b3),
273
+ mapping(this.c1), mapping(this.c2), mapping(this.c3),
274
+ );
275
+ }
276
+
250
277
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
251
278
  public static translation(amount: Vec2): Mat33 {
252
279
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -297,7 +324,21 @@ export default class Mat33 {
297
324
  return result.rightMul(Mat33.translation(center.times(-1)));
298
325
  }
299
326
 
300
- /** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */
327
+ /** @see {@link !fromCSSMatrix} */
328
+ public toCSSMatrix(): string {
329
+ return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`;
330
+ }
331
+
332
+ /**
333
+ * Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33.
334
+ *
335
+ * Note that such a matrix has the form,
336
+ * ```
337
+ * ⎡ a c e ⎤
338
+ * ⎢ b d f ⎥
339
+ * ⎣ 0 0 1 ⎦
340
+ * ```
341
+ */
301
342
  public static fromCSSMatrix(cssString: string): Mat33 {
302
343
  if (cssString === '' || cssString === 'none') {
303
344
  return Mat33.identity;
@@ -38,7 +38,7 @@ describe('Path.toString', () => {
38
38
  const path = new Path(Vec2.of(1000, 2_000_000), [
39
39
  {
40
40
  kind: PathCommandType.LineTo,
41
- point: Vec2.of(30.0001, 40.000000001),
41
+ point: Vec2.of(30.00000001, 40.000000001),
42
42
  },
43
43
  ]);
44
44
 
@@ -53,4 +53,15 @@ describe('Path.toString', () => {
53
53
  'M100,100', 'l1,1', 'q1,1 -11-11', 'l10,10'
54
54
  ].join(''));
55
55
  });
56
+
57
+ it('should not lose precision when saving', () => {
58
+ const pathStr = 'M184.2,52.3l-.2-.2q-2.7,2.4 -3.2,3.5q-2.8,7 -.9,6.1q4.3-2.6 4.8-6.1q1.2-8.8 .4-8.3q-4.2,5.2 -3.9,3.9q.2-1.6 .3-2.1q.2-1.3 -.2-1q-3.8,6.5 -3.2,3.3q.6-4.1 1.1-5.3q4.1-10 3.3-8.3q-5.3,13.1 -6.6,14.1q-3.3,2.8 -1.8-1.5q2.8-9.7 2.7-8.4q0,.3 0,.4q-1.4,7.1 -2.7,8.5q-2.6,3.2 -2.5,2.9q-.3-1.9 -.7-1.9q-4.1,4.4 -2.9,1.9q1.1-3 .3-2.6q-1.8,2 -2.5,2.4q-4.5,2.8 -4.2,1.9q.3-1.6 .2-1.4q1.5,2.2 1.3,2.9q-.8,3.9 -.5,3.3q.8-7.6 2.5-13.3q2.6-9.2 2.9-6.9q.3,1.4 .3,1.2q-.7-.4 -.9,0q-2.2,11.6 -7.6,13.6q-3.9,1.6 -2.1-1.3q3-5.5 2.6-3.4q-.2,1.8 -.5,1.8q-3.2,.5 -4.1,1.2q-2.6,2.6 -1.9,2.5q4.7-4.4 3.7-5.5q-1.1-.9 -1.6-.6q-7.2,7.5 -3.9,6.5q.3-.1 .4-.4q.6-5.3 -.2-4.9q-2.8,2.3 -3.1,2.4q-3.7,1.5 -3.5,.5q.3-3.6 1.4-3.3q3.5,.7 1.9,2.4q-1.7,2.3 -1.6,.8q0-3.5 -.9-3.1q-5.1,3.3 -4.9,2.8q.1-4 -.8-3.5q-4.3,3.4 -4.6,2.5q-1-2.1 .5-8.7l-.2,0q-1.6,6.6 -.7,8.9q.7,1.2 5.2-2.3q.4-.5 .2,3.1q.1,1 5.5-2.4q.4-.4 .3,2.7q.1,2 2.4-.4q1.7-2.3 -2.1-3.2q-1.7-.3 -2,3.7q0,1.4 4.1-.1q.3-.1 3.1-2.4q.3-.5 -.4,4.5q0-.1 -.2,0q-2.6,1.2 4.5-5.7q0-.2 .8,.6q.9,.6 -3.7,4.7q-.5,1 2.7-1.7q.6-.7 3.7-1.2q.7-.2 .9-2.2q.1-2.7 -3.4,3.2q-1.8,3.4 2.7,1.9q5.6-2.1 7.8-14q-.1,.1 .3,.4q.6,.1 .3-1.6q-.7-2.8 -3.7,6.7q-1.8,5.8 -2.5,13.5q.1,1.1 1.3-3.1q.2-1 -1.3-3.3q-.5-.5 -1,1.6q-.1,1.3 4.8-1.5q1-1 3-2q.1-.4 -1.1,2q-1.1,3.1 3.7-1.3q-.4,0 -.1,1.5q.3,.8 3.3-2.5q1.3-1.6 2.7-8.9q0-.1 0-.4q-.3-1.9 -3.5,8.2q-1.3,4.9 2.4,2.1q1.4-1.2 6.6-14.3q.8-2.4 -3.9,7.9q-.6,1.3 -1.1,5.5q-.3,3.7 4-3.1q-.2,0 -.6,.6q-.2,.6 -.3,2.3q0,1.8 4.7-3.5q.1-.5 -1.2,7.9q-.5,3.2 -4.6,5.7q-1.3,1 1.5-5.5q.4-1.1 3.01-3.5';
59
+
60
+ const path1 = Path.fromString(pathStr);
61
+ path1['cachedStringVersion'] = null; // Clear the cache.
62
+ const path = Path.fromString(path1.toString(true));
63
+ path1['cachedStringVersion'] = null; // Clear the cache.
64
+
65
+ expect(path.toString(true)).toBe(path1.toString(true));
66
+ });
56
67
  });
package/src/math/Path.ts CHANGED
@@ -378,15 +378,17 @@ export default class Path {
378
378
 
379
379
  private cachedStringVersion: string|null = null;
380
380
 
381
- public toString(): string {
381
+ public toString(useNonAbsCommands?: boolean): string {
382
382
  if (this.cachedStringVersion) {
383
383
  return this.cachedStringVersion;
384
384
  }
385
385
 
386
- // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
387
- const makeRelativeCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
386
+ if (useNonAbsCommands === undefined) {
387
+ // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
388
+ useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
389
+ }
388
390
 
389
- const result = Path.toString(this.startPoint, this.parts, !makeRelativeCommands);
391
+ const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands);
390
392
  this.cachedStringVersion = result;
391
393
  return result;
392
394
  }
@@ -409,10 +411,13 @@ export default class Path {
409
411
  const roundedPrevY = prevPoint ? toRoundedString(prevPoint.y) : '';
410
412
 
411
413
  for (const point of points) {
414
+ const xComponent = toRoundedString(point.x);
415
+ const yComponent = toRoundedString(point.y);
416
+
412
417
  // Relative commands are often shorter as strings than absolute commands.
413
418
  if (!makeAbsCommand) {
414
- const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, roundedPrevX, roundedPrevY);
415
- const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, roundedPrevX, roundedPrevY);
419
+ const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, xComponent, roundedPrevX, roundedPrevY);
420
+ const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, yComponent, roundedPrevX, roundedPrevY);
416
421
 
417
422
  // No need for an additional separator if it starts with a '-'
418
423
  if (yComponentRelative.charAt(0) === '-') {
@@ -421,9 +426,6 @@ export default class Path {
421
426
  relativeCommandParts.push(`${xComponentRelative},${yComponentRelative}`);
422
427
  }
423
428
  } else {
424
- const xComponent = toRoundedString(point.x);
425
- const yComponent = toRoundedString(point.y);
426
-
427
429
  absoluteCommandParts.push(`${xComponent},${yComponent}`);
428
430
  }
429
431
  }
@@ -1,11 +1,8 @@
1
1
 
2
2
  import Rect2 from './Rect2';
3
3
  import { Vec2 } from './Vec2';
4
- import loadExpectExtensions from '../testing/loadExpectExtensions';
5
4
  import Mat33 from './Mat33';
6
5
 
7
- loadExpectExtensions();
8
-
9
6
  describe('Rect2', () => {
10
7
  it('width, height should always be positive', () => {
11
8
  expect(new Rect2(-1, -2, -3, 4)).objEq(new Rect2(-4, -2, 3, 4));
@@ -1,8 +1,5 @@
1
1
  import { Vec2 } from './Vec2';
2
2
  import Vec3 from './Vec3';
3
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
4
-
5
- loadExpectExtensions();
6
3
 
7
4
  describe('Vec2', () => {
8
5
  it('Magnitude', () => {
@@ -1,9 +1,6 @@
1
1
 
2
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
3
2
  import Vec3 from './Vec3';
4
3
 
5
- loadExpectExtensions();
6
-
7
4
  describe('Vec3', () => {
8
5
  it('.xy should contain the x and y components', () => {
9
6
  const vec = Vec3.of(1, 2, 3);
package/src/math/Vec3.ts CHANGED
@@ -95,6 +95,27 @@ export default class Vec3 {
95
95
  );
96
96
  }
97
97
 
98
+ /**
99
+ * If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise,
100
+ * if `other is a `number`, returns the result of scalar multiplication.
101
+ *
102
+ * @example
103
+ * ```
104
+ * Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18)
105
+ * ```
106
+ */
107
+ public scale(other: Vec3|number): Vec3 {
108
+ if (typeof other === 'number') {
109
+ return this.times(other);
110
+ }
111
+
112
+ return Vec3.of(
113
+ this.x * other.x,
114
+ this.y * other.y,
115
+ this.z * other.z,
116
+ );
117
+ }
118
+
98
119
  /**
99
120
  * Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated
100
121
  * 90 degrees counter-clockwise.
@@ -158,7 +179,7 @@ export default class Vec3 {
158
179
  );
159
180
  }
160
181
 
161
- public asArray(): number[] {
182
+ public asArray(): [ number, number, number ] {
162
183
  return [this.x, this.y, this.z];
163
184
  }
164
185
 
@@ -175,7 +196,7 @@ export default class Vec3 {
175
196
  * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 2.99); // → false
176
197
  * ```
177
198
  */
178
- public eq(other: Vec3, fuzz: number): boolean {
199
+ public eq(other: Vec3, fuzz: number = 1e-10): boolean {
179
200
  for (let i = 0; i < 3; i++) {
180
201
  if (Math.abs(other.at(i) - this.at(i)) > fuzz) {
181
202
  return false;
@@ -1,4 +1,4 @@
1
- import { toRoundedString, toStringOfSamePrecision } from './rounding';
1
+ import { cleanUpNumber, toRoundedString, toStringOfSamePrecision } from './rounding';
2
2
 
3
3
  describe('toRoundedString', () => {
4
4
  it('should round up numbers endings similar to .999999999999999', () => {
@@ -12,18 +12,28 @@ describe('toRoundedString', () => {
12
12
  expect(toRoundedString(10.999999998)).toBe('11');
13
13
  });
14
14
 
15
- // Handling this creates situations with potential error:
16
- //it('should round strings with multiple digits after the ending decimal points', () => {
17
- // expect(toRoundedString(292.2 - 292.8)).toBe('-0.6');
18
- //});
15
+ it('should round strings with multiple digits after the ending decimal points', () => {
16
+ expect(toRoundedString(292.2 - 292.8)).toBe('-.6');
17
+ expect(toRoundedString(4.06425600000023)).toBe('4.064256');
18
+ });
19
19
 
20
20
  it('should round down strings ending endings similar to .00000001', () => {
21
21
  expect(toRoundedString(10.00000001)).toBe('10');
22
+ expect(toRoundedString(-30.00000001)).toBe('-30');
23
+ expect(toRoundedString(-14.20000000000002)).toBe('-14.2');
24
+ });
25
+
26
+ it('should not round numbers insufficiently close to the next', () => {
27
+ expect(toRoundedString(-10.9999)).toBe('-10.9999');
28
+ expect(toRoundedString(-10.0001)).toBe('-10.0001');
29
+ expect(toRoundedString(-10.123499)).toBe('-10.123499');
30
+ expect(toRoundedString(0.00123499)).toBe('.00123499');
22
31
  });
23
32
  });
24
33
 
25
34
  it('toStringOfSamePrecision', () => {
26
35
  expect(toStringOfSamePrecision(1.23456, '1.12')).toBe('1.23');
36
+ expect(toStringOfSamePrecision(1.23456, '1.120')).toBe('1.235');
27
37
  expect(toStringOfSamePrecision(1.23456, '1.1')).toBe('1.2');
28
38
  expect(toStringOfSamePrecision(1.23456, '1.1', '5.32')).toBe('1.23');
29
39
  expect(toStringOfSamePrecision(-1.23456, '1.1', '5.32')).toBe('-1.23');
@@ -37,4 +47,19 @@ it('toStringOfSamePrecision', () => {
37
47
  expect(toStringOfSamePrecision(-0.09999999999999432, '291.3')).toBe('-.1');
38
48
  expect(toStringOfSamePrecision(-0.9999999999999432, '291.3')).toBe('-1');
39
49
  expect(toStringOfSamePrecision(9998.9, '.1', '-11')).toBe('9998.9');
50
+ expect(toStringOfSamePrecision(-14.20000000000002, '.000001', '-11')).toBe('-14.2');
51
+ });
52
+
53
+ it('cleanUpNumber', () => {
54
+ expect(cleanUpNumber('000.0000')).toBe('0');
55
+ expect(cleanUpNumber('-000.0000')).toBe('0');
56
+ expect(cleanUpNumber('0.0000')).toBe('0');
57
+ expect(cleanUpNumber('0.001')).toBe('.001');
58
+ expect(cleanUpNumber('-0.001')).toBe('-.001');
59
+ expect(cleanUpNumber('-0.000000001')).toBe('-.000000001');
60
+ expect(cleanUpNumber('-0.00000000100')).toBe('-.000000001');
61
+ expect(cleanUpNumber('1234')).toBe('1234');
62
+ expect(cleanUpNumber('1234.5')).toBe('1234.5');
63
+ expect(cleanUpNumber('1234.500')).toBe('1234.5');
64
+ expect(cleanUpNumber('1.1368683772161603e-13')).toBe('0');
40
65
  });
@@ -1,9 +1,17 @@
1
1
  // @packageDocumentation @internal
2
2
 
3
3
  // Clean up stringified numbers
4
- const cleanUpNumber = (text: string) => {
4
+ export const cleanUpNumber = (text: string) => {
5
5
  // Regular expression substitions can be somewhat expensive. Only do them
6
6
  // if necessary.
7
+
8
+ if (text.indexOf('e') > 0) {
9
+ // Round to zero.
10
+ if (text.match(/[eE][-]\d{2,}$/)) {
11
+ return '0';
12
+ }
13
+ }
14
+
7
15
  const lastChar = text.charAt(text.length - 1);
8
16
  if (lastChar === '0' || lastChar === '.') {
9
17
  // Remove trailing zeroes
@@ -12,10 +20,6 @@ const cleanUpNumber = (text: string) => {
12
20
 
13
21
  // Remove trailing period
14
22
  text = text.replace(/[.]$/, '');
15
-
16
- if (text === '-0') {
17
- return '0';
18
- }
19
23
  }
20
24
 
21
25
  const firstChar = text.charAt(0);
@@ -23,6 +27,11 @@ const cleanUpNumber = (text: string) => {
23
27
  // Remove unnecessary leading zeroes.
24
28
  text = text.replace(/^(0+)[.]/, '.');
25
29
  text = text.replace(/^-(0+)[.]/, '-.');
30
+ text = text.replace(/^(-?)0+$/, '$10');
31
+ }
32
+
33
+ if (text === '-0') {
34
+ return '0';
26
35
  }
27
36
 
28
37
  return text;
@@ -31,8 +40,8 @@ const cleanUpNumber = (text: string) => {
31
40
  export const toRoundedString = (num: number): string => {
32
41
  // Try to remove rounding errors. If the number ends in at least three/four zeroes
33
42
  // (or nines) just one or two digits, it's probably a rounding error.
34
- const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,2}$/;
35
- const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,2}$/;
43
+ const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/;
44
+ const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/;
36
45
 
37
46
  let text = num.toString(10);
38
47
  if (text.indexOf('.') === -1) {
@@ -122,8 +122,9 @@ export default abstract class AbstractRenderer {
122
122
  }
123
123
  }
124
124
 
125
- // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill]
126
- public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle): void {
125
+ // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill].
126
+ // This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`.
127
+ public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle) {
127
128
  const path = Path.fromRect(rect, lineWidth);
128
129
  this.drawPath(path.toRenderable(lineFill));
129
130
  }
@@ -0,0 +1,3 @@
1
+ import loadExpectExtensions from './loadExpectExtensions';
2
+ loadExpectExtensions();
3
+ jest.useFakeTimers();
@@ -1,4 +1,11 @@
1
1
  import { RenderingMode } from '../rendering/Display';
2
2
  import Editor from '../Editor';
3
3
 
4
- export default () => new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
4
+ /** Creates an editor. Should only be used in test files. */
5
+ export default () => {
6
+ if (jest === undefined) {
7
+ throw new Error('Files in the testing/ folder should only be used in tests!');
8
+ }
9
+
10
+ return new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
11
+ };
@@ -0,0 +1,17 @@
1
+
2
+
3
+ // Type declarations for custom matchers
4
+ interface CustomMatchers<R = unknown> {
5
+ objEq(expected: {
6
+ eq: (other: any, ...args: any)=> boolean;
7
+ }, ...opts: any): R;
8
+ }
9
+
10
+ declare namespace jest {
11
+ interface Expect extends CustomMatchers {}
12
+ interface Matchers<R> extends CustomMatchers<R> {}
13
+ interface AsyncAsymmetricMatchers extends CustomMatchers {}
14
+ }
15
+
16
+ declare interface JestMatchers<T> extends CustomMatchers<T> {
17
+ }