@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.
Files changed (145) hide show
  1. package/.eslintrc.json +4 -0
  2. package/TODO.md +49 -6
  3. package/core/core/core.js +0 -3
  4. package/core/dimensions/alignedDimension.js +5 -4
  5. package/core/dimensions/angularDimension.js +6 -5
  6. package/core/dimensions/baseCircularDimension.js +3 -2
  7. package/core/dimensions/baseDimension.js +59 -93
  8. package/core/dimensions/baseLinearDimension.js +6 -5
  9. package/core/dimensions/diametricDimension.js +4 -3
  10. package/core/dimensions/dimension.js +21 -14
  11. package/core/dimensions/radialDimension.js +4 -3
  12. package/core/dimensions/rotatedDimension.js +12 -11
  13. package/core/entities/arc.js +112 -145
  14. package/core/entities/arctext.js +212 -174
  15. package/core/entities/basePolyline.js +33 -284
  16. package/core/entities/circle.js +77 -99
  17. package/core/entities/entity.js +114 -93
  18. package/core/entities/hatch.js +322 -249
  19. package/core/entities/insert.js +66 -81
  20. package/core/entities/line.js +21 -142
  21. package/core/entities/lwpolyline.js +16 -11
  22. package/core/entities/polyline.js +4 -4
  23. package/core/entities/rectangle.js +3 -1
  24. package/core/entities/solid.js +6 -15
  25. package/core/entities/text.js +188 -199
  26. package/core/lib/auxiliary/rubberBand.js +32 -0
  27. package/core/lib/auxiliary/selectionWindow.js +63 -0
  28. package/core/lib/auxiliary/snapPoint.js +113 -0
  29. package/core/lib/auxiliary/trackingLine.js +76 -0
  30. package/core/lib/canvas.js +228 -166
  31. package/core/lib/clipboard.js +7 -1
  32. package/core/lib/colour.js +16 -0
  33. package/core/lib/commandLine.js +1 -6
  34. package/core/lib/commandManager.js +18 -8
  35. package/core/lib/constants.js +28 -0
  36. package/core/lib/dxf/dxf.js +4 -3
  37. package/core/lib/entityManager.js +18 -11
  38. package/core/lib/inputManager.js +36 -13
  39. package/core/lib/intersect.js +25 -0
  40. package/core/lib/patterns.js +8 -1
  41. package/core/lib/plotOptions.js +67 -0
  42. package/core/lib/polylineUtils.js +75 -0
  43. package/core/lib/renderers/cairoRenderer.js +371 -0
  44. package/core/lib/renderers/canvasRenderer.js +315 -0
  45. package/core/lib/renderers/pdfRenderer.js +366 -0
  46. package/core/lib/renderers/rendererBase.js +284 -0
  47. package/core/lib/renderers/svgRenderer.js +343 -0
  48. package/core/lib/scene.js +27 -5
  49. package/core/lib/selectionManager.js +2 -2
  50. package/core/lib/settings.js +4 -2
  51. package/core/lib/snapping.js +4 -191
  52. package/core/lib/stateManager.js +30 -1
  53. package/core/lib/utils.js +21 -2
  54. package/core/properties/entityProperties.js +104 -0
  55. package/core/properties/property.js +106 -2
  56. package/core/properties/propertyManager.js +29 -13
  57. package/core/tables/block.js +4 -37
  58. package/core/tables/endblk.js +1 -1
  59. package/core/tables/layer.js +8 -0
  60. package/core/tables/styleManagerBase.js +3 -2
  61. package/core/tables/tableManagerBase.js +8 -3
  62. package/core/tools/chamfer.js +111 -137
  63. package/core/tools/chamferFilletBase.js +135 -34
  64. package/core/tools/copy.js +3 -3
  65. package/core/tools/explode.js +2 -2
  66. package/core/tools/extend.js +132 -19
  67. package/core/tools/fillet.js +86 -183
  68. package/core/tools/matchProp.js +2 -11
  69. package/core/tools/mirror.js +7 -6
  70. package/core/tools/move.js +3 -3
  71. package/core/tools/offset.js +3 -5
  72. package/core/tools/pasteclip.js +1 -1
  73. package/core/tools/rotate.js +6 -3
  74. package/core/tools/scale.js +1 -1
  75. package/core/tools/trim.js +265 -23
  76. package/core/tools/zoom.js +1 -1
  77. package/jest.config.js +6 -0
  78. package/package.json +5 -3
  79. package/test/dimensions/alignedDimension.test.js +14 -7
  80. package/test/dimensions/angularDimension.test.js +6 -6
  81. package/test/dimensions/baseDimension.test.js +69 -8
  82. package/test/dimensions/baseLinearDimension.test.js +62 -0
  83. package/test/dimensions/diametricDimension.test.js +6 -6
  84. package/test/dimensions/radialDimension.test.js +6 -6
  85. package/test/dimensions/rotatedDimension.test.js +14 -7
  86. package/test/entities/arc.test.js +80 -83
  87. package/test/entities/arctext.test.js +180 -53
  88. package/test/entities/basePolyline.test.js +129 -2
  89. package/test/entities/circle.test.js +57 -85
  90. package/test/entities/entity.test.js +42 -8
  91. package/test/entities/hatch.test.js +543 -70
  92. package/test/entities/insert.test.js +96 -1
  93. package/test/entities/line.test.js +15 -41
  94. package/test/entities/lwpolyline.test.js +104 -0
  95. package/test/entities/polyline.test.js +11 -0
  96. package/test/entities/rectangle.test.js +11 -0
  97. package/test/entities/solid.test.js +1 -1
  98. package/test/entities/text.test.js +294 -69
  99. package/test/integration/exportIntegration.reference.dxf +9 -9
  100. package/test/integration/exportIntegration.test.js +2 -2
  101. package/test/integration/intersectionIntegration.test.js +4 -5
  102. package/test/integration/pdfRendererIntegration.reference.pdf +17709 -0
  103. package/test/integration/pdfRendererIntegration.test.js +61 -0
  104. package/test/integration/svgRendererIntegration.reference.svg +3343 -0
  105. package/test/integration/svgRendererIntegration.test.js +61 -0
  106. package/test/integration/userInputIntegration.reference.dxf +1165 -105
  107. package/test/integration/userInputIntegration.test.js +239 -132
  108. package/test/lib/canvas.test.js +742 -123
  109. package/test/lib/constants.test.js +27 -0
  110. package/test/lib/entityManager.test.js +4 -4
  111. package/test/lib/inputManager.test.js +80 -19
  112. package/test/lib/intersect.test.js +83 -0
  113. package/test/lib/plotOptions.test.js +179 -0
  114. package/test/lib/rubberBand.test.js +84 -0
  115. package/test/lib/settings.test.js +5 -6
  116. package/test/lib/snapping.test.js +71 -129
  117. package/test/lib/stateManager.test.js +7 -7
  118. package/test/lib/utils.test.js +36 -2
  119. package/test/properties/entityProperties.test.js +132 -0
  120. package/test/properties/property.test.js +53 -0
  121. package/test/properties/propertyManager.test.js +3 -3
  122. package/test/renderers/canvasRenderer.test.js +317 -0
  123. package/test/renderers/pdfRenderer.test.js +271 -0
  124. package/test/renderers/rendererBase.test.js +213 -0
  125. package/test/renderers/svgRenderer.test.js +326 -0
  126. package/test/tables/block.test.js +31 -1
  127. package/test/tables/endblk.test.js +2 -2
  128. package/test/tables/layer.test.js +30 -0
  129. package/test/test-helpers/hatchGridRenderer.js +180 -0
  130. package/test/test-helpers/test-helpers.js +152 -0
  131. package/test/tools/chamfer.test.js +227 -5
  132. package/test/tools/chamferFilletBase.test.js +98 -9
  133. package/test/tools/copy.test.js +2 -2
  134. package/test/tools/explode.test.js +3 -3
  135. package/test/tools/extend.test.js +331 -0
  136. package/test/tools/fillet.test.js +296 -0
  137. package/test/tools/matchProp.test.js +5 -5
  138. package/test/tools/mirror.test.js +13 -13
  139. package/test/tools/offset.test.js +170 -3
  140. package/test/tools/pasteclip.test.js +5 -5
  141. package/test/tools/rotate.test.js +32 -2
  142. package/test/tools/scale.test.js +5 -5
  143. package/test/tools/trim.test.js +526 -0
  144. package/test/tools/zoom.test.js +7 -7
  145. package/core/lib/selectionWindow.js +0 -83
