js-draw 0.15.1 → 0.15.2

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 (82) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Color4.d.ts +1 -1
  4. package/dist/src/Color4.js +5 -1
  5. package/dist/src/Editor.d.ts +0 -2
  6. package/dist/src/Editor.js +15 -30
  7. package/dist/src/EditorImage.d.ts +25 -0
  8. package/dist/src/EditorImage.js +57 -2
  9. package/dist/src/EventDispatcher.d.ts +4 -3
  10. package/dist/src/SVGLoader.d.ts +1 -0
  11. package/dist/src/SVGLoader.js +15 -1
  12. package/dist/src/Viewport.d.ts +3 -3
  13. package/dist/src/Viewport.js +4 -8
  14. package/dist/src/components/AbstractComponent.d.ts +5 -1
  15. package/dist/src/components/AbstractComponent.js +10 -2
  16. package/dist/src/components/ImageBackground.d.ts +41 -0
  17. package/dist/src/components/ImageBackground.js +132 -0
  18. package/dist/src/components/ImageComponent.js +2 -0
  19. package/dist/src/components/builders/ArrowBuilder.d.ts +3 -1
  20. package/dist/src/components/builders/ArrowBuilder.js +43 -40
  21. package/dist/src/components/builders/LineBuilder.d.ts +3 -1
  22. package/dist/src/components/builders/LineBuilder.js +25 -28
  23. package/dist/src/components/builders/RectangleBuilder.js +1 -1
  24. package/dist/src/components/lib.d.ts +2 -1
  25. package/dist/src/components/lib.js +2 -1
  26. package/dist/src/components/localization.d.ts +2 -0
  27. package/dist/src/components/localization.js +2 -0
  28. package/dist/src/math/Mat33.js +43 -5
  29. package/dist/src/math/Path.d.ts +5 -0
  30. package/dist/src/math/Path.js +80 -28
  31. package/dist/src/math/Vec3.js +1 -1
  32. package/dist/src/rendering/Display.js +1 -1
  33. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +13 -1
  34. package/dist/src/rendering/renderers/AbstractRenderer.js +18 -3
  35. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  36. package/dist/src/rendering/renderers/CanvasRenderer.js +12 -2
  37. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
  38. package/dist/src/rendering/renderers/SVGRenderer.js +8 -2
  39. package/dist/src/testing/sendTouchEvent.d.ts +6 -0
  40. package/dist/src/testing/sendTouchEvent.js +26 -0
  41. package/dist/src/toolbar/IconProvider.js +1 -2
  42. package/dist/src/toolbar/widgets/HandToolWidget.js +1 -1
  43. package/dist/src/tools/Eraser.js +5 -2
  44. package/dist/src/tools/PanZoom.js +12 -0
  45. package/dist/src/tools/SelectionTool/Selection.js +1 -1
  46. package/dist/src/tools/SelectionTool/SelectionTool.js +5 -1
  47. package/package.json +1 -1
  48. package/src/Color4.test.ts +6 -0
  49. package/src/Color4.ts +6 -1
  50. package/src/Editor.ts +15 -36
  51. package/src/EditorImage.ts +74 -2
  52. package/src/EventDispatcher.ts +4 -1
  53. package/src/SVGLoader.ts +12 -1
  54. package/src/Viewport.ts +4 -7
  55. package/src/components/AbstractComponent.ts +11 -1
  56. package/src/components/ImageBackground.ts +167 -0
  57. package/src/components/ImageComponent.ts +2 -0
  58. package/src/components/builders/ArrowBuilder.ts +44 -41
  59. package/src/components/builders/LineBuilder.ts +26 -28
  60. package/src/components/builders/RectangleBuilder.ts +1 -1
  61. package/src/components/lib.ts +2 -0
  62. package/src/components/localization.ts +4 -0
  63. package/src/math/Mat33.test.ts +20 -1
  64. package/src/math/Mat33.ts +47 -5
  65. package/src/math/Path.ts +87 -28
  66. package/src/math/Vec3.test.ts +4 -0
  67. package/src/math/Vec3.ts +1 -1
  68. package/src/rendering/Display.ts +1 -1
  69. package/src/rendering/renderers/AbstractRenderer.ts +20 -3
  70. package/src/rendering/renderers/CanvasRenderer.ts +16 -3
  71. package/src/rendering/renderers/DummyRenderer.test.ts +1 -2
  72. package/src/rendering/renderers/SVGRenderer.ts +8 -1
  73. package/src/testing/sendTouchEvent.ts +43 -0
  74. package/src/toolbar/IconProvider.ts +1 -2
  75. package/src/toolbar/widgets/HandToolWidget.ts +1 -1
  76. package/src/tools/Eraser.test.ts +24 -1
  77. package/src/tools/Eraser.ts +6 -2
  78. package/src/tools/PanZoom.test.ts +267 -23
  79. package/src/tools/PanZoom.ts +15 -1
  80. package/src/tools/SelectionTool/Selection.ts +1 -1
  81. package/src/tools/SelectionTool/SelectionTool.ts +6 -1
  82. package/src/types.ts +1 -0
