@design-core/core 0.0.6 → 50.1.0

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 (173) hide show
  1. package/.eslintrc.json +4 -0
  2. package/.github/workflows/DesignCoreQA.yml +3 -0
  3. package/TODO.md +49 -6
  4. package/core/core/core.js +0 -3
  5. package/core/designCore.js +1 -1
  6. package/core/dimensions/alignedDimension.js +12 -11
  7. package/core/dimensions/angularDimension.js +38 -37
  8. package/core/dimensions/{baseCircularDimension.js → circularDimensionBase.js} +22 -21
  9. package/core/dimensions/diametricDimension.js +14 -13
  10. package/core/dimensions/dimension.js +62 -55
  11. package/core/dimensions/{baseDimension.js → dimensionBase.js} +73 -107
  12. package/core/dimensions/{baseLinearDimension.js → linearDimensionBase.js} +15 -14
  13. package/core/dimensions/radialDimension.js +12 -11
  14. package/core/dimensions/rotatedDimension.js +20 -19
  15. package/core/entities/arc.js +123 -156
  16. package/core/entities/arctext.js +222 -184
  17. package/core/entities/circle.js +77 -99
  18. package/core/entities/entity.js +114 -93
  19. package/core/entities/hatch.js +346 -273
  20. package/core/entities/insert.js +68 -83
  21. package/core/entities/line.js +21 -142
  22. package/core/entities/lwpolyline.js +16 -11
  23. package/core/entities/point.js +19 -19
  24. package/core/entities/polyline.js +7 -7
  25. package/core/entities/{basePolyline.js → polylineBase.js} +47 -299
  26. package/core/entities/rectangle.js +3 -1
  27. package/core/entities/solid.js +6 -15
  28. package/core/entities/text.js +190 -201
  29. package/core/lib/auxiliary/rubberBand.js +32 -0
  30. package/core/lib/auxiliary/selectionWindow.js +63 -0
  31. package/core/lib/auxiliary/snapPoint.js +113 -0
  32. package/core/lib/auxiliary/trackingLine.js +76 -0
  33. package/core/lib/boundingBox.js +11 -11
  34. package/core/lib/canvas.js +232 -168
  35. package/core/lib/clipboard.js +9 -3
  36. package/core/lib/colour.js +16 -0
  37. package/core/lib/commandLine.js +1 -6
  38. package/core/lib/commandManager.js +18 -8
  39. package/core/lib/constants.js +28 -0
  40. package/core/lib/dxf/dxf.js +15 -14
  41. package/core/lib/dxf/dxfIterator.js +2 -2
  42. package/core/lib/entityManager.js +33 -26
  43. package/core/lib/inputManager.js +36 -12
  44. package/core/lib/intersect.js +25 -0
  45. package/core/lib/matrix.js +1 -1
  46. package/core/lib/patterns.js +9 -2
  47. package/core/lib/plotOptions.js +67 -0
  48. package/core/lib/polylineUtils.js +75 -0
  49. package/core/lib/renderers/cairoRenderer.js +371 -0
  50. package/core/lib/renderers/canvasRenderer.js +315 -0
  51. package/core/lib/renderers/pdfRenderer.js +366 -0
  52. package/core/lib/renderers/rendererBase.js +284 -0
  53. package/core/lib/renderers/svgRenderer.js +343 -0
  54. package/core/lib/scene.js +28 -6
  55. package/core/lib/selectionManager.js +20 -20
  56. package/core/lib/settings.js +4 -2
  57. package/core/lib/snapping.js +4 -191
  58. package/core/lib/stateManager.js +30 -1
  59. package/core/lib/utils.js +26 -7
  60. package/core/properties/entityProperties.js +104 -0
  61. package/core/properties/property.js +106 -2
  62. package/core/properties/propertyManager.js +60 -44
  63. package/core/tables/block.js +50 -81
  64. package/core/tables/dimStyle.js +5 -5
  65. package/core/tables/endblk.js +1 -1
  66. package/core/tables/layer.js +8 -0
  67. package/core/tables/ltypeManager.js +1 -1
  68. package/core/tables/styleManagerBase.js +3 -2
  69. package/core/tables/tableManagerBase.js +8 -3
  70. package/core/tables/vport.js +2 -2
  71. package/core/tools/chamfer.js +117 -143
  72. package/core/tools/chamferFilletBase.js +138 -37
  73. package/core/tools/copy.js +11 -11
  74. package/core/tools/copybase.js +3 -3
  75. package/core/tools/copyclip.js +3 -3
  76. package/core/tools/cornerEntity.js +2 -2
  77. package/core/tools/cutclip.js +3 -3
  78. package/core/tools/explode.js +7 -7
  79. package/core/tools/extend.js +138 -25
  80. package/core/tools/fillet.js +92 -189
  81. package/core/tools/matchProp.js +3 -12
  82. package/core/tools/mirror.js +15 -14
  83. package/core/tools/move.js +8 -8
  84. package/core/tools/offset.js +31 -33
  85. package/core/tools/pasteclip.js +1 -1
  86. package/core/tools/purge.js +10 -10
  87. package/core/tools/rotate.js +19 -16
  88. package/core/tools/scale.js +7 -7
  89. package/core/tools/trim.js +271 -29
  90. package/core/tools/zoom.js +1 -1
  91. package/cspell.json +321 -0
  92. package/jest.config.js +6 -0
  93. package/package.json +5 -3
  94. package/test/dimensions/alignedDimension.test.js +17 -10
  95. package/test/dimensions/angularDimension.test.js +13 -13
  96. package/test/dimensions/diametricDimension.test.js +15 -15
  97. package/test/dimensions/dimension.test.js +12 -12
  98. package/test/dimensions/{baseDimension.test.js → dimensionBase.test.js} +73 -12
  99. package/test/dimensions/linearDimensionBase.test.js +62 -0
  100. package/test/dimensions/radialDimension.test.js +15 -15
  101. package/test/dimensions/rotatedDimension.test.js +18 -11
  102. package/test/entities/arc.test.js +83 -86
  103. package/test/entities/arctext.test.js +184 -57
  104. package/test/entities/circle.test.js +68 -96
  105. package/test/entities/entity.test.js +42 -8
  106. package/test/entities/hatch.test.js +569 -96
  107. package/test/entities/insert.test.js +99 -4
  108. package/test/entities/line.test.js +15 -41
  109. package/test/entities/lwpolyline.test.js +104 -0
  110. package/test/entities/point.test.js +3 -3
  111. package/test/entities/polyline.test.js +11 -0
  112. package/test/entities/{basePolyline.test.js → polylineBase.test.js} +192 -57
  113. package/test/entities/rectangle.test.js +11 -0
  114. package/test/entities/solid.test.js +1 -1
  115. package/test/entities/text.test.js +302 -77
  116. package/test/integration/exportIntegration.reference.dxf +9 -9
  117. package/test/integration/exportIntegration.test.js +5 -5
  118. package/test/integration/intersectionIntegration.test.js +4 -5
  119. package/test/integration/pdfRendererIntegration.reference.pdf +17709 -0
  120. package/test/integration/pdfRendererIntegration.test.js +61 -0
  121. package/test/integration/svgRendererIntegration.reference.svg +3343 -0
  122. package/test/integration/svgRendererIntegration.test.js +61 -0
  123. package/test/integration/userInputIntegration.reference.dxf +1165 -105
  124. package/test/integration/userInputIntegration.test.js +239 -132
  125. package/test/lib/boundingBox.test.js +3 -3
  126. package/test/lib/canvas.test.js +744 -125
  127. package/test/lib/constants.test.js +27 -0
  128. package/test/lib/entityManager.test.js +4 -4
  129. package/test/lib/inputManager.test.js +81 -20
  130. package/test/lib/intersect.test.js +83 -0
  131. package/test/lib/plotOptions.test.js +179 -0
  132. package/test/lib/rubberBand.test.js +84 -0
  133. package/test/lib/scene.test.js +9 -9
  134. package/test/lib/selectionManager.test.js +3 -3
  135. package/test/lib/settings.test.js +5 -6
  136. package/test/lib/snapping.test.js +72 -130
  137. package/test/lib/stateManager.test.js +7 -7
  138. package/test/lib/utils.test.js +36 -2
  139. package/test/properties/entityProperties.test.js +132 -0
  140. package/test/properties/property.test.js +53 -0
  141. package/test/properties/propertyManager.test.js +25 -25
  142. package/test/renderers/canvasRenderer.test.js +317 -0
  143. package/test/renderers/pdfRenderer.test.js +271 -0
  144. package/test/renderers/rendererBase.test.js +213 -0
  145. package/test/renderers/svgRenderer.test.js +326 -0
  146. package/test/tables/block.test.js +92 -13
  147. package/test/tables/endblk.test.js +2 -2
  148. package/test/tables/layer.test.js +30 -0
  149. package/test/tables/layerManager.test.js +2 -2
  150. package/test/test-helpers/hatchGridRenderer.js +180 -0
  151. package/test/test-helpers/test-helpers.js +158 -6
  152. package/test/tools/chamfer.test.js +273 -51
  153. package/test/tools/chamferFilletBase.test.js +125 -36
  154. package/test/tools/copy.test.js +8 -8
  155. package/test/tools/copybase.test.js +2 -2
  156. package/test/tools/copyclip.test.js +2 -2
  157. package/test/tools/cornerEntity.test.js +8 -8
  158. package/test/tools/cutclip.test.js +2 -2
  159. package/test/tools/erase.test.js +6 -6
  160. package/test/tools/explode.test.js +5 -5
  161. package/test/tools/extend.test.js +373 -42
  162. package/test/tools/fillet.test.js +230 -49
  163. package/test/tools/matchProp.test.js +11 -11
  164. package/test/tools/mirror.test.js +39 -39
  165. package/test/tools/move.test.js +7 -7
  166. package/test/tools/offset.test.js +222 -55
  167. package/test/tools/pasteclip.test.js +5 -5
  168. package/test/tools/purge.test.js +4 -4
  169. package/test/tools/rotate.test.js +39 -9
  170. package/test/tools/scale.test.js +33 -33
  171. package/test/tools/trim.test.js +579 -53
  172. package/test/tools/zoom.test.js +12 -12
  173. package/core/lib/selectionWindow.js +0 -83