@@ -3,7 +3,6 @@ import { Point } from '../../core/entities/point.js';
3
3
  import { DesignCore } from '../../core/designCore.js';
4
4
 
5
5
  import { BasePolyline } from '../../core/entities/basePolyline.js';
6
- import { BoundingBox } from '../../core/lib/boundingBox.js';
7
6
  import { Circle } from '../../core/entities/circle.js';
8
7
  import { Line } from '../../core/entities/line.js';
9
8
  import { Polyline } from '../../core/entities/polyline.js';
@@ -21,7 +20,7 @@ const points = [new Point(100, 100, 1), new Point(200, 100, 1)];
21
20
  const boundaryShape = new BasePolyline({ points: points });
22
21
 
23
22
  const hatch = new Hatch({ handle: '1' });
24
- hatch.childEntities = [boundaryShape];
23
+ hatch.setProperty('childEntities', [boundaryShape]);
25
24
 
26
25
  const hatchInputScenarios = [
27
26
  {
@@ -67,9 +66,9 @@ test.each(hatchInputScenarios)('Hatch.execute handles $desc', async (scenario) =
67
66
 
68
67
  await hatch.execute();
69
68
 
70
- expect(hatch.childEntities.length).toBe(1);
69
+ expect(hatch.getProperty('childEntities').length).toBe(1);
71
70
 
72
- expect(hatch.childEntities[0].points.length).toBe(pointCount); // closed polyline adds extra point
71
+ expect(hatch.getProperty('childEntities')[0].points.length).toBe(pointCount); // closed polyline adds extra point
73
72
 
74
73
  expect(hatch.points.length).toBe(2);
75
74
  // hatch first point should be at 0,0
@@ -78,10 +77,9 @@ test.each(hatchInputScenarios)('Hatch.execute handles $desc', async (scenario) =
78
77
  // hatch last point should be at 1,1
79
78
  expect(hatch.points.at(-1).x).toBe(1);
80
79
  expect(hatch.points.at(-1).y).toBe(1);
81
- expect(hatch.pattern).toBe(expectedPattern);
82
- expect(hatch.scale).toBe(expectedScale);
83
- expect(hatch.angle).toBe(expectedAngle);
84
- expect(hatch.solid).toBe(expectedPattern === 'SOLID');
80
+ expect(hatch.getProperty('patternName')).toBe(expectedPattern);
81
+ expect(hatch.getProperty('scale')).toBe(expectedScale);
82
+ expect(hatch.getProperty('angle')).toBe(expectedAngle);
85
83
  });
86
84
  });
87
85
 