@@ -0,0 +1,167 @@
1
+ import Color4 from '../Color4';
2
+ import Editor from '../Editor';
3
+ import EditorImage, { EditorImageEventType } from '../EditorImage';
4
+ import { DispatcherEventListener } from '../EventDispatcher';
5
+ import SerializableCommand from '../commands/SerializableCommand';
6
+ import LineSegment2 from '../math/LineSegment2';
7
+ import Mat33 from '../math/Mat33';
8
+ import Rect2 from '../math/Rect2';
9
+ import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
10
+ import AbstractComponent from './AbstractComponent';
11
+ import { ImageComponentLocalization } from './localization';
12
+ import RestyleableComponent, { ComponentStyle, createRestyleComponentCommand } from './RestylableComponent';
13
+
14
+ export enum BackgroundType {
15
+ SolidColor,
16
+ None,
17
+ }
18
+
19
+ export const imageBackgroundCSSClassName = 'js-draw-image-background';
20
+
21
+ // Represents the background of an image in the editor.
22
+ export default class ImageBackground extends AbstractComponent implements RestyleableComponent {
23
+ protected contentBBox: Rect2;
24
+ private viewportSizeChangeListener: DispatcherEventListener|null = null;
25
+
26
+ // eslint-disable-next-line @typescript-eslint/prefer-as-const
27
+ readonly isRestylableComponent: true = true;
28
+
29
+ public constructor(
30
+ private backgroundType: BackgroundType, private mainColor: Color4
31
+ ) {
32
+ super('image-background', 0);
33
+ this.contentBBox = Rect2.empty;
34
+ }
35
+
36
+ public getStyle(): ComponentStyle {
37
+ let color: Color4|undefined = this.mainColor;
38
+
39
+ if (this.backgroundType === BackgroundType.None) {
40
+ color = undefined;
41
+ }
42
+
43
+ return {
44
+ color,
45
+ };
46
+ }
47
+
48
+ public updateStyle(style: ComponentStyle): SerializableCommand {
49
+ return createRestyleComponentCommand(this.getStyle(), style, this);
50
+ }
51
+
52
+ // @internal
53
+ public forceStyle(style: ComponentStyle, _editor: Editor | null): void {
54
+ const fill = style.color;
55
+
56
+ if (!fill) {
57
+ return;
58
+ }
59
+
60
+ this.mainColor = fill;
61
+ if (fill.eq(Color4.transparent)) {
62
+ this.backgroundType = BackgroundType.None;
63
+ } else {
64
+ this.backgroundType = BackgroundType.SolidColor;
65
+ }
66
+ }
67
+
68
+ public onAddToImage(image: EditorImage) {
69
+ if (this.viewportSizeChangeListener) {
70
+ console.warn('onAddToImage called when background is already in an image');
71
+ this.onRemoveFromImage();
72
+ }
73
+
74
+ this.viewportSizeChangeListener = image.notifier.on(
75
+ EditorImageEventType.ExportViewportChanged, () => {
76
+ this.recomputeBBox(image);
77
+ });
78
+ this.recomputeBBox(image);
79
+ }
80
+
81
+ public onRemoveFromImage(): void {
82
+ this.viewportSizeChangeListener?.remove();
83
+ this.viewportSizeChangeListener = null;
84
+ }
85
+
86
+ private recomputeBBox(image: EditorImage) {
87
+ const importExportRect = image.getImportExportViewport().visibleRect;
88
+ if (!this.contentBBox.eq(importExportRect)) {
89
+ this.contentBBox = importExportRect;
90
+
91
+ // Re-render this if already added to the EditorImage.
92
+ image.queueRerenderOf(this);
93
+ }
94
+ }
95
+
96
+ public render(canvas: AbstractRenderer, visibleRect?: Rect2) {
97
+ if (this.backgroundType === BackgroundType.None) {
98
+ return;
99
+ }
100
+ canvas.startObject(this.contentBBox);
101
+
102
+ if (this.backgroundType === BackgroundType.SolidColor) {
103
+ // If the rectangle for this region contains the visible rect,
104
+ // we can fill the entire visible rectangle (which may be more efficient than
105
+ // filling the entire region for this.)
106
+ if (visibleRect) {
107
+ const intersection = visibleRect.intersection(this.contentBBox);
108
+ if (intersection) {
109
+ canvas.fillRect(intersection, this.mainColor);
110
+ }
111
+ } else {
112
+ canvas.fillRect(this.contentBBox, this.mainColor);
113
+ }
114
+ }
115
+
116
+ canvas.endObject(this.getLoadSaveData(), [ imageBackgroundCSSClassName ]);
117
+ }
118
+
119
+ public intersects(lineSegment: LineSegment2): boolean {
120
+ return this.contentBBox.getEdges().some(edge => edge.intersects(lineSegment));
121
+ }
122
+
123
+ public isSelectable(): boolean {
124
+ return false;
125
+ }
126
+
127
+ protected serializeToJSON() {
128
+ return {
129
+ mainColor: this.mainColor.toHexString(),
130
+ backgroundType: this.backgroundType,
131
+ };
132
+ }
133
+
134
+ protected applyTransformation(_affineTransfm: Mat33) {
135
+ // Do nothing — it doesn't make sense to transform the background.
136
+ }
137
+
138
+ public description(localizationTable: ImageComponentLocalization) {
139
+ if (this.backgroundType === BackgroundType.SolidColor) {
140
+ return localizationTable.filledBackgroundWithColor(this.mainColor.toString());
141
+ } else {
142
+ return localizationTable.emptyBackground;
143
+ }
144
+ }
145
+
146
+ protected createClone(): AbstractComponent {
147
+ return new ImageBackground(this.backgroundType, this.mainColor);
148
+ }
149
+
150
+ // @internal
151
+ public static deserializeFromJSON(json: any) {
152
+ if (typeof json === 'string') {
153
+ json = JSON.parse(json);
154
+ }
155
+
156
+ if (typeof json.mainColor !== 'string') {
157
+ throw new Error('Error deserializing — mainColor must be of type string.');
158
+ }
159
+
160
+ const backgroundType = json.backgroundType === BackgroundType.SolidColor ? BackgroundType.SolidColor : BackgroundType.None;
161
+ const mainColor = Color4.fromHex(json.mainColor);
162
+
163
+ return new ImageBackground(backgroundType, mainColor);
164
+ }
165
+ }
166
+
167
+ AbstractComponent.registerComponent('image-background', ImageBackground.deserializeFromJSON);
@@ -88,7 +88,9 @@ export default class ImageComponent extends AbstractComponent {
88
88
  }
