@design-core/core 0.0.5 → 0.0.7
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.
- package/.eslintrc.json +4 -0
- package/TODO.md +49 -6
- package/core/core/core.js +0 -3
- package/core/dimensions/alignedDimension.js +5 -4
- package/core/dimensions/angularDimension.js +6 -5
- package/core/dimensions/baseCircularDimension.js +3 -2
- package/core/dimensions/baseDimension.js +59 -93
- package/core/dimensions/baseLinearDimension.js +6 -5
- package/core/dimensions/diametricDimension.js +4 -3
- package/core/dimensions/dimension.js +21 -14
- package/core/dimensions/radialDimension.js +4 -3
- package/core/dimensions/rotatedDimension.js +12 -11
- package/core/entities/arc.js +112 -145
- package/core/entities/arctext.js +212 -174
- package/core/entities/basePolyline.js +33 -284
- package/core/entities/circle.js +77 -99
- package/core/entities/entity.js +114 -93
- package/core/entities/hatch.js +322 -249
- package/core/entities/insert.js +66 -81
- package/core/entities/line.js +21 -142
- package/core/entities/lwpolyline.js +16 -11
- package/core/entities/polyline.js +4 -4
- package/core/entities/rectangle.js +3 -1
- package/core/entities/solid.js +6 -15
- package/core/entities/text.js +188 -199
- package/core/lib/auxiliary/rubberBand.js +32 -0
- package/core/lib/auxiliary/selectionWindow.js +63 -0
- package/core/lib/auxiliary/snapPoint.js +113 -0
- package/core/lib/auxiliary/trackingLine.js +76 -0
- package/core/lib/canvas.js +228 -166
- package/core/lib/clipboard.js +7 -1
- package/core/lib/colour.js +16 -0
- package/core/lib/commandLine.js +1 -6
- package/core/lib/commandManager.js +18 -8
- package/core/lib/constants.js +28 -0
- package/core/lib/dxf/dxf.js +4 -3
- package/core/lib/entityManager.js +18 -11
- package/core/lib/inputManager.js +36 -13
- package/core/lib/intersect.js +25 -0
- package/core/lib/patterns.js +8 -1
- package/core/lib/plotOptions.js +67 -0
- package/core/lib/polylineUtils.js +75 -0
- package/core/lib/renderers/cairoRenderer.js +371 -0
- package/core/lib/renderers/canvasRenderer.js +315 -0
- package/core/lib/renderers/pdfRenderer.js +366 -0
- package/core/lib/renderers/rendererBase.js +284 -0
- package/core/lib/renderers/svgRenderer.js +343 -0
- package/core/lib/scene.js +27 -5
- package/core/lib/selectionManager.js +2 -2
- package/core/lib/settings.js +4 -2
- package/core/lib/snapping.js +4 -191
- package/core/lib/stateManager.js +30 -1
- package/core/lib/utils.js +21 -2
- package/core/properties/entityProperties.js +104 -0
- package/core/properties/property.js +106 -2
- package/core/properties/propertyManager.js +29 -13
- package/core/tables/block.js +4 -37
- package/core/tables/endblk.js +1 -1
- package/core/tables/layer.js +8 -0
- package/core/tables/styleManagerBase.js +3 -2
- package/core/tables/tableManagerBase.js +8 -3
- package/core/tools/chamfer.js +111 -137
- package/core/tools/chamferFilletBase.js +135 -34
- package/core/tools/copy.js +3 -3
- package/core/tools/explode.js +2 -2
- package/core/tools/extend.js +132 -19
- package/core/tools/fillet.js +86 -183
- package/core/tools/matchProp.js +2 -11
- package/core/tools/mirror.js +7 -6
- package/core/tools/move.js +3 -3
- package/core/tools/offset.js +3 -5
- package/core/tools/pasteclip.js +1 -1
- package/core/tools/rotate.js +6 -3
- package/core/tools/scale.js +1 -1
- package/core/tools/trim.js +265 -23
- package/core/tools/zoom.js +1 -1
- package/jest.config.js +6 -0
- package/package.json +5 -3
- package/test/dimensions/alignedDimension.test.js +14 -7
- package/test/dimensions/angularDimension.test.js +6 -6
- package/test/dimensions/baseDimension.test.js +69 -8
- package/test/dimensions/baseLinearDimension.test.js +62 -0
- package/test/dimensions/diametricDimension.test.js +6 -6
- package/test/dimensions/radialDimension.test.js +6 -6
- package/test/dimensions/rotatedDimension.test.js +14 -7
- package/test/entities/arc.test.js +80 -83
- package/test/entities/arctext.test.js +180 -53
- package/test/entities/basePolyline.test.js +129 -2
- package/test/entities/circle.test.js +57 -85
- package/test/entities/entity.test.js +42 -8
- package/test/entities/hatch.test.js +543 -70
- package/test/entities/insert.test.js +96 -1
- package/test/entities/line.test.js +15 -41
- package/test/entities/lwpolyline.test.js +104 -0
- package/test/entities/polyline.test.js +11 -0
- package/test/entities/rectangle.test.js +11 -0
- package/test/entities/solid.test.js +1 -1
- package/test/entities/text.test.js +294 -69
- package/test/integration/exportIntegration.reference.dxf +9 -9
- package/test/integration/exportIntegration.test.js +2 -2
- package/test/integration/intersectionIntegration.test.js +4 -5
- package/test/integration/pdfRendererIntegration.reference.pdf +17709 -0
- package/test/integration/pdfRendererIntegration.test.js +61 -0
- package/test/integration/svgRendererIntegration.reference.svg +3343 -0
- package/test/integration/svgRendererIntegration.test.js +61 -0
- package/test/integration/userInputIntegration.reference.dxf +1165 -105
- package/test/integration/userInputIntegration.test.js +239 -132
- package/test/lib/canvas.test.js +742 -123
- package/test/lib/constants.test.js +27 -0
- package/test/lib/entityManager.test.js +4 -4
- package/test/lib/inputManager.test.js +80 -19
- package/test/lib/intersect.test.js +83 -0
- package/test/lib/plotOptions.test.js +179 -0
- package/test/lib/rubberBand.test.js +84 -0
- package/test/lib/settings.test.js +5 -6
- package/test/lib/snapping.test.js +71 -129
- package/test/lib/stateManager.test.js +7 -7
- package/test/lib/utils.test.js +36 -2
- package/test/properties/entityProperties.test.js +132 -0
- package/test/properties/property.test.js +53 -0
- package/test/properties/propertyManager.test.js +3 -3
- package/test/renderers/canvasRenderer.test.js +317 -0
- package/test/renderers/pdfRenderer.test.js +271 -0
- package/test/renderers/rendererBase.test.js +213 -0
- package/test/renderers/svgRenderer.test.js +326 -0
- package/test/tables/block.test.js +31 -1
- package/test/tables/endblk.test.js +2 -2
- package/test/tables/layer.test.js +30 -0
- package/test/test-helpers/hatchGridRenderer.js +180 -0
- package/test/test-helpers/test-helpers.js +152 -0
- package/test/tools/chamfer.test.js +227 -5
- package/test/tools/chamferFilletBase.test.js +98 -9
- package/test/tools/copy.test.js +2 -2
- package/test/tools/explode.test.js +3 -3
- package/test/tools/extend.test.js +331 -0
- package/test/tools/fillet.test.js +296 -0
- package/test/tools/matchProp.test.js +5 -5
- package/test/tools/mirror.test.js +13 -13
- package/test/tools/offset.test.js +170 -3
- package/test/tools/pasteclip.test.js +5 -5
- package/test/tools/rotate.test.js +32 -2
- package/test/tools/scale.test.js +5 -5
- package/test/tools/trim.test.js +526 -0
- package/test/tools/zoom.test.js +7 -7
- package/core/lib/selectionWindow.js +0 -83
package/test/lib/canvas.test.js
CHANGED
|
@@ -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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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', () => {
|
|
@@ -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
|
|
226
|
+
const renderer = new MockRenderer();
|
|
217
227
|
core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
234
|
+
const renderer = new MockRenderer();
|
|
224
235
|
core.settings.canvasbackgroundcolour = { r: 246, g: 245, b: 244 };
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
242
|
+
const renderer = new MockRenderer();
|
|
231
243
|
core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
250
|
+
const renderer = new MockRenderer();
|
|
238
251
|
core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
246
|
-
|
|
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,
|
|
254
|
-
expect(
|
|
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
|
|
260
|
-
|
|
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,
|
|
271
|
-
expect(
|
|
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
|
|
277
|
-
|
|
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,
|
|
289
|
-
expect(
|
|
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
|
|
309
|
+
test('ACI 7 is recoloured in SELECTED state', () => {
|
|
293
310
|
core.settings.canvasbackgroundcolour = { r: 30, g: 30, b: 30 };
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
canvas.setContext(aci7MockEntity,
|
|
297
|
-
|
|
298
|
-
expect(
|
|
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
|
|
321
|
-
canvas.setContext(byBlockItem,
|
|
322
|
-
expect(
|
|
323
|
-
expect(
|
|
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
|
|
327
|
-
const
|
|
328
|
-
canvas.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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,
|
|
343
|
-
expect(
|
|
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('
|
|
361
|
-
const
|
|
362
|
-
canvas.
|
|
363
|
-
|
|
364
|
-
expect(
|
|
365
|
-
expect(
|
|
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('
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
canvas.setContext(redEntity,
|
|
373
|
-
|
|
374
|
-
expect(
|
|
375
|
-
|
|
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('
|
|
379
|
-
const
|
|
380
|
-
canvas.
|
|
381
|
-
|
|
382
|
-
expect(
|
|
383
|
-
expect(
|
|
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
|
|
387
|
-
const
|
|
388
|
-
|
|
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.
|
|
399
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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.addItem('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.addItem('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.addItem('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.addItem('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.addItem('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.addItem('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.addItem('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
|
+
});
|