@@ -117,10 +115,9 @@ test('Test Hatch', () => {
117
115
 
118
116
  const newHatch = new Hatch(data);
119
117
 
120
- expect(newHatch.patternName).toBe('HONEY');
121
- expect(newHatch.angle).toBe(45);
122
- expect(newHatch.scale).toBe(2);
123
- expect(newHatch.solid).toBe(false);
118
+ expect(newHatch.getProperty('patternName')).toBe('HONEY');
119
+ expect(newHatch.getProperty('angle')).toBe(45);
120
+ expect(newHatch.getProperty('scale')).toBe(2);
124
121
  });
125
122
 
126
123
  test('Test Hatch.getDataValue', () => {
@@ -134,19 +131,20 @@ test('Test Hatch.getDataValue', () => {
134
131
  expect(hatch.getDataValue(data, 72)).toBe(0);
135
132
  });
136
133
 
137
- test('Test Hatch.getPatternName', () => {
138
- expect(hatch.getPatternName()).toBe('ANSI31');
139
- });
134
+ test('Test Hatch patternName', () => {
135
+ expect(hatch.getProperty('patternName')).toBe('ANSI31');
136
+
137
+ hatch.setProperty('patternName', 'Solid');
138
+ expect(hatch.getProperty('patternName')).toBe('SOLID');
140
139
 
141
- test('Test Hatch.setPatternName', () => {
142
- expect(hatch.solid).toBe(false);
143
- hatch.setPatternName('Solid');
144
- expect(hatch.pattern).toBe('SOLID');
145
- expect(hatch.solid).toBe(true);
140
+ hatch.setProperty('patternName', 'Ansi31');
141
+ expect(hatch.getProperty('patternName')).toBe('ANSI31');
142
+ });
146
143
 
147
- hatch.setPatternName('Ansi31');
148
- expect(hatch.pattern).toBe('ANSI31');
149
- expect(hatch.solid).toBe(false);
144
+ test('Test Hatch dxfCode 70 overrides patternName to SOLID', () => {
145
+ const data = { '2': 'ANSI31', '70': 1 };
146
+ const h = new Hatch(data);
147
+ expect(h.getProperty('patternName')).toBe('SOLID');
150
148
  });
151
149
 
152
150
  test('Test Hatch.closestPoint', () => {
@@ -298,8 +296,8 @@ ANSI31
298
296
 
299
297
  // Export rotated and scaled
300
298
  const rotatedScaleHatch = new Hatch({ handle: '1' });
301
- rotatedScaleHatch.angle = 45;
302
- rotatedScaleHatch.scale = 2;
299
+ rotatedScaleHatch.setProperty('angle', 45);
300
+ rotatedScaleHatch.setProperty('scale', 2);
303
301
 
304
302
  file = new File();
305
303
  rotatedScaleHatch.dxf(file);
@@ -312,7 +310,7 @@ ANSI31
312
310
  const boundary = rotatedScaleHatch.processSelection([shape]);
313
311
  expect(boundary[0]).toBeInstanceOf(Polyline);
314
312
 
315
- rotatedScaleHatch.childEntities = boundary;
313
+ rotatedScaleHatch.setProperty('childEntities', boundary);
316
314
  rotatedScaleHatch.dxf(file);
317
315
  // console.log(file.contents);
318
316
 
@@ -510,97 +508,572 @@ test('Test Hatch.processSelection', () => {
510
508
 
511
509
  // Minimal mock canvas context that records moveTo calls and supports try/catch split
512
510
  /**
513
- * Create a minimal mock canvas context
514
- * @return {Object} mock context
511
+ * Create a minimal mock renderer
512
+ * @return {Object} mock renderer
515
513
  */
516
514
  function makeMockCtx() {
517
- let moveCount = 0;
515
+ let traceCount = 0;
518
516
  let fillCalled = false;
519
517
  return {
520
- save: () => {},
521
- restore: () => {},
522
- translate: () => {},
523
- scale: () => {},
524
- rotate: () => {},
518
+ setDash: () => {},
525
519
  beginPath: () => {},
526
- setLineDash: () => {},
527
- lineWidth: 0,
528
- moveTo: () => {
529
- moveCount++;
530
- },
531
- lineTo: () => {},
520
+ closePath: () => {},
532
521
  stroke: () => {},
533
522
  fill: () => {
534
523
  fillCalled = true;
535
524
  },
536
- getMoveCalls: () => moveCount,
525
+ applyPath: (options) => {
526
+ if (options?.fill) fillCalled = true;
527
+ },
528
+ tracePath: () => {
529
+ traceCount++;
530
+ },
531
+ drawShape: () => {},
532
+ drawSegments: (segments) => {
533
+ traceCount += segments.length;
534
+ },
535
+ getMoveCalls: () => traceCount,
537
536
  getFillCalled: () => fillCalled,
538
537
  };
539
538
  }
540
539
 
541
- test('Test Hatch.createPattern solid fill calls ctx.fill', () => {
540
+ test('Test Hatch.draw solid fill renders filled boundary', () => {
542
541
  const solidHatch = new Hatch({ patternName: 'SOLID' });
543
- solidHatch.childEntities = [new BasePolyline({ points: [new Point(0, 0), new Point(10, 0)] })];
542
+ solidHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)] })]);
544
543
 
545
544
  const ctx = makeMockCtx();
546
- solidHatch.createPattern(ctx, 1, solidHatch.boundingBox());
545
+ solidHatch.draw(ctx);
547
546
 
548
547
  expect(ctx.getFillCalled()).toBe(true);
549
- expect(ctx.getMoveCalls()).toBe(0);
550
548
  });
551
549
 
552
- test('Test Hatch.createPattern unknown pattern calls ctx.fill', () => {
550
+ test('Test Hatch.draw unknown pattern does not stroke pattern lines', () => {
553
551
  const unknownHatch = new Hatch({ patternName: 'ANSI31' });
554
- unknownHatch.childEntities = [new BasePolyline({ points: [new Point(0, 0), new Point(10, 0)] })];
555
- // Override pattern to a nonexistent name without going through setPatternName
556
- unknownHatch.pattern = 'NOTAPATTERN';
552
+ unknownHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)] })]);
553
+ // Override pattern to a nonexistent name
554
+ unknownHatch.setProperty('patternName', 'NOTAPATTERN');
557
555
 
558
556
  const ctx = makeMockCtx();
559
- unknownHatch.createPattern(ctx, 1, unknownHatch.boundingBox());
557
+ unknownHatch.draw(ctx);
560
558
 
561
- expect(ctx.getFillCalled()).toBe(true);
562
- expect(ctx.getMoveCalls()).toBe(0);
559
+ // No pattern lines drawn
560
+ expect(ctx.getFillCalled()).toBe(false);
563
561
  });