89
89
 
90
90
  public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
91
+ canvas.startObject(this.contentBBox);
91
92
  canvas.drawImage(this.image);
93
+ canvas.endObject(this.getLoadSaveData());
92
94
  }
93
95
 
94
96
  public getProportionalRenderingTime(): number {
@@ -1,4 +1,4 @@
1
- import { PathCommandType } from '../../math/Path';
1
+ import Path, { PathCommandType } from '../../math/Path';
2
2
  import Rect2 from '../../math/Rect2';
3
3
  import AbstractRenderer from '../../rendering/renderers/AbstractRenderer';
4
4
  import { StrokeDataPoint } from '../../types';
@@ -7,14 +7,14 @@ import AbstractComponent from '../AbstractComponent';
7
7
  import Stroke from '../Stroke';
8
8
  import { ComponentBuilder, ComponentBuilderFactory } from './types';
9
9
 
10
- export const makeArrowBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, _viewport: Viewport) => {
11
- return new ArrowBuilder(initialPoint);
10
+ export const makeArrowBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, viewport: Viewport) => {
11
+ return new ArrowBuilder(initialPoint, viewport);
12
12
  };
13
13
 
14
14
  export default class ArrowBuilder implements ComponentBuilder {
15
15
  private endPoint: StrokeDataPoint;
16
16
 
17
- public constructor(private readonly startPoint: StrokeDataPoint) {
17
+ public constructor(private readonly startPoint: StrokeDataPoint, private readonly viewport: Viewport) {
18
18
  this.endPoint = startPoint;
19
19
  }
20
20
 
@@ -28,10 +28,10 @@ export default class ArrowBuilder implements ComponentBuilder {
28
28
  }
29
29
 
30
30
  private buildPreview(): Stroke {
31
- const startPoint = this.startPoint.pos;
31
+ const lineStartPoint = this.startPoint.pos;
32
32
  const endPoint = this.endPoint.pos;
33
- const toEnd = endPoint.minus(startPoint).normalized();
34
- const arrowLength = endPoint.minus(startPoint).length();
33
+ const toEnd = endPoint.minus(lineStartPoint).normalized();
34
+ const arrowLength = endPoint.minus(lineStartPoint).length();
35
35
 
36
36
  // Ensure that the arrow tip is smaller than the arrow.
37
37
  const arrowTipSize = Math.min(this.getLineWidth(), arrowLength / 2);
@@ -45,42 +45,45 @@ export default class ArrowBuilder implements ComponentBuilder {
45
45
  const scaledStartNormal = lineNormal.times(startSize);
46
46
  const scaledBaseNormal = lineNormal.times(endSize);
47
47
 
48
+ const path = new Path(arrowTipBase.minus(scaledBaseNormal), [
49
+ // Stem
50
+ {
51
+ kind: PathCommandType.LineTo,
52
+ point: lineStartPoint.minus(scaledStartNormal),
53
+ },
54
+ {
55
+ kind: PathCommandType.LineTo,
56
+ point: lineStartPoint.plus(scaledStartNormal),
57
+ },
58
+ {
59
+ kind: PathCommandType.LineTo,
60
+ point: arrowTipBase.plus(scaledBaseNormal),
61
+ },
62
+
63
+ // Head
64
+ {
65
+ kind: PathCommandType.LineTo,
66
+ point: arrowTipBase.plus(lineNormal.times(arrowTipSize).plus(scaledBaseNormal)),
67
+ },
68
+ {
69
+ kind: PathCommandType.LineTo,
70
+ point: endPoint.plus(toEnd.times(endSize)),
71
+ },
72
+ {
73
+ kind: PathCommandType.LineTo,
74
+ point: arrowTipBase.plus(lineNormal.times(-arrowTipSize).minus(scaledBaseNormal)),
75
+ },
76
+ {
77
+ kind: PathCommandType.LineTo,
78
+ point: arrowTipBase.minus(scaledBaseNormal),
79
+ },
80
+ // Round all points in the arrow (to remove unnecessary decimal places)
81
+ ]).mapPoints(point => this.viewport.roundPoint(point));
82
+
48
83
  const preview = new Stroke([
49
84
  {
50
- startPoint: arrowTipBase.minus(scaledBaseNormal),
51
- commands: [
52
- // Stem
53
- {
54
- kind: PathCommandType.LineTo,
55
- point: startPoint.minus(scaledStartNormal),
56
- },
57
- {
58
- kind: PathCommandType.LineTo,
59
- point: startPoint.plus(scaledStartNormal),
60
- },
61
- {
62
- kind: PathCommandType.LineTo,
63
- point: arrowTipBase.plus(scaledBaseNormal),
64
- },
65
-
66
- // Head
67
- {
68
- kind: PathCommandType.LineTo,
69
- point: arrowTipBase.plus(lineNormal.times(arrowTipSize).plus(scaledBaseNormal))
70
- },
71
- {
72
- kind: PathCommandType.LineTo,
73
- point: endPoint.plus(toEnd.times(endSize)),
74
- },
75
- {
76
- kind: PathCommandType.LineTo,
77
- point: arrowTipBase.plus(lineNormal.times(-arrowTipSize).minus(scaledBaseNormal)),
78
- },
79
- {
80
- kind: PathCommandType.LineTo,
81
- point: arrowTipBase.minus(scaledBaseNormal),
82
- },
83
- ],
85
+ startPoint: path.startPoint,
86
+ commands: path.parts,
84
87
  style: {
85
88
  fill: this.startPoint.color,
86
89
  }
@@ -1,4 +1,4 @@
1
- import { PathCommandType } from '../../math/Path';
1
+ import Path, { PathCommandType } from '../../math/Path';
2
2
  import Rect2 from '../../math/Rect2';
3
3
  import AbstractRenderer from '../../rendering/renderers/AbstractRenderer';
4
4
  import { StrokeDataPoint } from '../../types';
@@ -7,14 +7,14 @@ import AbstractComponent from '../AbstractComponent';
7
7
  import Stroke from '../Stroke';
8
8
  import { ComponentBuilder, ComponentBuilderFactory } from './types';
9
9
 
10
- export const makeLineBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, _viewport: Viewport) => {
11
- return new LineBuilder(initialPoint);
10
+ export const makeLineBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, viewport: Viewport) => {
11
+ return new LineBuilder(initialPoint, viewport);
12
12
  };
13
13
 
14
14
  export default class LineBuilder implements ComponentBuilder {
15
15
  private endPoint: StrokeDataPoint;
16
16
 
17
- public constructor(private readonly startPoint: StrokeDataPoint) {
17
+ public constructor(private readonly startPoint: StrokeDataPoint, private readonly viewport: Viewport) {
18
18
  this.endPoint = startPoint;
19
19
  }
20
20
 
@@ -35,31 +35,29 @@ export default class LineBuilder implements ComponentBuilder {
35
35
  const scaledStartNormal = lineNormal.times(startSize);
36
36
  const scaledEndNormal = lineNormal.times(endSize);
37
37
 
38
- const preview = new Stroke([
38
+ const strokeStartPoint = startPoint.minus(scaledStartNormal);
39
+
40
+ const path = new Path(strokeStartPoint, [
41
+ {
42
+ kind: PathCommandType.LineTo,
43
+ point: startPoint.plus(scaledStartNormal),
44
+ },
45
+ {
46
+ kind: PathCommandType.LineTo,
47
+ point: endPoint.plus(scaledEndNormal),
48
+ },
39
49
  {
40
- startPoint: startPoint.minus(scaledStartNormal),
41
- commands: [
42
- {
43
- kind: PathCommandType.LineTo,
44
- point: startPoint.plus(scaledStartNormal),
45
- },
46
- {
47
- kind: PathCommandType.LineTo,
48
- point: endPoint.plus(scaledEndNormal),
49
- },
50
- {
51
- kind: PathCommandType.LineTo,
52
- point: endPoint.minus(scaledEndNormal),
53
- },
54
- {
55
- kind: PathCommandType.LineTo,
56
- point: startPoint.minus(scaledStartNormal),
57
- },
58
- ],
59
- style: {
60
- fill: this.startPoint.color,
61
- }
62
- }
50
+ kind: PathCommandType.LineTo,
51
+ point: endPoint.minus(scaledEndNormal),
52
+ },
53
+ {
54
+ kind: PathCommandType.LineTo,
55
+ point: startPoint.minus(scaledStartNormal),
56
+ },
57
+ ]).mapPoints(point => this.viewport.roundPoint(point));
58
+
59
+ const preview = new Stroke([
60
+ path.toRenderable({ fill: this.startPoint.color })
63
61
  ]);
64
62
 
65
63
  return preview;
@@ -49,7 +49,7 @@ export default class RectangleBuilder implements ComponentBuilder {
49
49
  ).transformedBy(
50
50
  // Rotate the canvas rectangle so that its rotation matches the screen
51
51
  rotationMat
52
- );
52
+ ).mapPoints(point => this.viewport.roundPoint(point));
53
53
 
54
54
  const preview = new Stroke([
55
55
  path.toRenderable({
@@ -9,6 +9,7 @@ import Stroke from './Stroke';
9
9
  import TextComponent from './TextComponent';
10
10
  import ImageComponent from './ImageComponent';
11
11
  import RestyleableComponent, { createRestyleComponentCommand } from './RestylableComponent';
12
+ import ImageBackground from './ImageBackground';
12
13
 
13
14
  export {
14
15
  Stroke,
@@ -18,5 +19,6 @@ export {
18
19
 
19
20
  TextComponent,
20
21
  Stroke as StrokeComponent,
22
+ ImageBackground as BackgroundComponent,
21
23
  ImageComponent,
22
24
  };
@@ -4,6 +4,8 @@ export interface ImageComponentLocalization {
4
4
  imageNode: (description: string)=> string;
5
5
  stroke: string;
6
6
  svgObject: string;
7
+ emptyBackground: string;
8
+ filledBackgroundWithColor: (color: string)=> string;
7
9
 
8
10
  restyledElements: string;
9
11
  }
@@ -13,6 +15,8 @@ export const defaultComponentLocalization: ImageComponentLocalization = {
13
15
  stroke: 'Stroke',
14
16
  svgObject: 'SVG Object',
15
17
  restyledElements: 'Restyled elements',
18
+ emptyBackground: 'Empty background',
19
+ filledBackgroundWithColor: (color) => `Filled background (${color})`,
16
20
  text: (text) => `Text object: ${text}`,
17
21
  imageNode: (description: string) => `Image: ${description}`,
18
22
  };
@@ -72,7 +72,7 @@ describe('Mat33 tests', () => {
72
72
  });
73
73
 
74
74
  it('90 degree z-rotation matricies should rotate 90 degrees counter clockwise', () => {
75
- const fuzz = 0.01;
75
+ const fuzz = 0.001;
76
76
 
77
77
  const M = Mat33.zRotation(Math.PI / 2);
78
78
  const rotated = M.transformVec2(Vec2.unitX);
@@ -80,6 +80,18 @@ describe('Mat33 tests', () => {
80
80
  expect(M.transformVec2(rotated)).objEq(Vec2.unitX.times(-1), fuzz);
81
81
  });
82
82
 
83
+ it('z-rotation matricies should preserve the given origin', () => {
84
+ const testPairs: Array<[number, Vec2]> = [
85
+ [ Math.PI / 2, Vec2.zero ],
86
+ [ -Math.PI / 2, Vec2.zero ],
87
+ [ -Math.PI / 2, Vec2.of(10, 10) ],
88
+ ];
89
+
90
+ for (const [ angle, center ] of testPairs) {
91
+ expect(Mat33.zRotation(angle, center).transformVec2(center)).objEq(center);
92
+ }
93
+ });
94
+
83
95
  it('translation matricies should translate Vec2s', () => {
84
96
  const fuzz = 0.01;
85
97
 
@@ -140,6 +152,13 @@ describe('Mat33 tests', () => {
140
152
  ).objEq(Vec2.unitX, fuzz);
141
153
  });
142
154
 
155
+ it('z-rotation should preserve given origin', () => {
156
+ const rotationOrigin = Vec2.of(75.16363373235318, 104.29870408043762);
157
+ const angle = 6.205048847547065;
158
+
159
+ expect(Mat33.zRotation(angle, rotationOrigin).transformVec2(rotationOrigin)).objEq(rotationOrigin);
160
+ });
161
+
143
162
  it('should correctly apply a mapping to all components', () => {
144
163
  expect(
145
164
  new Mat33(
package/src/math/Mat33.ts CHANGED
@@ -240,11 +240,49 @@ export default class Mat33 {
240
240
  }
241
241
 
242
242
  public toString(): string {
243
- return `
244
- ${this.a1},\t ${this.a2},\t ${this.a3}\t
245
- ⎢ ${this.b1},\t ${this.b2},\t ${this.b3}\t ⎥
246
- ${this.c1},\t ${this.c2},\t ${this.c3}\t
247
- `.trimEnd().trimStart();
243
+ let result = '';
244
+ const maxColumnLens = [ 0, 0, 0 ];
245
+
246
+ // Determine the longest item in each column so we can pad the others to that
247
+ // length.
248
+ for (const row of this.rows) {
249
+ for (let i = 0; i < 3; i++) {
250
+ maxColumnLens[i] = Math.max(maxColumnLens[0], `${row.at(i)}`.length);
251
+ }
252
+ }
253
+
254
+ for (let i = 0; i < 3; i++) {
255
+ if (i === 0) {
256
+ result += '⎡ ';
257
+ } else if (i === 1) {
258
+ result += '⎢ ';
259
+ } else {
260
+ result += '⎣ ';
261
+ }
262
+
263
+ // Add each component of the ith row (after padding it)
264
+ for (let j = 0; j < 3; j++) {
265
+ const val = this.rows[i].at(j).toString();
266
+
267
+ let padding = '';
268
+ for (let i = val.length; i < maxColumnLens[j]; i++) {
269
+ padding += ' ';
270
+ }
271
+
272
+ result += val + ', ' + padding;
273
+ }
274
+
275
+ if (i === 0) {
276
+ result += ' ⎤';
277
+ } else if (i === 1) {
278
+ result += ' ⎥';
279
+ } else {
280
+ result += ' ⎦';
281
+ }
282
+ result += '\n';
283
+ }
284
+
285
+ return result.trimEnd();
248
286
  }
249
287
 
250
288
  /**
@@ -302,6 +340,10 @@ export default class Mat33 {
302
340
  }
303
341
 
304
342
  public static zRotation(radians: number, center: Point2 = Vec2.zero): Mat33 {
343
+ if (radians === 0) {
344
+ return Mat33.identity;
345
+ }
346
+
305
347
  const cos = Math.cos(radians);
306
348
  const sin = Math.sin(radians);
307
349
 
package/src/math/Path.ts CHANGED
@@ -209,39 +209,42 @@ export default class Path {
209
209
  return result;
210
210
  }
211
211
 
212
+ private static mapPathCommand(part: PathCommand, mapping: (point: Point2)=> Point2): PathCommand {
213
+ switch (part.kind) {
214
+ case PathCommandType.MoveTo:
215
+ case PathCommandType.LineTo:
216
+ return {
217
+ kind: part.kind,
218
+ point: mapping(part.point),
219
+ };
220
+ break;
221
+ case PathCommandType.CubicBezierTo:
222
+ return {
223
+ kind: part.kind,
224
+ controlPoint1: mapping(part.controlPoint1),
225
+ controlPoint2: mapping(part.controlPoint2),
226
+ endPoint: mapping(part.endPoint),
227
+ };
228
+ break;
229
+ case PathCommandType.QuadraticBezierTo:
230
+ return {
231
+ kind: part.kind,
232
+ controlPoint: mapping(part.controlPoint),
233
+ endPoint: mapping(part.endPoint),
234
+ };
235
+ break;
236
+ }
237
+
238
+ const exhaustivenessCheck: never = part;
239
+ return exhaustivenessCheck;
240
+ }
241
+
212
242
  public mapPoints(mapping: (point: Point2)=>Point2): Path {
213
243
  const startPoint = mapping(this.startPoint);
214
244
  const newParts: PathCommand[] = [];
215
245
 
216
- let exhaustivenessCheck: never;
217
246
  for (const part of this.parts) {
218
- switch (part.kind) {
219
- case PathCommandType.MoveTo:
220
- case PathCommandType.LineTo:
221
- newParts.push({
222
- kind: part.kind,
223
- point: mapping(part.point),
224
- });
225
- break;
226
- case PathCommandType.CubicBezierTo:
227
- newParts.push({
228
- kind: part.kind,
229
- controlPoint1: mapping(part.controlPoint1),
230
- controlPoint2: mapping(part.controlPoint2),
231
- endPoint: mapping(part.endPoint),
232
- });
233
- break;
234
- case PathCommandType.QuadraticBezierTo:
235
- newParts.push({
236
- kind: part.kind,
237
- controlPoint: mapping(part.controlPoint),
238
- endPoint: mapping(part.endPoint),
239
- });
240
- break;
241
- default:
242
- exhaustivenessCheck = part;
243
- return exhaustivenessCheck;
244
- }
247
+ newParts.push(Path.mapPathCommand(part, mapping));
245
248
  }
246
249
 
247
250
  return new Path(startPoint, newParts);
@@ -431,6 +434,62 @@ export default class Path {
431
434
  };
432
435
  }
433
436
 
437
+ /**
438
+ * @returns a Path that, when rendered, looks roughly equivalent to the given path.
439
+ */
440
+ public static visualEquivalent(renderablePath: RenderablePathSpec, visibleRect: Rect2): RenderablePathSpec {
441
+ const path = Path.fromRenderable(renderablePath);
442
+ const strokeWidth = renderablePath.style.stroke?.width ?? 0;
443
+ const onlyStroked = strokeWidth > 0 && renderablePath.style.fill.a === 0;
444
+
445
+ // Scale the expanded rect --- the visual equivalent is only close for huge strokes.
446
+ const expandedRect = visibleRect.grownBy(strokeWidth)
447
+ .transformedBoundingBox(Mat33.scaling2D(2, visibleRect.center));
448
+
449
+ // TODO: Handle simplifying very small paths.
450
+ if (expandedRect.containsRect(path.bbox.grownBy(strokeWidth))) {
451
+ return renderablePath;
452
+ }
453
+ const parts: PathCommand[] = [];
454
+ let startPoint = path.startPoint;
455
+
456
+ for (const part of path.parts) {
457
+ const partBBox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
458
+ let endPoint;
459
+
460
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
461
+ endPoint = part.point;
462
+ } else {
463
+ endPoint = part.endPoint;
464
+ }
465
+
466
+ const intersectsVisible = partBBox.intersects(visibleRect);
467
+
468
+ if (intersectsVisible) {
469
+ // TODO: Can we trim parts of paths that intersect the visible rectangle?
470
+ parts.push(part);
471
+ } else if (onlyStroked || part.kind === PathCommandType.MoveTo) {
472
+ // We're stroking (not filling) and the path doesn't intersect the bounding box.
473
+ // Don't draw it, but preserve the endpoints.
474
+ parts.push({
475
+ kind: PathCommandType.MoveTo,
476
+ point: endPoint,
477
+ });
478
+ }
479
+ else {
480
+ // Otherwise, we may be filling. Try to roughly preserve the filled region.
481
+ parts.push({
482
+ kind: PathCommandType.LineTo,
483
+ point: endPoint,
484
+ });
485
+ }
486
+
487
+ startPoint = endPoint;
488
+ }
489
+
490
+ return new Path(path.startPoint, parts).toRenderable(renderablePath.style);
491
+ }
492
+
434
493
  private cachedStringVersion: string|null = null;
435
494
 
436
495
  public toString(useNonAbsCommands?: boolean): string {
@@ -31,6 +31,10 @@ describe('Vec3', () => {
31
31
  expect(Vec3.zero.orthog().dot(Vec3.zero)).toBe(0);
32
32
  });
33
33
 
34
+ it('.minus should return the difference between two vectors', () => {
35
+ expect(Vec3.of(1, 2, 3).minus(Vec3.of(4, 5, 6))).objEq(Vec3.of(1 - 4, 2 - 5, 3 - 6));
36
+ });
37
+
34
38
  it('.orthog should return a unit vector', () => {
35
39
  expect(Vec3.zero.orthog().magnitude()).toBe(1);
36
40
  expect(Vec3.unitZ.orthog().magnitude()).toBe(1);
package/src/math/Vec3.ts CHANGED
@@ -77,7 +77,7 @@ export default class Vec3 {
77
77
  }
78
78
 
79
79
  public minus(v: Vec3): Vec3 {
80
- return this.plus(v.times(-1));
80
+ return Vec3.of(this.x - v.x, this.y - v.y, this.z - v.z);
81
81
  }
82
82
 
83
83
  public dot(other: Vec3): number {
@@ -74,7 +74,7 @@ export default class Display {
74
74
  },
75
75
  blockResolution: cacheBlockResolution,
76
76
  cacheSize: 600 * 600 * 4 * 90,
77
- maxScale: 1.4,
77
+ maxScale: 1.3,
78
78
 
79
79
  // Require about 20 strokes with 4 parts each to cache an image in one of the
80
80
  // parts of the cache grid.