@@ -1,12 +1,29 @@
1
1
  import { Core } from '../../core/core/core.js';
2
2
  import { Point } from '../../core/entities/point.js';
3
+ import { BoundingBox } from '../../core/lib/boundingBox.js';
3
4
  import { Matrix } from '../../core/lib/matrix.js';
4
5
  import { Input } from '../../core/lib/input.js';
6
+ import { Colour } from '../../core/lib/colour.js';
7
+ import { PlotOptions } from '../../core/lib/plotOptions.js';
5
8
  import { jest } from '@jest/globals';
9
+ import { MockRenderer } from '../test-helpers/test-helpers.js';
6
10
 
7
11
  const core = new Core();
8
12
  const canvas = core.canvas;
9
13
 
14
+ canvas.setRenderer(MockRenderer);
15
+
16
+ /**
17
+ * Add getProperty(name) to a mock entity so canvas.setContext/isAci7 can read properties.
18
+ * @param {Object} obj - plain object representing a mock entity
19
+ * @return {Object} the same object with a getProperty method mixed in
20
+ */
21
+ function withGetProperty(obj) {
22
+ return { ...obj, getProperty(name) {
23
+ return this[name];
24
+ } };
25
+ }
26
+
10
27
  /**
11
28
  * Create a mock canvas rendering context for testing
12
29
  * @return {object} mock context
@@ -36,21 +53,12 @@ test('Test Canvas constructor defaults', () => {
36
53
  expect(canvas.minScaleFactor).toBe(0.05);
37
54
  expect(canvas.maxScaleFactor).toBe(300);
38
55
  expect(canvas.flipped).toBe(false);
39
- expect(canvas.panDelta).toBeDefined();
40
- expect(canvas.lastDelta).toBeDefined();
41
56
  });
42
57
 
43
58
  test('Test Canvas.getScale', () => {
44
59
  expect(canvas.getScale()).toBe(canvas.matrix.getScale());
45
60
  });
46
61
 
47
- test('Test Canvas.paintStates', () => {
48
- expect(canvas.paintStates.ENTITIES).toBe('ENTITIES');
49
- expect(canvas.paintStates.TEMPORARY).toBe('TEMPORARY');
50
- expect(canvas.paintStates.SELECTED).toBe('SELECTED');
51
- expect(canvas.paintStates.AUXILLARY).toBe('AUXILLARY');
52
- });
53
-
54
62
  test('Test Canvas.setExternalPaintCallbackFunction', () => {
55
63
  let called = false;
56
64
  const callback = () => {
@@ -138,11 +146,14 @@ test('Test Canvas.paint flips only once', () => {
138
146
  expect(canvas.flipped).toBe(true);
139
147
  });
140
148
 
141
- test('Test Canvas.mouseUp resets pan state on middle button', () => {
142
- canvas.lastDelta = new Point(50, 50);
143
- canvas.mouseUp(1);
144
- expect(canvas.lastDelta.x).toBe(0);
145
- expect(canvas.lastDelta.y).toBe(0);
149
+ test('Test Canvas.mouseUp calls requestPaint on middle button', () => {
150
+ const paintSpy = jest.spyOn(canvas, 'requestPaint').mockImplementation(() => {});
151
+ try {
152
+ canvas.mouseUp(1);
153
+ expect(paintSpy).toHaveBeenCalled();
154
+ } finally {
155
+ paintSpy.mockRestore();
156
+ }
146
157
  });
147
158
 
148
159
  test('Test Canvas.getSceneOffset returns extents', () => {
@@ -158,7 +169,7 @@ test('Test Canvas.getSceneOffset returns extents', () => {
158
169
  });
159
170
 
160
171
  test('Test Canvas.zoomExtents with entities', () => {
161
- core.scene.addItem('Line', { points: [new Point(0, 0), new Point(500, 500)] });
172
+ core.scene.addEntity('Line', { points: [new Point(0, 0), new Point(500, 500)] });
162
173
  canvas.matrix = new Matrix();
163
174
  canvas.width = 800;
164
175
  canvas.height = 600;
@@ -176,28 +187,28 @@ test('Test Canvas.zoomExtents with no entities', () => {
176
187
  expect(emptyCanvas.getScale()).toBe(scaleBefore);
177
188
  });
178
189
 
179
- const aci7MockEntity = {
190
+ const aci7MockEntity = withGetProperty({
180
191
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
181
192
  getLineType: () => ({ getPattern: () => [] }),
182
193
  lineWidth: 1,
183
194
  entityColour: { aci: 7, byLayer: false, byBlock: false },
184
- };
195
+ });
185
196
 
186
197
  // An entity with ACI 3 whose draw colour happens to be near-white — NOT ACI 7, should not be recoloured
187
- const nearWhiteMockEntity = {
198
+ const nearWhiteMockEntity = withGetProperty({
188
199
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
189
200
  getLineType: () => ({ getPattern: () => [] }),
190
201
  lineWidth: 1,
191
202
  entityColour: { aci: 3, byLayer: false, byBlock: false },
192
- };
203
+ });
193
204
 
194
205
  // An entity with a true colour whose aci fell back to 7 (non-ACI-table colour)
195
- const trueColourFallbackAci7MockEntity = {
206
+ const trueColourFallbackAci7MockEntity = withGetProperty({
196
207
  getDrawColour: () => ({ r: 200, g: 100, b: 50 }),
197
208
  getLineType: () => ({ getPattern: () => [] }),
198
209
  lineWidth: 1,
199
210
  entityColour: { aci: 7, isTrueColour: true, byLayer: false, byBlock: false },
200
- };
211
+ });
201
212
 
202
213
  describe('Test Canvas.setContext ACI 7 background-dependent colour', () => {
203
214
  let originalBackground;
@@ -205,7 +216,6 @@ describe('Test Canvas.setContext ACI 7 background-dependent colour', () => {
205
216
  beforeEach(() => {
206
217
  core.activate();
207
218
  originalBackground = core.settings.canvasbackgroundcolour;
208
- canvas.paintState = canvas.paintStates.ENTITIES;
209
219
  });
210
220
 
211
221
  afterEach(() => {
@@ -213,207 +223,268 @@ describe('Test Canvas.setContext ACI 7 background-dependent colour', () => {
213
223
  });
214
224
 
215
225
  test('ACI 7 is white on dark background', () => {
216
- const context = createMockContext();
226
+ const renderer = new MockRenderer();
217
227
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
218
- canvas.setContext(aci7MockEntity, context);
219
- expect(context.strokeStyle).toBe('rgb(255, 255, 255)');
228
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
229
+ canvas.setContext(aci7MockEntity, renderer);
230
+ expect(renderer.strokeStyle).toBe('rgb(255, 255, 255)');
220
231
  });
221
232
 
222
233
  test('ACI 7 is black on light background', () => {
223
- const context = createMockContext();
234
+ const renderer = new MockRenderer();
224
235
  core.settings.canvasbackgroundcolour = { r: 246, g: 245, b: 244 };
225
- canvas.setContext(aci7MockEntity, context);
226
- expect(context.strokeStyle).toBe('rgb(0, 0, 0)');
236
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
237
+ canvas.setContext(aci7MockEntity, renderer);
238
+ expect(renderer.strokeStyle).toBe('rgb(0, 0, 0)');
227
239
  });
228
240
 
229
241
  test('non-ACI-7 near-white colour is not recoloured', () => {
230
- const context = createMockContext();
242
+ const renderer = new MockRenderer();
231
243
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
232
- canvas.setContext(nearWhiteMockEntity, context);
233
- expect(context.strokeStyle).toBe('rgb(254, 254, 254)');
244
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
245
+ canvas.setContext(nearWhiteMockEntity, renderer);
246
+ expect(renderer.strokeStyle).toBe('rgb(254, 254, 254)');
234
247
  });
235
248
 
236
249
  test('true colour with fallback aci=7 is not recoloured', () => {
237
- const context = createMockContext();
250
+ const renderer = new MockRenderer();
238
251
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
239
- canvas.setContext(trueColourFallbackAci7MockEntity, context);
240
- expect(context.strokeStyle).toBe('rgb(200, 100, 50)');
252
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
253
+ canvas.setContext(trueColourFallbackAci7MockEntity, renderer);
254
+ expect(renderer.strokeStyle).toBe('rgb(200, 100, 50)');
241
255
  });
242
256
 
243
257
  test('ACI 7 via ByLayer resolves white on dark background', () => {
244
258
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
245
- const context = createMockContext();
246
- const byLayerEntity = {
259
+ const renderer = new MockRenderer();
260
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
261
+ const byLayerEntity = withGetProperty({
247
262
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
248
263
  getLineType: () => ({ getPattern: () => [] }),
249
264
  lineWidth: 1,
250
265
  entityColour: { aci: 256, byLayer: true, byBlock: false },
251
266
  layer: '0',
252
- };
253
- canvas.setContext(byLayerEntity, context);
254
- expect(context.strokeStyle).toBe('rgb(255, 255, 255)');
267
+ });
268
+ canvas.setContext(byLayerEntity, renderer);
269
+ expect(renderer.strokeStyle).toBe('rgb(255, 255, 255)');
255
270
  });
256
271
 
257
272
  test('ACI 7 via ByBlock (direct) resolves white on dark background', () => {
258
273
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
259
- const context = createMockContext();
260
- const byBlockItem = {
274
+ const renderer = new MockRenderer();
275
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
276
+ const byBlockItem = withGetProperty({
261
277
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
262
278
  getLineType: () => ({ getPattern: () => [] }),
263
279
  lineWidth: 1,
264
280
  entityColour: { aci: 0, byLayer: false, byBlock: true },
265
- };
266
- const block = {
281
+ });
282
+ const block = withGetProperty({
267
283
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
268
284
  entityColour: { aci: 7, byLayer: false, byBlock: false },
269
- };
270
- canvas.setContext(byBlockItem, context, block);
271
- expect(context.strokeStyle).toBe('rgb(255, 255, 255)');
285
+ });
286
+ canvas.setContext(byBlockItem, renderer, block);
287
+ expect(renderer.strokeStyle).toBe('rgb(255, 255, 255)');
272
288
  });
273
289
 
274
290
  test('ACI 7 via ByBlock → ByLayer resolves white on dark background', () => {
275
291
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
276
- const context = createMockContext();
277
- const byBlockItem = {
292
+ const renderer = new MockRenderer();
293
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
294
+ const byBlockItem = withGetProperty({
278
295
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
279
296
  getLineType: () => ({ getPattern: () => [] }),
280
297
  lineWidth: 1,
281
298
  entityColour: { aci: 0, byLayer: false, byBlock: true },
282
- };
283
- const block = {
299
+ });
300
+ const block = withGetProperty({
284
301
  getDrawColour: () => ({ r: 254, g: 254, b: 254 }),
285
302
  entityColour: { aci: 256, byLayer: true, byBlock: false },
286
303
  layer: '0',
287
- };
288
- canvas.setContext(byBlockItem, context, block);
289
- expect(context.strokeStyle).toBe('rgb(255, 255, 255)');
304
+ });
305
+ canvas.setContext(byBlockItem, renderer, block);
306
+ expect(renderer.strokeStyle).toBe('rgb(255, 255, 255)');
290
307
  });
291
308
 
292
- test('ACI 7 is not recoloured in SELECTED state', () => {
309
+ test('ACI 7 is recoloured in SELECTED state', () => {
293
310
  core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
294
- const context = createMockContext();
295
- canvas.paintState = canvas.paintStates.SELECTED;
296
- canvas.setContext(aci7MockEntity, context);
297
- const sel = core.settings.selecteditemscolour;
298
- expect(context.strokeStyle).toBe(`rgb(${sel.r}, ${sel.g}, ${sel.b})`);
311
+ const renderer = new MockRenderer();
312
+ renderer.setBackgroundColour(core.settings.canvasbackgroundcolour);
313
+ canvas.setContext(aci7MockEntity, renderer);
314
+ // SELECTED pass uses no special flags — ACI 7 background-adaptive logic still applies
315
+ expect(renderer.strokeStyle).toBe('rgb(255, 255, 255)');
299
316
  });
300
317
  });
301
318
 
302
319
  describe('Test Canvas.setContext ByBlock colour', () => {
303
320
  beforeEach(() => {
304
321
  core.activate();
305
- canvas.paintState = canvas.paintStates.ENTITIES;
306
322
  });
307
323
 
308
- const byBlockItem = {
324
+ const byBlockItem = withGetProperty({
309
325
  getDrawColour: () => ({ r: 0, g: 0, b: 0 }),
310
326
  getLineType: () => ({ getPattern: () => [] }),
311
327
  lineWidth: 1,
312
328
  entityColour: { aci: 0, byLayer: false, byBlock: true },
313
- };
314
- const block = {
329
+ });
330
+ const block = withGetProperty({
315
331
  getDrawColour: () => ({ r: 255, g: 0, b: 0 }),
316
332
  entityColour: { aci: 1, byLayer: false, byBlock: false },
317
- };
333
+ });
318
334
 
319
335
  test('ByBlock item inherits draw colour from block', () => {
320
- const context = createMockContext();
321
- canvas.setContext(byBlockItem, context, block);
322
- expect(context.strokeStyle).toBe('rgb(255, 0, 0)');
323
- expect(context.fillStyle).toBe('rgb(255, 0, 0)');
336
+ const renderer = new MockRenderer();
337
+ canvas.setContext(byBlockItem, renderer, block);
338
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
339
+ expect(renderer.fillStyle).toBe('rgb(255, 0, 0)');
324
340
  });
325
341
 
326
- test('ByBlock colour is not applied in SELECTED state', () => {
327
- const context = createMockContext();
328
- canvas.paintState = canvas.paintStates.SELECTED;
329
- canvas.setContext(byBlockItem, context, block);
330
- const sel = core.settings.selecteditemscolour;
331
- expect(context.strokeStyle).toBe(`rgb(${sel.r}, ${sel.g}, ${sel.b})`);
342
+ test('overrides.colour is the glow colour; ByBlock colour resolution still applies for normal draw', () => {
343
+ const renderer = new MockRenderer();
344
+ canvas.setContext(byBlockItem, renderer, block, { colour: { r: 0, g: 255, b: 0 } });
345
+ // Entity still draws in its ByBlock-resolved colour
346
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
347
+ // overrides.colour goes into the highlight glow
348
+ expect(renderer.isHighlighted).toBe(true);
349
+ expect(renderer.highlightColour).toEqual({ r: 0, g: 255, b: 0 });
332
350
  });
333
351
 
334
352
  test('non-ByBlock item with block keeps its own colour', () => {
335
- const context = createMockContext();
336
- const directItem = {
353
+ const renderer = new MockRenderer();
354
+ const directItem = withGetProperty({
337
355
  getDrawColour: () => ({ r: 0, g: 255, b: 0 }),
338
356
  getLineType: () => ({ getPattern: () => [] }),
339
357
  lineWidth: 1,
340
358
  entityColour: { aci: 2, byLayer: false, byBlock: false },
341
- };
342
- canvas.setContext(directItem, context, block);
343
- expect(context.strokeStyle).toBe('rgb(0, 255, 0)');
359
+ });
360
+ canvas.setContext(directItem, renderer, block);
361
+ expect(renderer.strokeStyle).toBe('rgb(0, 255, 0)');
344
362
  });
345
363
  });
346
364
 
347
365
  describe('Test Canvas.setContext paint states', () => {
348
- const redEntity = {
366
+ const redEntity = withGetProperty({
349
367
  getDrawColour: () => ({ r: 255, g: 0, b: 0 }),
350
368
  getLineType: () => ({ getPattern: () => [] }),
351
369
  lineWidth: 10,
352
370
  entityColour: { aci: 1, byLayer: false, byBlock: false },
353
- };
371
+ });
354
372
 
355
373
  beforeEach(() => {
356
374
  core.activate();
357
375
  canvas.matrix = new Matrix();
358
376
  });
359
377
 
360
- test('ENTITIES state uses entity colour and normal lineWidth', () => {
361
- const context = createMockContext();
362
- canvas.paintState = canvas.paintStates.ENTITIES;
363
- canvas.setContext(redEntity, context);
364
- expect(context.strokeStyle).toBe('rgb(255, 0, 0)');
365
- expect(context.fillStyle).toBe('rgb(255, 0, 0)');
366
- expect(context.lineWidth).toBe(10 / canvas.getScale());
378
+ test('default (no flags) uses entity colour and normal lineWidth', () => {
379
+ const renderer = new MockRenderer();
380
+ canvas.setContext(redEntity, renderer);
381
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
382
+ expect(renderer.fillStyle).toBe('rgb(255, 0, 0)');
383
+ expect(renderer.lineWidth).toBe(10 / canvas.getScale());
367
384
  });
368
385
 
369
- test('SELECTED state overrides colour with selecteditemscolour and doubles lineWidth', () => {
370
- const context = createMockContext();
371
- canvas.paintState = canvas.paintStates.SELECTED;
372
- canvas.setContext(redEntity, context);
373
- const sel = core.settings.selecteditemscolour;
374
- expect(context.strokeStyle).toBe(`rgb(${sel.r}, ${sel.g}, ${sel.b})`);
375
- expect(context.lineWidth).toBe(10 * 2 / canvas.getScale());
386
+ test('overrides.colour becomes the highlight glow colour; entity draws in its natural colour', () => {
387
+ const renderer = new MockRenderer();
388
+ const haloColour = Colour.blend(core.settings.accentcolour, core.settings.canvasbackgroundcolour, 0.5);
389
+ canvas.setContext(redEntity, renderer, undefined, { colour: haloColour, lineWidthDelta: 4 });
390
+ // Entity always draws in its own colour on the normal pass
391
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
392
+ // lineWidthDelta goes to the highlight delta, not to lineWidth
393
+ expect(renderer.lineWidth).toBe(10 / canvas.getScale());
394
+ expect(renderer.isHighlighted).toBe(true);
395
+ expect(renderer.highlightColour).toEqual(haloColour);
396
+ expect(renderer.highlightLineWidthDelta).toBeCloseTo(4 / canvas.getScale());
376
397
  });
377
398
 
378
- test('TEMPORARY state keeps entity colour and doubles lineWidth', () => {
379
- const context = createMockContext();
380
- canvas.paintState = canvas.paintStates.TEMPORARY;
381
- canvas.setContext(redEntity, context);
382
- expect(context.strokeStyle).toBe('rgb(255, 0, 0)');
383
- expect(context.lineWidth).toBe(10 * 2 / canvas.getScale());
399
+ test('overrides.colour without lineWidthDelta sets highlight glow; entity draws in its own colour', () => {
400
+ const renderer = new MockRenderer();
401
+ canvas.setContext(redEntity, renderer, undefined, { colour: { r: 0, g: 255, b: 0 } });
402
+ // Entity still draws in its own colour
403
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
404
+ expect(renderer.lineWidth).toBe(10 / canvas.getScale());
405
+ expect(renderer.isHighlighted).toBe(true);
406
+ expect(renderer.highlightColour).toEqual({ r: 0, g: 255, b: 0 });
384
407
  });
385
408
 
386
- test('setContext applies line type dash pattern to context', () => {
387
- const context = createMockContext();
388
- let appliedPattern = null;
389
- context.setLineDash = (pattern) => {
390
- appliedPattern = pattern;
391
- };
392
- const dashedEntity = {
409
+ test('setContext applies line type dash pattern to renderer', () => {
410
+ const renderer = new MockRenderer();
411
+ const dashedEntity = withGetProperty({
393
412
  getDrawColour: () => ({ r: 255, g: 0, b: 0 }),
394
413
  getLineType: () => ({ getPattern: () => [5, 5] }),
395
414
  lineWidth: 1,
396
415
  entityColour: { aci: 1, byLayer: false, byBlock: false },
397
- };
398
- canvas.paintState = canvas.paintStates.ENTITIES;
399
- canvas.setContext(dashedEntity, context);
400
- expect(appliedPattern).toEqual([5, 5]);
416
+ });
417
+ canvas.setContext(dashedEntity, renderer);
418
+ expect(renderer.dashPattern).toEqual([5, 5]);
401
419
  });
402
420
  });
403
421
 
404
422
  describe('Test Canvas.zoom', () => {
405
423
  beforeEach(() => {
424
+ core.activate();
406
425
  canvas.matrix = new Matrix();
426
+ canvas.width = 800;
427
+ canvas.height = 600;
428
+ canvas.flipped = true;
429
+ });
430
+
431
+ test('zoom(2) doubles the scale', () => {
432
+ const before = canvas.getScale();
433
+ canvas.zoom(2);
434
+ expect(canvas.getScale()).toBeCloseTo(before * 2, 10);
435
+ });
436
+
437
+ test('zoom(0.5) halves the scale', () => {
438
+ const before = canvas.getScale();
439
+ canvas.zoom(0.5);
440
+ expect(canvas.getScale()).toBeCloseTo(before * 0.5, 10);
407
441
  });
408
442
 
409
- test('zoom increases scale', () => {
443
+ test('zoom(1) leaves scale unchanged', () => {
444
+ const before = canvas.getScale();
445
+ canvas.zoom(1);
446
+ expect(canvas.getScale()).toBeCloseTo(before, 10);
447
+ });
448
+
449
+ test('zoom keeps the scene point under the cursor fixed (zoom-point invariant)', () => {
450
+ // Position mouse at canvas (400, 300) — centre of the 800×600 viewport
451
+ core.mouse.mouseMoved(400, 300);
452
+ const sceneBefore = core.mouse.pointOnScene();
453
+
410
454
  canvas.zoom(2);
411
- expect(canvas.getScale()).toBeGreaterThan(1);
455
+
456
+ // The scene point that was under the cursor should not have moved
457
+ const sceneAfter = core.mouse.pointOnScene();
458
+ expect(sceneAfter.x).toBeCloseTo(sceneBefore.x, 4);
459
+ expect(sceneAfter.y).toBeCloseTo(sceneBefore.y, 4);
412
460
  });
413
461
 
414
- test('zoom decreases scale', () => {
462
+ test('zoom-point invariant holds for an off-centre mouse position', () => {
463
+ core.mouse.mouseMoved(200, 150);
464
+ const sceneBefore = core.mouse.pointOnScene();
465
+
466
+ canvas.zoom(3);
467
+
468
+ const sceneAfter = core.mouse.pointOnScene();
469
+ expect(sceneAfter.x).toBeCloseTo(sceneBefore.x, 4);
470
+ expect(sceneAfter.y).toBeCloseTo(sceneBefore.y, 4);
471
+ });
472
+
473
+ test('zoom-point invariant holds when zooming out', () => {
474
+ core.mouse.mouseMoved(600, 450);
475
+ const sceneBefore = core.mouse.pointOnScene();
476
+
415
477
  canvas.zoom(0.5);
416
- expect(canvas.getScale()).toBeLessThan(1);
478
+
479
+ const sceneAfter = core.mouse.pointOnScene();
480
+ expect(sceneAfter.x).toBeCloseTo(sceneBefore.x, 4);
481
+ expect(sceneAfter.y).toBeCloseTo(sceneBefore.y, 4);
482
+ });
483
+
484
+ test('successive zooms compound scale correctly', () => {
485
+ canvas.zoom(2);
486
+ canvas.zoom(3);
487
+ expect(canvas.getScale()).toBeCloseTo(6, 10);
417
488
  });
418
489
  });
419
490
 
@@ -425,7 +496,7 @@ test('Test Canvas.doubleClick left and right buttons do not throw', () => {
425
496
  test('Test Canvas.doubleClick middle button triggers zoomExtents', () => {
426
497
  const testCore = new Core();
427
498
  testCore.activate();
428
- testCore.scene.addItem('Line', { points: [new Point(0, 0), new Point(500, 500)] });
499
+ testCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(500, 500)] });
429
500
  testCore.canvas.width = 800;
430
501
  testCore.canvas.height = 600;
431
502
  testCore.canvas.matrix = new Matrix();
@@ -443,14 +514,6 @@ test('Test Canvas.zoomToWindow with inverted coordinate order', () => {
443
514
  expect(canvas.getScale()).toBeGreaterThan(1);
444
515
  });
445
516
 
446
- test('Test Canvas.paint resets paintState to undefined after painting', () => {
447
- core.activate();
448
- canvas.matrix = new Matrix();
449
- canvas.flipped = true;
450
- canvas.paint(createMockContext(), 800, 600);
451
- expect(canvas.paintState).toBeUndefined();
452
- });
453
-
454
517
  test('Test Canvas.getSceneOffset visible area shrinks when zoomed in', () => {
455
518
  core.activate();
456
519
  canvas.matrix = new Matrix();
@@ -614,3 +677,559 @@ describe('Test Canvas cursor states', () => {
614
677
  expect(() => canvas.mouseMoved()).not.toThrow();
615
678
  });
616
679
  });
680
+
681
+ // ─── Canvas.pan() ─────────────────────────────────────────────────────────────
682
+
683
+ describe('Test Canvas.pan', () => {
684
+ beforeEach(() => {
685
+ core.activate();
686
+ canvas.matrix = new Matrix();
687
+ canvas.width = 800;
688
+ canvas.height = 600;
689
+ canvas.flipped = true; // avoid flip side-effect in pan calls
690
+ });
691
+
692
+ test('pan translates matrix by the scene-space mouse movement', () => {
693
+ // Simulate a left mouseDown at canvas (100, 300) — sets #lastPanCanvasPoint
694
+ core.mouse.mouseMoved(100, 300);
695
+ canvas.mouseDown(0);
696
+
697
+ // Move mouse 50 pixels right, 20 pixels down in canvas space
698
+ core.mouse.mouseMoved(150, 320);
699
+ canvas.pan();
700
+
701
+ // At scale=1 the canvas y-axis is inverted relative to scene:
702
+ // canvas dy = +20 → scene dy = -20
703
+ const e = canvas.matrix.e; // x translation
704
+ const f = canvas.matrix.f; // y translation (raw matrix value)
705
+ expect(e).toBeCloseTo(50, 5);
706
+ // Moving the mouse 20 raw pixels down decreases Mouse.y by 20 (y = -raw + height).
707
+ // transformToScene re-inverts, so scene delta.y = +20. matrix.translate(50, 20) → f += 20.
708
+ expect(f).toBeCloseTo(20, 5);
709
+ });
710
+
711
+ test('second pan call accumulates correctly (incremental anchor advances)', () => {
712
+ // mouseDown at (0, 0)
713
+ core.mouse.mouseMoved(0, 0);
714
+ canvas.mouseDown(0);
715
+
716
+ // First move: +100 x, no y
717
+ core.mouse.mouseMoved(100, 0);
718
+ canvas.pan();
719
+
720
+ // Second move: another +100 x — anchor advanced so delta is still +100, not +200
721
+ core.mouse.mouseMoved(200, 0);
722
+ canvas.pan();
723
+
724
+ expect(canvas.matrix.e).toBeCloseTo(200, 5);
725
+ });
726
+
727
+ test('pan at 2× zoom applies correct scene-space delta', () => {
728
+ canvas.zoom(2);
729
+ const scaledE = canvas.matrix.e;
730
+
731
+ core.mouse.mouseMoved(0, 300);
732
+ canvas.mouseDown(0);
733
+
734
+ // Move 100 canvas pixels right
735
+ core.mouse.mouseMoved(100, 300);
736
+ canvas.pan();
737
+
738
+ // At any zoom level, dragging 100 canvas pixels right always changes matrix.e by 100:
739
+ // scene_delta = 100 / scale, then matrix.translate(scene_delta) multiplies by scale → 100.
740
+ // This gives a consistent 1:1 screen-space pan feel regardless of zoom.
741
+ const deltaE = canvas.matrix.e - scaledE;
742
+ expect(deltaE).toBeCloseTo(100, 4);
743
+ });
744
+
745
+ test('pan at 0.5× zoom applies correct scene-space delta', () => {
746
+ canvas.zoom(0.5);
747
+ const scaledE = canvas.matrix.e;
748
+
749
+ core.mouse.mouseMoved(0, 300);
750
+ canvas.mouseDown(0);
751
+
752
+ // Move 100 canvas pixels right
753
+ core.mouse.mouseMoved(100, 300);
754
+ canvas.pan();
755
+
756
+ // Same invariant: canvas_delta/scale * scale = canvas_delta → deltaE = 100.
757
+ const deltaE = canvas.matrix.e - scaledE;
758
+ expect(deltaE).toBeCloseTo(100, 4);
759
+ });
760
+
761
+ test('pan with no movement produces no translation', () => {
762
+ core.mouse.mouseMoved(200, 200);
763
+ canvas.mouseDown(0);
764
+ const eBefore = canvas.matrix.e;
765
+ const fBefore = canvas.matrix.f;
766
+
767
+ canvas.pan(); // mouse hasn't moved
768
+
769
+ expect(canvas.matrix.e).toBeCloseTo(eBefore, 10);
770
+ expect(canvas.matrix.f).toBeCloseTo(fBefore, 10);
771
+ });
772
+
773
+ test('middle-button mouseDown also seeds the pan anchor correctly', () => {
774
+ core.mouse.mouseMoved(50, 300);
775
+ canvas.mouseDown(1); // middle button
776
+
777
+ core.mouse.mouseMoved(150, 300); // +100 x
778
+ canvas.pan();
779
+
780
+ expect(canvas.matrix.e).toBeCloseTo(100, 5);
781
+ });
782
+
783
+ test('mouseDownCanvasPoint is not mutated by pan()', () => {
784
+ core.mouse.mouseMoved(100, 300);
785
+ canvas.mouseDown(0);
786
+ const originalX = core.mouse.mouseDownCanvasPoint.x;
787
+ const originalY = core.mouse.mouseDownCanvasPoint.y;
788
+
789
+ core.mouse.mouseMoved(200, 400);
790
+ canvas.pan();
791
+
792
+ // pan() must not touch Mouse.mouseDownCanvasPoint
793
+ expect(core.mouse.mouseDownCanvasPoint.x).toBe(originalX);
794
+ expect(core.mouse.mouseDownCanvasPoint.y).toBe(originalY);
795
+ });
796
+ });
797
+
798
+ // ─── Canvas.#paintEntity (via paint / previewEntities) ───────────────────────
799
+
800
+ describe('Test Canvas.#paintEntity', () => {
801
+ /**
802
+ * Create a mock canvas context that records save/restore calls
803
+ * and allows overriding individual methods.
804
+ * @param {Object} [overrides] - properties to merge into the context
805
+ * @return {Object} spy context
806
+ */
807
+ function createSpyContext(overrides = {}) {
808
+ let depth = 0;
809
+ let maxDepth = 0;
810
+ const saveRestoreLog = [];
811
+
812
+ const ctx = {
813
+ setTransform: () => {},
814
+ translate: () => {},
815
+ rotate: () => {},
816
+ scale: () => {},
817
+ fillStyle: '',
818
+ fillRect: () => {},
819
+ strokeStyle: '',
820
+ lineWidth: 0,
821
+ setLineDash: () => {},
822
+ beginPath: () => {},
823
+ moveTo: () => {},
824
+ lineTo: () => {},
825
+ stroke: () => {},
826
+ fill: () => {},
827
+ save() {
828
+ depth++;
829
+ maxDepth = Math.max(maxDepth, depth);
830
+ saveRestoreLog.push('save');
831
+ },
832
+ restore() {
833
+ depth--;
834
+ saveRestoreLog.push('restore');
835
+ },
836
+ get saveDepth() {
837
+ return depth;
838
+ },
839
+ get maxSaveDepth() {
840
+ return maxDepth;
841
+ },
842
+ get saveRestoreLog() {
843
+ return saveRestoreLog;
844
+ },
845
+ ...overrides,
846
+ };
847
+ return ctx;
848
+ }
849
+
850
+ /**
851
+ * Minimal mock entity that behaves as a leaf (draw returns undefined)
852
+ * @param {Object} [colour] - draw colour
853
+ * @return {Object} mock leaf entity
854
+ */
855
+ function makeLeaf(colour = { r: 255, g: 0, b: 0 }) {
856
+ return withGetProperty({
857
+ getDrawColour: () => colour,
858
+ getLineType: () => ({ getPattern: () => [] }),
859
+ lineWidth: 1,
860
+ entityColour: { aci: 1, byLayer: false, byBlock: false },
861
+ draw: jest.fn(() => undefined),
862
+ });
863
+ }
864
+
865
+ /**
866
+ * Mock entity whose draw() applies a translate and returns an array of children
867
+ * @param {Array} children
868
+ * @return {Object} mock container entity
869
+ */
870
+ function makeContainer(children) {
871
+ return withGetProperty({
872
+ getDrawColour: () => ({ r: 0, g: 255, b: 0 }),
873
+ getLineType: () => ({ getPattern: () => [] }),
874
+ lineWidth: 1,
875
+ entityColour: { aci: 2, byLayer: false, byBlock: false },
876
+ draw: jest.fn((ctx) => {
877
+ ctx.applyTransform({ x: 10, y: 10 });
878
+ return children;
879
+ }),
880
+ });
881
+ }
882
+
883
+ let paintCore;
884
+ let paintCanvas;
885
+
886
+ beforeEach(() => {
887
+ paintCore = new Core();
888
+ paintCore.activate();
889
+ paintCanvas = paintCore.canvas;
890
+ paintCanvas.setRenderer(MockRenderer);
891
+ paintCanvas.matrix = new Matrix();
892
+ paintCanvas.flipped = true; // skip the one-time flip inside paint()
893
+ paintCanvas.width = 800;
894
+ paintCanvas.height = 600;
895
+ });
896
+
897
+ afterEach(() => {
898
+ paintCore.scene.previewEntities.clear();
899
+ });
900
+
901
+ test('leaf entity: draw() is called once', () => {
902
+ const leaf = makeLeaf();
903
+ paintCore.scene.previewEntities.add(leaf);
904
+
905
+ paintCanvas.paint(createSpyContext(), 800, 600);
906
+
907
+ expect(leaf.draw).toHaveBeenCalledTimes(1);
908
+ });
909
+
910
+ test('leaf entity: save/restore wrap the draw() call', () => {
911
+ const leaf = makeLeaf();
912
+ paintCore.scene.previewEntities.add(leaf);
913
+
914
+ const ctx = createSpyContext();
915
+ paintCanvas.paint(ctx, 800, 600);
916
+
917
+ // save and restore are balanced
918
+ const saves = ctx.saveRestoreLog.filter((e) => e === 'save').length;
919
+ const restores = ctx.saveRestoreLog.filter((e) => e === 'restore').length;
920
+ expect(saves).toBe(restores);
921
+ // Entity save appears before entity restore
922
+ const firstEntitySave = ctx.saveRestoreLog.indexOf('save');
923
+ const lastEntityRestore = ctx.saveRestoreLog.lastIndexOf('restore');
924
+ expect(firstEntitySave).toBeLessThan(lastEntityRestore);
925
+ });
926
+
927
+ test('container entity: children draw() are each called once', () => {
928
+ const child1 = makeLeaf({ r: 255, g: 0, b: 0 });
929
+ const child2 = makeLeaf({ r: 0, g: 0, b: 255 });
930
+ const container = makeContainer([child1, child2]);
931
+ paintCore.scene.previewEntities.add(container);
932
+
933
+ paintCanvas.paint(createSpyContext(), 800, 600);
934
+
935
+ expect(container.draw).toHaveBeenCalledTimes(1);
936
+ expect(child1.draw).toHaveBeenCalledTimes(1);
937
+ expect(child2.draw).toHaveBeenCalledTimes(1);
938
+ });
939
+
940
+ test('container entity: save/restore are balanced including children', () => {
941
+ const child = makeLeaf();
942
+ const container = makeContainer([child]);
943
+ paintCore.scene.previewEntities.add(container);
944
+
945
+ const ctx = createSpyContext();
946
+ paintCanvas.paint(ctx, 800, 600);
947
+
948
+ const saves = ctx.saveRestoreLog.filter((e) => e === 'save').length;
949
+ const restores = ctx.saveRestoreLog.filter((e) => e === 'restore').length;
950
+ expect(saves).toBe(restores);
951
+ });
952
+
953
+ test('container with empty items array: no child draws occur', () => {
954
+ const container = makeContainer([]);
955
+ paintCore.scene.previewEntities.add(container);
956
+
957
+ paintCanvas.paint(createSpyContext(), 800, 600);
958
+
959
+ expect(container.draw).toHaveBeenCalledTimes(1);
960
+ });
961
+
962
+ test('overrides.colour sets highlight glow; leaf entity draws in its natural colour', () => {
963
+ const leaf = makeLeaf({ r: 255, g: 0, b: 0 });
964
+ const renderer = new MockRenderer();
965
+ paintCanvas.setContext(leaf, renderer, undefined, { colour: { r: 0, g: 0, b: 255 } });
966
+ // Entity draws in its natural colour, not the override
967
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
968
+ expect(renderer.isHighlighted).toBe(true);
969
+ expect(renderer.highlightColour).toEqual({ r: 0, g: 0, b: 255 });
970
+ });
971
+
972
+ test('overrides.colour sets highlight glow for children of a container', () => {
973
+ const child = makeLeaf({ r: 255, g: 0, b: 0 });
974
+ const overrideColour = { r: 0, g: 255, b: 0 };
975
+ const renderer = new MockRenderer();
976
+
977
+ // setContext is called for the child — verify the highlight glow colour flows through
978
+ // and the entity draws in its own natural colour
979
+ paintCanvas.setContext(child, renderer, undefined, { colour: overrideColour });
980
+ expect(renderer.strokeStyle).toBe('rgb(255, 0, 0)');
981
+ expect(renderer.isHighlighted).toBe(true);
982
+ expect(renderer.highlightColour).toEqual(overrideColour);
983
+ });
984
+
985
+ test('deeply nested container: all descendants have draw() called', () => {
986
+ const grandchild = makeLeaf();
987
+ const child = makeContainer([grandchild]);
988
+ const root = makeContainer([child]);
989
+ paintCore.scene.previewEntities.add(root);
990
+
991
+ paintCanvas.paint(createSpyContext(), 800, 600);
992
+
993
+ expect(root.draw).toHaveBeenCalledTimes(1);
994
+ expect(child.draw).toHaveBeenCalledTimes(1);
995
+ expect(grandchild.draw).toHaveBeenCalledTimes(1);
996
+ });
997
+
998
+ test('multiple leaf entities each get save/restore', () => {
999
+ const leaf1 = makeLeaf();
1000
+ const leaf2 = makeLeaf();
1001
+ paintCore.scene.previewEntities.add(leaf1);
1002
+ paintCore.scene.previewEntities.add(leaf2);
1003
+
1004
+ const ctx = createSpyContext();
1005
+ paintCanvas.paint(ctx, 800, 600);
1006
+
1007
+ expect(leaf1.draw).toHaveBeenCalledTimes(1);
1008
+ expect(leaf2.draw).toHaveBeenCalledTimes(1);
1009
+ const saves = ctx.saveRestoreLog.filter((e) => e === 'save').length;
1010
+ const restores = ctx.saveRestoreLog.filter((e) => e === 'restore').length;
1011
+ expect(saves).toBe(restores);
1012
+ });
1013
+
1014
+ test('restore() is called even when draw() throws', () => {
1015
+ const throwing = withGetProperty({
1016
+ getDrawColour: () => ({ r: 255, g: 0, b: 0 }),
1017
+ getLineType: () => ({ getPattern: () => [] }),
1018
+ lineWidth: 1,
1019
+ entityColour: { aci: 1, byLayer: false, byBlock: false },
1020
+ draw: jest.fn(() => {
1021
+ throw new Error('draw failure');
1022
+ }),
1023
+ });
1024
+ paintCore.scene.previewEntities.add(throwing);
1025
+
1026
+ const ctx = createSpyContext();
1027
+ // broken entities are caught and logged — paint() must not throw
1028
+ expect(() => paintCanvas.paint(ctx, 800, 600)).not.toThrow();
1029
+
1030
+ const saves = ctx.saveRestoreLog.filter((e) => e === 'save').length;
1031
+ const restores = ctx.saveRestoreLog.filter((e) => e === 'restore').length;
1032
+ expect(saves).toBe(restores);
1033
+ });
1034
+ });
1035
+
1036
+ // ─── Canvas.buildExportMatrix ─────────────────────────────────────────────────
1037
+
1038
+ /**
1039
+ * Helper: create a BoundingBox from plain coordinates.
1040
+ * @param {number} xMin
1041
+ * @param {number} yMin
1042
+ * @param {number} xMax
1043
+ * @param {number} yMax
1044
+ * @return {BoundingBox}
1045
+ */
1046
+ function makeBBox(xMin, yMin, xMax, yMax) {
1047
+ return new BoundingBox(new Point(xMin, yMin), new Point(xMax, yMax));
1048
+ }
1049
+
1050
+ describe('Canvas.buildExportMatrix', () => {
1051
+ beforeEach(() => {
1052
+ core.activate();
1053
+ canvas.matrix = new Matrix();
1054
+ });
1055
+
1056
+ test('returns null when scene width is zero', () => {
1057
+ const area = makeBBox(100, 0, 100, 200);
1058
+ expect(canvas.buildExportMatrix({ area, pageWidth: 595, pageHeight: 842 })).toBeNull();
1059
+ });
1060
+
1061
+ test('returns null when scene height is zero', () => {
1062
+ const area = makeBBox(0, 50, 200, 50);
1063
+ expect(canvas.buildExportMatrix({ area, pageWidth: 595, pageHeight: 842 })).toBeNull();
1064
+ });
1065
+
1066
+ test('a and d equal the computed scale (no Y-flip)', () => {
1067
+ const area = makeBBox(0, 0, 100, 100);
1068
+ const m = canvas.buildExportMatrix({ area, pageWidth: 595, pageHeight: 842 });
1069
+ expect(m.a).toBe(m.d);
1070
+ expect(m.a).toBeGreaterThan(0);
1071
+ });
1072
+
1073
+ test('fit scale is limited by the narrower page axis', () => {
1074
+ // 200-wide × 100-tall scene on portrait A4 (595 × 842) with default margin 40
1075
+ // usable = 515 × 762; limiting axis = width → scale = 515/200 = 2.575
1076
+ const area = makeBBox(0, 0, 200, 100);
1077
+ const m = canvas.buildExportMatrix({ area, pageWidth: 595, pageHeight: 842 });
1078
+ expect(m.a).toBeCloseTo(515 / 200, 5);
1079
+ });
1080
+
1081
+ test('explicit plotScale overrides fit calculation', () => {
1082
+ const area = makeBBox(0, 0, 100, 100);
1083
+ const m = canvas.buildExportMatrix({ area, pageWidth: 595, pageHeight: 842, plotScale: 2 });
1084
+ expect(m.a).toBe(2);
1085
+ expect(m.d).toBe(2);
1086
+ });
1087
+
1088
+ test('scene origin (0,0) is centred on the page when area is square and page is square', () => {
1089
+ // 100×100 scene on 300×300 page, margin 0 → scale = 3, translateX = (300 - 300)/2 = 0
1090
+ const area = makeBBox(0, 0, 100, 100);
1091
+ const m = canvas.buildExportMatrix({ area, pageWidth: 300, pageHeight: 300, margin: 0 });
1092
+ expect(m.e).toBeCloseTo(0, 5);
1093
+ expect(m.f).toBeCloseTo(0, 5);
1094
+ });
1095
+
1096
+ test('non-zero scene origin is properly offset', () => {
1097
+ // Scene from (50,50) to (150,150) on 300×300 page, no margin
1098
+ // scale = 3, translateX = (300 - 100*3)/2 - 50*3 = 0 - 150 = -150
1099
+ const area = makeBBox(50, 50, 150, 150);
1100
+ const m = canvas.buildExportMatrix({ area, pageWidth: 300, pageHeight: 300, margin: 0 });
1101
+ expect(m.e).toBeCloseTo(-150, 5);
1102
+ expect(m.f).toBeCloseTo(-150, 5);
1103
+ });
1104
+
1105
+ test('custom margin is respected', () => {
1106
+ // 100×100 scene on 300×300 page, margin 50 → usable = 200×200, scale = 2
1107
+ const area = makeBBox(0, 0, 100, 100);
1108
+ const m = canvas.buildExportMatrix({ area, pageWidth: 300, pageHeight: 300, margin: 50 });
1109
+ expect(m.a).toBeCloseTo(2, 5);
1110
+ // translateX = 50 + (200 - 100*2)/2 - 0 = 50 + 0 = 50
1111
+ expect(m.e).toBeCloseTo(50, 5);
1112
+ expect(m.f).toBeCloseTo(50, 5);
1113
+ });
1114
+ });
1115
+
1116
+ // ─── Canvas.exportTo ──────────────────────────────────────────────────────────
1117
+
1118
+ describe('Canvas.exportTo', () => {
1119
+ let exportCore;
1120
+ let exportCanvas;
1121
+ let renderer;
1122
+
1123
+ beforeEach(() => {
1124
+ exportCore = new Core();
1125
+ exportCore.activate();
1126
+ exportCanvas = exportCore.canvas;
1127
+ exportCanvas.matrix = new Matrix();
1128
+ exportCanvas.width = 800;
1129
+ exportCanvas.height = 600;
1130
+ exportCanvas.flipped = true;
1131
+ renderer = new MockRenderer();
1132
+ });
1133
+
1134
+ test('returns false when scene has no entities (no bounding box)', () => {
1135
+ const options = new PlotOptions(595, 842);
1136
+ // Empty scene → Scene.boundingBox() returns null/undefined
1137
+ expect(exportCanvas.exportTo(renderer, options)).toBe(false);
1138
+ });
1139
+
1140
+ test('returns true when scene has entities', () => {
1141
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(100, 100)] });
1142
+ const options = new PlotOptions(595, 842);
1143
+ expect(exportCanvas.exportTo(renderer, options)).toBe(true);
1144
+ });
1145
+
1146
+ test('calls renderer.setTransform with a valid matrix', () => {
1147
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(100, 100)] });
1148
+ let receivedMatrix;
1149
+ renderer.setTransform = (m) => {
1150
+ receivedMatrix = m;
1151
+ };
1152
+ const options = new PlotOptions(595, 842);
1153
+ exportCanvas.exportTo(renderer, options);
1154
+ expect(receivedMatrix).toBeDefined();
1155
+ expect(receivedMatrix.a).toBeGreaterThan(0);
1156
+ expect(receivedMatrix.d).toBeGreaterThan(0);
1157
+ });
1158
+
1159
+ test('calls renderer.setBackgroundColour with white', () => {
1160
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(100, 100)] });
1161
+ let bgColour;
1162
+ renderer.setBackgroundColour = (c) => {
1163
+ bgColour = c;
1164
+ };
1165
+ const options = new PlotOptions(595, 842);
1166
+ exportCanvas.exportTo(renderer, options);
1167
+ expect(bgColour).toEqual({ r: 255, g: 255, b: 255 });
1168
+ });
1169
+
1170
+ test('calls renderer.setStyle with the plotOptions style', () => {
1171
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(100, 100)] });
1172
+ const options = new PlotOptions(595, 842);
1173
+ const spyCalls = [];
1174
+ renderer.setStyle = (fn) => {
1175
+ spyCalls.push(fn);
1176
+ };
1177
+ exportCanvas.exportTo(renderer, options);
1178
+ expect(spyCalls).toHaveLength(1);
1179
+ });
1180
+
1181
+ test('EXTENTS uses Scene.boundingBox regardless of viewport', () => {
1182
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(500, 500)] });
1183
+ // Pan far away so viewport does not contain the entity
1184
+ exportCanvas.matrix.translate(-10000, -10000);
1185
+ let receivedMatrix;
1186
+ renderer.setTransform = (m) => {
1187
+ receivedMatrix = m;
1188
+ };
1189
+ const options = new PlotOptions(595, 842);
1190
+ options.setOption('plotArea', PlotOptions.Area.EXTENTS);
1191
+ exportCanvas.exportTo(renderer, options);
1192
+ // Should have produced a valid matrix mapping the entity extents onto the page
1193
+ expect(receivedMatrix).toBeDefined();
1194
+ expect(receivedMatrix.a).toBeGreaterThan(0);
1195
+ });
1196
+
1197
+ test('DISPLAY uses current viewport instead of entity extents', () => {
1198
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(500, 500)] });
1199
+ let extentsMatrix;
1200
+ let displayMatrix;
1201
+
1202
+ // Capture extents-mode matrix
1203
+ renderer.setTransform = (m) => {
1204
+ extentsMatrix = { ...m };
1205
+ };
1206
+ const extentsOptions = new PlotOptions(595, 842);
1207
+ extentsOptions.setOption('plotArea', PlotOptions.Area.EXTENTS);
1208
+ exportCanvas.exportTo(renderer, extentsOptions);
1209
+
1210
+ // Zoom in to a small region so viewport differs from extents
1211
+ exportCanvas.zoom(10);
1212
+
1213
+ renderer.setTransform = (m) => {
1214
+ displayMatrix = { ...m };
1215
+ };
1216
+ const displayOptions = new PlotOptions(595, 842);
1217
+ displayOptions.setOption('plotArea', PlotOptions.Area.DISPLAY);
1218
+ exportCanvas.exportTo(renderer, displayOptions);
1219
+
1220
+ // Display scale will be larger (more zoomed in region mapped to same page)
1221
+ expect(displayMatrix.a).not.toBeCloseTo(extentsMatrix.a, 3);
1222
+ });
1223
+
1224
+ test('explicit plotScale is forwarded to buildExportMatrix', () => {
1225
+ exportCore.scene.addEntity('Line', { points: [new Point(0, 0), new Point(100, 100)] });
1226
+ let receivedMatrix;
1227
+ renderer.setTransform = (m) => {
1228
+ receivedMatrix = m;
1229
+ };
1230
+ const options = new PlotOptions(595, 842);
1231
+ options.setOption('plotScale', 0.5);
1232
+ exportCanvas.exportTo(renderer, options);
1233
+ expect(receivedMatrix.a).toBeCloseTo(0.5, 5);
1234
+ });
1235
+ });