564
562
 
565
- test('Test Hatch.createPattern tight bound line count - square boundary', () => {
563
+ test('Test Hatch.draw tight bound line count - square boundary', () => {
566
564
  // ANSI31: angle=45, yDelta=3.175, no dashes (dashLength = bbXLength/2)
567
565
  // 100x100 square: halfW=halfH=50, sin45=cos45=√2/2
568
566
  // halfY = 50*(√2/2) + 50*(√2/2) ≈ 70.71
569
- // yIncrement = ceil(70.71 / 3.175) = 23 → loop i=-23..22 = 46 lines
570
- const squareBB = new BoundingBox(new Point(0, 100), new Point(100, 0));
567
+ // yIncrement = ceil(70.71 / 3.175) = 23 → loop i=-23..22 = 46 raw lines
568
+ // i=-23 (|ly|=73.025 > halfY=70.71) misses the square → 45 clipped segments
571
569
  const squareHatch = new Hatch({ patternName: 'ANSI31' });
570
+ squareHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)] })]);
572
571
  const ctx = makeMockCtx();
573
- squareHatch.createPattern(ctx, 1, squareBB);
574
- expect(ctx.getMoveCalls()).toBe(46);
572
+ squareHatch.draw(ctx);
573
+ expect(ctx.getMoveCalls()).toBe(45);
575
574
  });
576
575
 
577
- test('Test Hatch.createPattern tight bound line count - flat boundary', () => {
576
+ test('Test Hatch.draw tight bound line count - flat boundary', () => {
578
577
  // ANSI31: angle=45, yDelta=3.175, no dashes
579
578
  // 100x10 flat box: halfW=50, halfH=5, sin45=cos45=√2/2
580
579
  // halfY = (50+5)*(√2/2) ≈ 38.89
581
- // yIncrement = ceil(38.89 / 3.175) = 13 → loop i=-13..12 = 26 lines
582
- const flatBB = new BoundingBox(new Point(0, 10), new Point(100, 0));
580
+ // yIncrement = ceil(38.89 / 3.175) = 13 → loop i=-13..12 = 26 raw lines
581
+ // i=-13 (|ly|=41.275 > halfY=38.89) misses the flat box → 25 clipped segments
583
582
  const flatHatch = new Hatch({ patternName: 'ANSI31' });
583
+ flatHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 10), new Point(0, 10)] })]);
584
584
  const ctx = makeMockCtx();
585
- flatHatch.createPattern(ctx, 1, flatBB);
586
- expect(ctx.getMoveCalls()).toBe(26);
585
+ flatHatch.draw(ctx);
586
+ expect(ctx.getMoveCalls()).toBe(25);
587
587
  });
588
588
 
589
- test('Test Hatch.createPattern tight bound produces fewer lines for flat boundary', () => {
589
+ test('Test Hatch.draw tight bound produces fewer lines for flat boundary', () => {
590
590
  // A flat boundary (100x10) should produce fewer lines than a square (100x100).
591
- // This verifies that the tight projection avoids over-generating lines for
592
- // non-square shapes, unlike the old Math.max(bbX, bbY) approach.
593
- const squareBB = new BoundingBox(new Point(0, 100), new Point(100, 0));
594
- const flatBB = new BoundingBox(new Point(0, 10), new Point(100, 0));
595
-
596
591
  const squareHatch = new Hatch({ patternName: 'ANSI31' });
592
+ squareHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)] })]);
593
+
597
594
  const flatHatch = new Hatch({ patternName: 'ANSI31' });
595
+ flatHatch.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 10), new Point(0, 10)] })]);
598
596
 
599
597
  const squareCtx = makeMockCtx();
600
- squareHatch.createPattern(squareCtx, 1, squareBB);
598
+ squareHatch.draw(squareCtx);
601
599
 
602
600
  const flatCtx = makeMockCtx();
603
- flatHatch.createPattern(flatCtx, 1, flatBB);
601
+ flatHatch.draw(flatCtx);
604
602
 
605
603
  expect(flatCtx.getMoveCalls()).toBeLessThan(squareCtx.getMoveCalls());
606
604
  });
605
+
606
+ // ── buildPatternCache — early return tests ───────────────────────────────────
607
+
608
+ test('Hatch.buildPatternCache no childEntities sets cachedPattern to empty array', () => {
609
+ const h = new Hatch({ patternName: 'HONEY' });
610
+ // childEntities is empty by default
611
+ h.buildPatternCache();
612
+ expect(h.cachedPattern).toEqual([]);
613
+ });
614
+
615
+ test('Hatch.buildPatternCache solid fill sets cachedPattern to empty array', () => {
616
+ const h = new Hatch({ patternName: 'SOLID' });
617
+ h.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)] })]);
618
+ h.buildPatternCache();
619
+ expect(h.cachedPattern).toEqual([]);
620
+ });
621
+
622
+ test('Hatch.buildPatternCache unknown pattern sets cachedPattern to empty array', () => {
623
+ const h = new Hatch({ patternName: 'HONEY' });
624
+ h.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)] })]);
625
+ h.setProperty('patternName', 'NOTAPATTERN');
626
+ h.buildPatternCache();
627
+ expect(h.cachedPattern).toEqual([]);
628
+ });
629
+
630
+ // ── buildPatternCache — pattern structure and scale tests ────────────────────
631
+
632
+ /**
633
+ * Build a 100×100 square boundary
634
+ * @return {Array} array containing one BasePolyline boundary
635
+ */
636
+ const makeSquare = () => [new BasePolyline({ points: [new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)] })];
637
+
638
+ /**
639
+ * Sum all segment counts across all families in a cached hatch
640
+ * @param {Hatch} h
641
+ * @return {number} total segment count
642
+ */
643
+ const totalSegments = (h) => h.cachedPattern.reduce((s, f) => s + f.segments.length, 0);
644
+
645
+ test('Hatch.buildPatternCache HONEY has 3 families each with dashes and segments', () => {
646
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
647
+ h.setProperty('childEntities', makeSquare());
648
+ h.buildPatternCache();
649
+
650
+ expect(h.cachedPattern.length).toBe(3);
651
+ for (const family of h.cachedPattern) {
652
+ expect(family.dashes.length).toBeGreaterThan(0);
653
+ expect(family.segments.length).toBeGreaterThan(0);
654
+ }
655
+ });
656
+
657
+ test('Hatch.buildPatternCache HONEY scale 2 dashes are 2× scale 1 dashes', () => {
658
+ const h1 = new Hatch({ patternName: 'HONEY', scale: 1 });
659
+ h1.setProperty('childEntities', makeSquare());
660
+ h1.buildPatternCache();
661
+
662
+ const h2 = new Hatch({ patternName: 'HONEY', scale: 2 });
663
+ h2.setProperty('childEntities', makeSquare());
664
+ h2.buildPatternCache();
665
+
666
+ for (let i = 0; i < h1.cachedPattern.length; i++) {
667
+ const d1 = h1.cachedPattern[i].dashes;
668
+ const d2 = h2.cachedPattern[i].dashes;
669
+ expect(d2.length).toBe(d1.length);
670
+ for (let j = 0; j < d1.length; j++) {
671
+ expect(d2[j]).toBeCloseTo(d1[j] * 2, 5);
672
+ }
673
+ }
674
+ });
675
+
676
+ test('Hatch.buildPatternCache HONEY scale 2 produces fewer segments than scale 1', () => {
677
+ const h1 = new Hatch({ patternName: 'HONEY', scale: 1 });
678
+ h1.setProperty('childEntities', makeSquare());
679
+ h1.buildPatternCache();
680
+
681
+ const h2 = new Hatch({ patternName: 'HONEY', scale: 2 });
682
+ h2.setProperty('childEntities', makeSquare());
683
+ h2.buildPatternCache();
684
+
685
+ expect(totalSegments(h2)).toBeLessThan(totalSegments(h1));
686
+ });
687
+
688
+ test('Hatch.buildPatternCache ANSI31 scale 2 produces fewer segments than scale 1', () => {
689
+ const h1 = new Hatch({ patternName: 'ANSI31', scale: 1 });
690
+ h1.setProperty('childEntities', makeSquare());
691
+ h1.buildPatternCache();
692
+
693
+ const h2 = new Hatch({ patternName: 'ANSI31', scale: 2 });
694
+ h2.setProperty('childEntities', makeSquare());
695
+ h2.buildPatternCache();
696
+
697
+ expect(h2.cachedPattern[0].segments.length).toBeLessThan(h1.cachedPattern[0].segments.length);
698
+ });
699
+
700
+ test('Hatch.buildPatternCache HONEY all dashPhase values are non-negative', () => {
701
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
702
+ h.setProperty('childEntities', makeSquare());
703
+ h.buildPatternCache();
704
+
705
+ for (const family of h.cachedPattern) {
706
+ for (const seg of family.segments) {
707
+ expect(seg.dashPhase).toBeGreaterThanOrEqual(0);
708
+ }
709
+ }
710
+ });
711
+
712
+ test('Hatch.buildPatternCache scale change invalidates and rebuilds cache with updated dashes', () => {
713
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
714
+ h.setProperty('childEntities', makeSquare());
715
+ h.buildPatternCache();
716
+
717
+ const dashes1 = [...h.cachedPattern[0].dashes];
718
+
719
+ h.setProperty('scale', 2);
720
+ expect(h.cachedPattern).toBeNull();
721
+
722
+ h.buildPatternCache();
723
+ const dashes2 = [...h.cachedPattern[0].dashes];
724
+
725
+ for (let i = 0; i < dashes1.length; i++) {
726
+ expect(dashes2[i]).toBeCloseTo(dashes1[i] * 2, 5);
727
+ }
728
+ });
729
+
730
+ // ── buildPatternCache — nested boundary tests ────────────────────────────────
731
+
732
+ /**
733
+ * A 20×20 inner square hole centred in the 100×100 outer square
734
+ * @return {BasePolyline} inner boundary polyline
735
+ */
736
+ const makeInnerSquare = () => new BasePolyline({ points: [new Point(40, 40), new Point(60, 40), new Point(60, 60), new Point(40, 60)] });
737
+
738
+ test('Hatch.buildPatternCache ANSI31 nested boundary: no segment midpoint inside inner hole', () => {
739
+ // Even-odd rule: a ray from inside the hole crosses 2 boundaries → even → not hatched
740
+ const h = new Hatch({ patternName: 'ANSI31', scale: 1 });
741
+ h.setProperty('childEntities', [makeSquare()[0], makeInnerSquare()]);
742
+ h.buildPatternCache();
743
+
744
+ for (const family of h.cachedPattern) {
745
+ for (const seg of family.segments) {
746
+ const mx = (seg.x1 + seg.x2) / 2;
747
+ const my = (seg.y1 + seg.y2) / 2;
748
+ expect(mx > 40 && mx < 60 && my > 40 && my < 60).toBe(false);
749
+ }
750
+ }
751
+ });
752
+
753
+ test('Hatch.buildPatternCache ANSI31 nested boundary produces more segments than outer-only', () => {
754
+ // Lines that cross the hole are split into two sub-segments, increasing total count
755
+ const outerOnly = new Hatch({ patternName: 'ANSI31', scale: 1 });
756
+ outerOnly.setProperty('childEntities', makeSquare());
757
+ outerOnly.buildPatternCache();
758
+
759
+ const withHole = new Hatch({ patternName: 'ANSI31', scale: 1 });
760
+ withHole.setProperty('childEntities', [makeSquare()[0], makeInnerSquare()]);
761
+ withHole.buildPatternCache();
762
+
763
+ expect(withHole.cachedPattern[0].segments.length).toBeGreaterThan(outerOnly.cachedPattern[0].segments.length);
764
+ });
765
+
766
+ test('Hatch.buildPatternCache HONEY nested boundary: no segment midpoint inside inner hole', () => {
767
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
768
+ h.setProperty('childEntities', [makeSquare()[0], makeInnerSquare()]);
769
+ h.buildPatternCache();
770
+
771
+ expect(h.cachedPattern.length).toBe(3);
772
+ for (const family of h.cachedPattern) {
773
+ for (const seg of family.segments) {
774
+ const mx = (seg.x1 + seg.x2) / 2;
775
+ const my = (seg.y1 + seg.y2) / 2;
776
+ expect(mx > 40 && mx < 60 && my > 40 && my < 60).toBe(false);
777
+ }
778
+ }
779
+ });
780
+
781
+ test('Hatch.buildPatternCache HONEY nested boundary produces more segments than outer-only', () => {
782
+ const outerOnly = new Hatch({ patternName: 'HONEY', scale: 1 });
783
+ outerOnly.setProperty('childEntities', makeSquare());
784
+ outerOnly.buildPatternCache();
785
+
786
+ const withHole = new Hatch({ patternName: 'HONEY', scale: 1 });
787
+ withHole.setProperty('childEntities', [makeSquare()[0], makeInnerSquare()]);
788
+ withHole.buildPatternCache();
789
+
790
+ expect(totalSegments(withHole)).toBeGreaterThan(totalSegments(outerOnly));
791
+ });
792
+
793
+ // ── buildPatternCache — circle boundary tests ────────────────────────────────
794
+
795
+ /**
796
+ * Build a circular boundary by converting a Circle entity to a polyline
797
+ * @param {number} cx - centre x
798
+ * @param {number} cy - centre y
799
+ * @param {number} r - radius
800
+ * @return {Polyline} boundary polyline
801
+ */
802
+ const makeCircleBoundary = (cx, cy, r) => {
803
+ const h = new Hatch();
804
+ return h.processSelection([new Circle({ points: [new Point(cx, cy), new Point(cx + r, cy)] })])[0];
805
+ };
806
+
807
+ test('Hatch.buildPatternCache HONEY circle boundary has 3 families with dashes and segments', () => {
808
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
809
+ h.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
810
+ h.buildPatternCache();
811
+
812
+ expect(h.cachedPattern.length).toBe(3);
813
+ for (const family of h.cachedPattern) {
814
+ expect(family.dashes.length).toBeGreaterThan(0);
815
+ expect(family.segments.length).toBeGreaterThan(0);
816
+ }
817
+ });
818
+
819
+ test('Hatch.buildPatternCache HONEY circle scale 2 dashes are 2× scale 1 dashes', () => {
820
+ const h1 = new Hatch({ patternName: 'HONEY', scale: 1 });
821
+ h1.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
822
+ h1.buildPatternCache();
823
+
824
+ const h2 = new Hatch({ patternName: 'HONEY', scale: 2 });
825
+ h2.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
826
+ h2.buildPatternCache();
827
+
828
+ for (let i = 0; i < h1.cachedPattern.length; i++) {
829
+ const d1 = h1.cachedPattern[i].dashes;
830
+ const d2 = h2.cachedPattern[i].dashes;
831
+ expect(d2.length).toBe(d1.length);
832
+ for (let j = 0; j < d1.length; j++) {
833
+ expect(d2[j]).toBeCloseTo(d1[j] * 2, 5);
834
+ }
835
+ }
836
+ });
837
+
838
+ test('Hatch.buildPatternCache HONEY circle scale 2 produces fewer segments than scale 1', () => {
839
+ const h1 = new Hatch({ patternName: 'HONEY', scale: 1 });
840
+ h1.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
841
+ h1.buildPatternCache();
842
+
843
+ const h2 = new Hatch({ patternName: 'HONEY', scale: 2 });
844
+ h2.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
845
+ h2.buildPatternCache();
846
+
847
+ expect(totalSegments(h2)).toBeLessThan(totalSegments(h1));
848
+ });
849
+
850
+ test('Hatch.buildPatternCache HONEY circle all dashPhase values are non-negative', () => {
851
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
852
+ h.setProperty('childEntities', [makeCircleBoundary(50, 50, 40)]);
853
+ h.buildPatternCache();
854
+
855
+ for (const family of h.cachedPattern) {
856
+ for (const seg of family.segments) {
857
+ expect(seg.dashPhase).toBeGreaterThanOrEqual(0);
858
+ }
859
+ }
860
+ });
861
+
862
+ test('Hatch.buildPatternCache ANSI31 outer square + inner circle hole: no segment midpoint inside circle', () => {
863
+ const h = new Hatch({ patternName: 'ANSI31', scale: 1 });
864
+ h.setProperty('childEntities', [makeSquare()[0], makeCircleBoundary(50, 50, 15)]);
865
+ h.buildPatternCache();
866
+
867
+ for (const family of h.cachedPattern) {
868
+ for (const seg of family.segments) {
869
+ const mx = (seg.x1 + seg.x2) / 2;
870
+ const my = (seg.y1 + seg.y2) / 2;
871
+ expect((mx - 50) ** 2 + (my - 50) ** 2).toBeGreaterThanOrEqual(15 * 15);
872
+ }
873
+ }
874
+ });
875
+
876
+ test('Hatch.buildPatternCache HONEY outer square + inner circle hole: no segment midpoint inside circle', () => {
877
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
878
+ h.setProperty('childEntities', [makeSquare()[0], makeCircleBoundary(50, 50, 15)]);
879
+ h.buildPatternCache();
880
+
881
+ for (const family of h.cachedPattern) {
882
+ for (const seg of family.segments) {
883
+ const mx = (seg.x1 + seg.x2) / 2;
884
+ const my = (seg.y1 + seg.y2) / 2;
885
+ expect((mx - 50) ** 2 + (my - 50) ** 2).toBeGreaterThanOrEqual(15 * 15);
886
+ }
887
+ }
888
+ });
889
+
890
+ test('Hatch.buildPatternCache ANSI31 outer circle + inner circle hole: no segment midpoint inside inner circle', () => {
891
+ const h = new Hatch({ patternName: 'ANSI31', scale: 1 });
892
+ h.setProperty('childEntities', [makeCircleBoundary(50, 50, 40), makeCircleBoundary(50, 50, 15)]);
893
+ h.buildPatternCache();
894
+
895
+ expect(h.cachedPattern[0].segments.length).toBeGreaterThan(0);
896
+ for (const family of h.cachedPattern) {
897
+ for (const seg of family.segments) {
898
+ const mx = (seg.x1 + seg.x2) / 2;
899
+ const my = (seg.y1 + seg.y2) / 2;
900
+ expect((mx - 50) ** 2 + (my - 50) ** 2).toBeGreaterThanOrEqual(15 * 15);
901
+ }
902
+ }
903
+ });
904
+
905
+ test('Hatch.buildPatternCache HONEY outer circle + inner circle hole: no segment midpoint inside inner circle', () => {
906
+ const h = new Hatch({ patternName: 'HONEY', scale: 1 });
907
+ h.setProperty('childEntities', [makeCircleBoundary(50, 50, 40), makeCircleBoundary(50, 50, 15)]);
908
+ h.buildPatternCache();
909
+
910
+ expect(h.cachedPattern.length).toBe(3);
911
+ for (const family of h.cachedPattern) {
912
+ for (const seg of family.segments) {
913
+ const mx = (seg.x1 + seg.x2) / 2;
914
+ const my = (seg.y1 + seg.y2) / 2;
915
+ expect((mx - 50) ** 2 + (my - 50) ** 2).toBeGreaterThanOrEqual(15 * 15);
916
+ }
917
+ }
918
+ });
919
+
920
+ // ── setProperty ──────────────────────────────────────────────────────────────
921
+
922
+ test('Hatch.setProperty unknown property does nothing', () => {
923
+ const h = new Hatch({ patternName: 'ANSI31', angle: 10 });
924
+ h.setProperty('nonExistentProperty', 99);
925
+ expect(h.hasOwnProperty('nonExistentProperty')).toBe(false);
926
+ expect(h.getProperty('angle')).toBe(10);
927
+ });
928
+
929
+ test('Hatch.setProperty known property updates value and invalidates cache', () => {
930
+ const h = new Hatch({ patternName: 'ANSI31', angle: 0 });
931
+ h.setProperty('childEntities', makeSquare());
932
+ h.buildPatternCache();
933
+ expect(h.cachedPattern).not.toBeNull();
934
+
935
+ h.setProperty('angle', 45);
936
+
937
+ expect(h.getProperty('angle')).toBe(45);
938
+ expect(h.cachedPattern).toBeNull();
939
+ });
940
+
941
+ test('Hatch.setProperty patternName updates pattern and solid flag via setter', () => {
942
+ const h = new Hatch({ patternName: 'ANSI31' });
943
+ expect(h.getProperty('patternName')).toBe('ANSI31');
944
+
945
+ h.setProperty('patternName', 'SOLID');
946
+
947
+ expect(h.getProperty('patternName')).toBe('SOLID');
948
+ expect(h.cachedPattern).toBeNull();
949
+ });
950
+
951
+ test('Hatch.setProperty points translation shifts child entity positions', () => {
952
+ const h = new Hatch({ patternName: 'ANSI31' });
953
+ // h.points = [Point(0,0), Point(1,1)] — 45° angle set by constructor
954
+ h.setProperty('childEntities', [new BasePolyline({ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)] })]);
955
+
956
+ // New points: same 45° angle (delta angle = 0 → no rotation), origin shifted by (5, 0)
957
+ h.setProperty('points', [new Point(5, 0), new Point(6, 1)]);
958
+
959
+ expect(h.getProperty('childEntities')[0].points[0].x).toBeCloseTo(5);
960
+ expect(h.getProperty('childEntities')[0].points[0].y).toBeCloseTo(0);
961
+ expect(h.getProperty('childEntities')[0].points[1].x).toBeCloseTo(15);
962
+ expect(h.getProperty('childEntities')[0].points[1].y).toBeCloseTo(0);
963
+ expect(h.cachedPattern).toBeNull();
964
+ });
965
+
966
+ test('Hatch.setProperty points rotation updates hatch angle', () => {
967
+ const h = new Hatch({ patternName: 'ANSI31', angle: 0 });
968
+ h.setProperty('childEntities', makeSquare());
969
+ // h.points = [Point(0,0), Point(1,1)] — 45° angle
970
+ // New points: [Point(0,0), Point(1,0)] — 0° angle → theta = -45°
971
+ h.setProperty('points', [new Point(0, 0), new Point(1, 0)]);
972
+
973
+ // hatch.angle should decrease by 45°
974
+ expect(h.getProperty('angle')).toBeCloseTo(-45, 1);
975
+ expect(h.cachedPattern).toBeNull();
976
+ });
977
+
978
+ // ── touched ──────────────────────────────────────────────────────────────────
979
+
980
+ test('Hatch.touched with no childEntities returns false', () => {
981
+ const h = new Hatch();
982
+ expect(h.touched({ min: new Point(-999, -999), max: new Point(999, 999) })).toBe(false);
983
+ });
984
+
985
+ test('Hatch.touched returns true when selection overlaps boundary', () => {
986
+ const h = new Hatch({ patternName: 'ANSI31' });
987
+ h.setProperty('childEntities', makeSquare()); // 0,0 → 100,100
988
+ expect(h.touched({ min: new Point(-10, -10), max: new Point(50, 50) })).toBe(true);
989
+ });
990
+
991
+ test('Hatch.touched returns false when selection does not overlap boundary', () => {
992
+ const h = new Hatch({ patternName: 'ANSI31' });
993
+ h.setProperty('childEntities', makeSquare()); // 0,0 → 100,100
994
+ expect(h.touched({ min: new Point(200, 200), max: new Point(300, 300) })).toBe(false);
995
+ });
996
+
997
+ // ── draw: scale guard and stroke/dash tracking ───────────────────────────────
998
+
999
+ test('Hatch.draw scale guard resets sub-zero scale to 1 and rebuilds cache', () => {
1000
+ const h = new Hatch({ patternName: 'ANSI31' });
1001
+ h.setProperty('childEntities', makeSquare());
1002
+ h.setProperty('scale', 0.001); // below guard threshold
1003
+
1004
+ const ctx = makeMockCtx();
1005
+ h.draw(ctx);
1006
+
1007
+ expect(h.getProperty('scale')).toBe(1);
1008
+ expect(h.cachedPattern).not.toBeNull();
1009
+ });
1010
+
1011
+ /**
1012
+ * Create a mock renderer that tracks stroke call count and setDash offset arguments
1013
+ * @return {Object} tracking mock renderer
1014
+ */
1015
+ function makeTrackingCtx() {
1016
+ let strokeCount = 0;
1017
+ const lineDashOffsets = [];
1018
+ return {
1019
+ setDash: () => {},
1020
+ beginPath: () => {},
1021
+ closePath: () => {},
1022
+ tracePath: () => {},
1023
+ stroke: () => {
1024
+ strokeCount++;
1025
+ },
1026
+ fill: () => {},
1027
+ drawShape: () => {},
1028
+ drawSegments: (segments, dashes) => {
1029
+ if (!dashes.length) {
1030
+ strokeCount++;
1031
+ } else {
1032
+ for (const seg of segments) {
1033
+ lineDashOffsets.push(seg.dashPhase ?? 0);
1034
+ strokeCount++;
1035
+ }
1036
+ }
1037
+ },
1038
+ getStrokeCalls: () => strokeCount,
1039
+ getLineDashOffsets: () => lineDashOffsets,
1040
+ };
1041
+ }
1042
+
1043
+ test('Hatch.draw ANSI31 solid-line family batches all segments into one stroke call', () => {
1044
+ const h = new Hatch({ patternName: 'ANSI31' });
1045
+ h.setProperty('childEntities', makeSquare());
1046
+
1047
+ const ctx = makeTrackingCtx();
1048
+ h.draw(ctx);
1049
+
1050
+ // ANSI31 has no dashes → solid-line branch → one beginPath + all tracePath + one stroke()
1051
+ expect(ctx.getStrokeCalls()).toBe(1);
1052
+ expect(ctx.getLineDashOffsets().length).toBe(0);
1053
+ });
1054
+
1055
+ test('Hatch.draw HONEY dashed family sets lineDashOffset and strokes once per segment', () => {
1056
+ const h = new Hatch({ patternName: 'HONEY' });
1057
+ h.setProperty('childEntities', makeSquare());
1058
+ h.buildPatternCache();
1059
+ const expectedSegs = totalSegments(h);
1060
+
1061
+ const ctx = makeTrackingCtx();
1062
+ h.draw(ctx);
1063
+
1064
+ // Every dashed segment gets its own setDash offset and stroke call
1065
+ expect(ctx.getLineDashOffsets().length).toBe(expectedSegs);
1066
+ expect(ctx.getStrokeCalls()).toBe(expectedSegs);
1067
+ });
1068
+
1069
+ // ── processSelection: uncloseable items ──────────────────────────────────────
1070
+
1071
+ test('Hatch.processSelection returns empty array when items cannot form a closed loop', () => {
1072
+ // Two parallel lines that share no endpoints — no closed boundary possible
1073
+ const disconnected = [
1074
+ new Line({ points: [new Point(0, 0), new Point(10, 0)] }),
1075
+ new Line({ points: [new Point(0, 5), new Point(10, 5)] }),
1076
+ ];
1077
+ const h = new Hatch();
1078
+ expect(h.processSelection(disconnected)).toEqual([]);
1079
+ });