@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
@@ -34,50 +34,45 @@ export class Hatch extends Entity {
34
34
  constructor(data) {
35
35
  super(data);
36
36
 
37
- // store the boundary shapes
38
- Object.defineProperty(this, 'childEntities', {
39
- value: [],
37
+ // cache for pre-computed world-space pattern line segments
38
+ Object.defineProperty(this, 'cachedPattern', {
39
+ value: null,
40
40
  writable: true,
41
+ enumerable: false,
41
42
  });
42
43
 
43
- Object.defineProperty(this, 'pattern', {
44
- value: 'ANSI31',
45
- writable: true,
44
+ // Boundary shapes stored in EntityProperties so all entity data lives in one place.
45
+ this.properties.add(Property.Names.CHILDENTITIES, {
46
+ type: Property.Type.ENTITIES,
47
+ value: [],
48
+ visible: false,
46
49
  });
47
50
 
48
- Object.defineProperty(this, 'solid', {
49
- value: false,
50
- writable: true,
51
- });
51
+ // lineType not applicable to hatch
52
+ this.properties.remove(Property.Names.LINETYPE);
52
53
 
53
- Object.defineProperty(this, 'patternName', {
54
- // value: 'ANSI31',
55
- // writable: true,
56
- enumerable: true,
57
- get: this.getPatternName,
58
- set: this.setPatternName,
59
- });
54
+ const rawPatternName = String(Property.loadValue([data?.[2], data?.patternName], 'ANSI31')).toUpperCase();
55
+ // DXF Groupcode 70 - Solid Fill Flag (1 = solid, 0 = pattern): if set, override pattern name
56
+ const resolvedPatternName = data?.[70] === 1 ? 'SOLID' : rawPatternName;
60
57
 
61
- Object.defineProperty(this, 'angle', {
62
- value: 0,
63
- writable: true,
64
- enumerable: true,
65
- });
66
-
67
- Object.defineProperty(this, 'scale', {
68
- value: 1,
69
- writable: true,
70
- enumerable: true,
58
+ // DXF Groupcode 2 - Hatch pattern name
59
+ this.properties.add(Property.Names.PATTERNNAME, {
60
+ type: Property.Type.LIST,
61
+ value: resolvedPatternName,
62
+ dxfCode: 2,
63
+ options: () => Object.keys(Patterns.hatch_patterns).map((name) => ({ display: name, value: name })),
71
64
  });
72
-
73
- // hide inherited properties
74
- // needs to be enumerable=false to not appear in the object props
75
- Object.defineProperty(this, 'lineType', {
76
- enumerable: false,
65
+ // DXF Groupcode 41 - Hatch pattern scale
66
+ this.properties.add(Property.Names.SCALE, {
67
+ type: Property.Type.NUMBER,
68
+ value: Property.loadValue([data?.[41], data?.scale], 1),
69
+ dxfCode: 41,
77
70
  });
78
-
79
- Object.defineProperty(this, 'lineWidth', {
80
- enumerable: false,
71
+ // DXF Groupcode 52 - Hatch pattern angle
72
+ this.properties.add(Property.Names.ANGLE, {
73
+ type: Property.Type.NUMBER,
74
+ value: Property.loadValue([data?.[52], data?.angle], 0),
75
+ dxfCode: 52,
81
76
  });
82
77
 
83
78
  // add a single point to this.points if no other points exist
@@ -85,24 +80,15 @@ export class Hatch extends Entity {
85
80
  this.points.push(new Point());
86
81
  }
87
82
 
88
- // DXF Groupcode 70 - Solid Fill Flag (1 = solid, 0 = pattern)
89
- this.solid = Boolean(Property.loadValue([data?.solid, data?.[70]], 0));
90
- // DXF Groupcode 2 - Hatch pattern name
91
- this.patternName = Property.loadValue([data?.patternName, data?.[2]], 'ANSI31');
92
- // DXF Groupcode 41 - Hatch pattern scale
93
- this.scale = Property.loadValue([data?.scale, data?.[41]], 1);
94
- // DXF Groupcode 52 - Hatch pattern angle
95
- this.angle = Property.loadValue([data?.angle, data?.[52]], 0);
96
-
97
83
  if (data) {
98
84
  if (data.hasOwnProperty('childEntities')) {
99
85
  if (Array.isArray(data.childEntities)) {
100
- this.childEntities = data.childEntities;
86
+ this.setProperty(Property.Names.CHILDENTITIES, data.childEntities);
101
87
  }
102
88
  } else {
103
89
  const shapes = this.processBoundaryData(data);
104
90
  if (shapes.length) {
105
- this.childEntities = shapes;
91
+ this.setProperty(Property.Names.CHILDENTITIES, shapes);
106
92
  }
107
93
  }
108
94
  }
@@ -118,20 +104,198 @@ export class Hatch extends Entity {
118
104
  }
119
105
 
120
106
  /**
121
- * Get the hatch pattern name
122
- * @return {string}
107
+ * Build and cache world-space line segments for the current pattern.
108
+ * For pattern hatches, segments are pre-clipped against the boundary using
109
+ * geometric intersection so draw() requires no ctx.save/clip/restore overhead.
123
110
  */
124
- getPatternName() {
125
- return this.pattern;
126
- }
111
+ buildPatternCache() {
112
+ if (!this.getProperty(Property.Names.CHILDENTITIES).length) {
113
+ this.cachedPattern = [];
114
+ return;
115
+ }
127
116
 
128
- /**
129
- * Set the hatch pattern name
130
- * @param {string} name
131
- */
132
- setPatternName(name) {
133
- this.pattern = name.toUpperCase();
134
- this.solid = this.pattern === 'SOLID';
117
+ if (this.getProperty(Property.Names.PATTERNNAME) === 'SOLID' || !Patterns.patternExists(this.getProperty(Property.Names.PATTERNNAME))) {
118
+ this.cachedPattern = [];
119
+ return;
120
+ }
121
+
122
+ // Build closed boundary arrays for intersection-based pre-clipping
123
+ const bb = this.boundingBox();
124
+ const boundaryExtentX = bb.xMax + bb.xLength + 1;
125
+ const boundaries = [];
126
+ // Collect all interior boundary vertices (the junction points between segments).
127
+ // An intersection point that lands exactly on a junction vertex will be reported
128
+ // by both adjacent segments, doubling the crossing count unless we deduplicate.
129
+ const boundaryVertices = [];
130
+ const childEntities = this.getProperty(Property.Names.CHILDENTITIES);
131
+ for (let i = 0; i < childEntities.length; i++) {
132
+ const shape = childEntities[i];
133
+ if (!shape.points.length) continue;
134
+ const pts = [...shape.points];
135
+ if (!pts[0].isSame(pts.at(-1))) pts.push(pts[0]);
136
+ boundaries.push(pts);
137
+ // All unique vertices (skip only the closure duplicate at pts[length-1]).
138
+ // pts[0] is also a junction — shared by the last and first segments.
139
+ for (let j = 0; j < pts.length - 1; j++) {
140
+ boundaryVertices.push(pts[j]);
141
+ }
142
+ }
143
+
144
+ const centerPoint = bb.centerPoint;
145
+ const bbXLength = bb.xLength;
146
+ const bbYLength = bb.yLength;
147
+ const s = this.getProperty(Property.Names.SCALE);
148
+ const lines = [];
149
+
150
+ const pattern = Patterns.getPattern(this.getProperty(Property.Names.PATTERNNAME));
151
+ pattern.forEach((patternLine) => {
152
+ let dashLength = bbXLength / 2;
153
+ if (patternLine.dashes.length) {
154
+ dashLength = patternLine.getDashLength();
155
+ }
156
+
157
+ let dashes = [...patternLine.dashes];
158
+ let dashOffset = 0;
159
+
160
+ if (dashes.length && dashes[0] < 0) {
161
+ const firstIndex = dashes.shift();
162
+ dashOffset = Math.abs(firstIndex);
163
+ dashes.push(firstIndex);
164
+ }
165
+
166
+ dashes = dashes.map((x) => (Math.abs(x) + 0.00001) * s);
167
+
168
+ // Combined rotation: pattern line angle plus the hatch entity angle
169
+ const rotation = Utils.degrees2radians(patternLine.angle + this.getProperty(Property.Names.ANGLE));
170
+
171
+ // Precomputed rotation components used to transform between pattern-local
172
+ // (line-aligned) space and world space throughout this family's loop.
173
+ const cosR = Math.cos(rotation);
174
+ const sinR = Math.sin(rotation);
175
+
176
+ // World-space anchor for this family: bounding-box centre shifted by the
177
+ // pattern line's origin offset (scaled to match the hatch scale).
178
+ const cx = centerPoint.x + patternLine.xOrigin * s;
179
+ const cy = centerPoint.y + patternLine.yOrigin * s;
180
+
181
+ // Bounding-box half-extents in pattern-local (unscaled) space.
182
+ const halfW = bbXLength / (2 * s);
183
+ const halfH = bbYLength / (2 * s);
184
+
185
+ // Absolute trig values for projecting axis-aligned extents onto rotated axes.
186
+ const sinA = Math.abs(sinR);
187
+ const cosA = Math.abs(cosR);
188
+
189
+ // Half-extents of the bounding box projected onto the pattern line's
190
+ // parallel (halfX) and perpendicular (halfY) axes. halfX bounds how far
191
+ // each raw segment must reach; halfY bounds how many parallel lines are needed.
192
+ const halfX = halfW * cosA + halfH * sinA;
193
+ const halfY = halfW * sinA + halfH * cosA;
194
+
195
+ // Perpendicular projection of the family's origin offset from the bounding-box
196
+ // centre onto the direction perpendicular to the pattern lines. A non-zero
197
+ // origin shifts the whole family, so the loop range must be widened on the far
198
+ // side and the tangent guard must use the shifted position.
199
+ const originPerpOffset = patternLine.yOrigin * cosR - patternLine.xOrigin * sinR;
200
+
201
+ const maxSegmentsPerFamily = 1000;
202
+ const yIncrement = Math.min(
203
+ Math.ceil((halfY + Math.abs(originPerpOffset)) / Math.abs(patternLine.yDelta)),
204
+ maxSegmentsPerFamily,
205
+ );
206
+
207
+ const segments = [];
208
+ for (let i = -yIncrement; i < yIncrement; i++) {
209
+ const yOffset = patternLine.yDelta * i;
210
+ // A line whose shifted perpendicular position equals or exceeds halfY is
211
+ // tangent to or outside the bounding box — it cannot produce any valid
212
+ // inside sub-segment. Skip it to avoid phantom slivers caused by
213
+ // near-tangent numerical artefacts in the arc-intersection code.
214
+ if (Math.abs(yOffset + originPerpOffset) >= halfY) continue;
215
+ // Each family member i is phase-shifted along the line direction by i × xDelta.
216
+ const xPhaseShift = patternLine.xDelta * i;
217
+ // Extend the raw segment far enough in both directions to cover the bounding
218
+ // box regardless of how large the phase shift grows for large i.
219
+ const xHalfSpan = dashLength * Math.ceil((halfX + Math.abs(xPhaseShift)) / dashLength);
220
+ const lx1 = xPhaseShift - xHalfSpan + dashOffset;
221
+ const lx2 = xPhaseShift + xHalfSpan;
222
+ const ly = yOffset;
223
+ const rx1 = (lx1 * cosR - ly * sinR) * s + cx;
224
+ const ry1 = (lx1 * sinR + ly * cosR) * s + cy;
225
+ const rx2 = (lx2 * cosR - ly * sinR) * s + cx;
226
+ const ry2 = (lx2 * sinR + ly * cosR) * s + cy;
227
+
228
+ // Find all intersection t-values where this segment crosses a boundary edge.
229
+ // A point that falls on a boundary vertex is shared by two adjacent segments
230
+ // and would be reported twice, doubling the crossing count. Track which vertex
231
+ // t-values have already been added and skip duplicates.
232
+ const segStartPt = new Point(rx1, ry1);
233
+ const segEndPt = new Point(rx2, ry2);
234
+ const dir = segEndPt.subtract(segStartPt);
235
+ const lenSq = dir.dot(dir);
236
+ if (lenSq === 0) continue;
237
+ const sqrtLenSq = Math.sqrt(lenSq);
238
+ const ts = [0, 1];
239
+ const vertexTsAdded = new Set();
240
+ for (const b of boundaries) {
241
+ const result = Intersection.intersectPolylinePolyline(b, [segStartPt, segEndPt]);
242
+ for (const pt of result.points) {
243
+ const t = pt.subtract(segStartPt).dot(dir) / lenSq;
244
+ if (t > 0.0001 && t < 0.9999) {
245
+ // Check if this point coincides with a boundary vertex
246
+ let isVertex = false;
247
+ for (const v of boundaryVertices) {
248
+ const vdx = pt.x - v.x;
249
+ const vdy = pt.y - v.y;
250
+ if (vdx * vdx + vdy * vdy < 1e-10) {
251
+ isVertex = true;
252
+ break;
253
+ }
254
+ }
255
+ if (isVertex) {
256
+ // Round t to 6 decimal places to get a stable key
257
+ const tKey = Math.round(t * 1e6);
258
+ if (!vertexTsAdded.has(tKey)) {
259
+ vertexTsAdded.add(tKey);
260
+ ts.push(t);
261
+ }
262
+ } else {
263
+ ts.push(t);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ ts.sort((a, b) => a - b);
270
+
271
+ // Deduplicate very close t-values (e.g. a corner vertex found by two edges)
272
+ const uniqueTs = [ts[0]];
273
+ for (let k = 1; k < ts.length; k++) {
274
+ if (ts[k] - uniqueTs.at(-1) > 0.0001) uniqueTs.push(ts[k]);
275
+ }
276
+
277
+ // Test each interval's midpoint; keep sub-segments that are inside the boundary
278
+ for (let k = 0; k < uniqueTs.length - 1; k++) {
279
+ const tMid = (uniqueTs[k] + uniqueTs[k + 1]) / 2;
280
+ if (Intersection.isInsidePolyline(segStartPt.x + tMid * dir.x, segStartPt.y + tMid * dir.y, boundaries, boundaryExtentX)) {
281
+ const p1 = segStartPt.lerp(segEndPt, uniqueTs[k]);
282
+ const p2 = segStartPt.lerp(segEndPt, uniqueTs[k + 1]);
283
+ // Store the segment along with its initial dash phase, which is used by draw()
284
+ segments.push({
285
+ x1: p1.x,
286
+ y1: p1.y,
287
+ x2: p2.x,
288
+ y2: p2.y,
289
+ dashPhase: uniqueTs[k] * sqrtLenSq,
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ lines.push({ dashes, segments });
296
+ });
297
+
298
+ this.cachedPattern = lines;
135
299
  }
136
300
 
137
301
  /**
@@ -189,6 +353,16 @@ export class Hatch extends Entity {
189
353
  }
190
354
 
191
355
  shape.points.push(polyPoint);
356
+
357
+ // On the last vertex, apply the closed flag (73) to the shape.
358
+ // For polyline boundaries 73 = is-closed and appears once, before
359
+ // the vertices, so it has already been parsed into data[73]. We
360
+ // must not consume it inside the arc-edge branch (that branch uses
361
+ // 73 as the per-edge "is counter-clockwise" flag), so we read it
362
+ // here only when we are in the polyline branch and on the final vertex.
363
+ if (edgeNum === edgeCount - 1 && this.getDataValue(data, 73)) {
364
+ shape.flags.addValue(1);
365
+ }
192
366
  } else if (edgeType === 1) {
193
367
  // Line
194
368
  // 10 - X; 20 - Y; 11 - X; 21 - Y
@@ -223,7 +397,7 @@ export class Hatch extends Entity {
223
397
  throw Error(err);
224
398
  }
225
399
  shapeData.points.push(centerPoint);
226
- shapeData[40] = this.getDataValue(data, 40);
400
+ shapeData.radius = this.getDataValue(data, 40);
227
401
  shapeData.startAngle = this.getDataValue(data, 50);
228
402
  shapeData.endAngle = this.getDataValue(data, 51);
229
403
  // arc direction: - ccw > 0, cw <= 0 default 1
@@ -312,7 +486,7 @@ export class Hatch extends Entity {
312
486
  const boundary = this.processSelection(selectedItems);
313
487
 
314
488
  if (boundary.length) {
315
- this.childEntities = boundary;
489
+ this.setProperty(Property.Names.CHILDENTITIES, boundary);
316
490
  validBoundary = true;
317
491
  } else {
318
492
  // reset selection
@@ -335,7 +509,7 @@ export class Hatch extends Entity {
335
509
  const selectedItems = DesignCore.Scene.selectionManager.selectedItems.slice(0);
336
510
  const shapes = this.processSelection(selectedItems);
337
511
  if (shapes.length) {
338
- DesignCore.Scene.tempEntities.create(this.type, { points: this.points, childEntities: shapes });
512
+ DesignCore.Scene.previewEntities.create(this.type, { points: this.points, childEntities: shapes }); // childEntities is passed as plain data here, not via getProperty
339
513
  }
340
514
  }
341
515
 
@@ -409,6 +583,8 @@ export class Hatch extends Entity {
409
583
  // Drop the redundant closure point — the first point already records
410
584
  // the same position; keeping it would create a zero-length segment
411
585
  shape.points.push(...iterationPoints.slice(0, -1));
586
+ // Mark as closed (flag bit 1)
587
+ shape.flags.addValue(1);
412
588
  selectedchildEntities.push(shape);
413
589
  iterationPoints = [];
414
590
  break;
@@ -422,168 +598,52 @@ export class Hatch extends Entity {
422
598
 
423
599
  /**
424
600
  * Draw the entity
425
- * @param {Object} ctx - context
601
+ * @param {Object} renderer
426
602
  * @param {number} scale
427
603
  */
428
- draw(ctx, scale) {
604
+ draw(renderer) {
429
605
  // ensure the scale is valid
430
- if (this.scale < 0.01) {
431
- this.scale = 1;
432
- }
433
-
434
- if (!this.childEntities.length) return;
435
-
436
- ctx.save();
437
-
438
- // Start a new composite path for all boundary loops
439
- try {
440
- ctx.beginPath();
441
- } catch { // Cairo
442
- ctx.newPath();
443
- }
444
-
445
- // Trace all boundary paths as sub-paths in a single composite path
446
- // Using even-odd fill rule, islands are automatically handled:
447
- // areas enclosed by an odd number of boundaries are filled,
448
- // areas enclosed by an even number are left empty
449
- for (let i = 0; i < this.childEntities.length; i++) {
450
- const shape = this.childEntities[i];
451
- if (!shape.points.length) continue;
452
-
453
- // Start a new sub-path for this boundary
454
- ctx.moveTo(shape.points[0].x, shape.points[0].y);
455
- // Trace the boundary path (without stroking)
456
- shape.draw(ctx, scale, false);
457
- // Close the sub-path
458
- ctx.closePath();
606
+ if (this.getProperty(Property.Names.SCALE) < 0.01) {
607
+ this.setProperty(Property.Names.SCALE, 1);
459
608
  }
460
609
 
461
- // Clip using even-odd rule
462
- try { // Cairo - clip() takes no arguments, uses the fill rule set above
463
- ctx.setFillRule(1); // Cairo.FillRule.EVEN_ODD
464
- ctx.clip();
465
- } catch { // HTML Canvas
466
- ctx.clip('evenodd');
467
- }
468
-
469
- // Start a new path for the pattern/fill area
470
- try {
471
- ctx.beginPath();
472
- } catch { // Cairo
473
- ctx.newPath();
474
- }
475
-
476
- // Create an oversized rectangle covering all boundaries
477
- const bb = this.boundingBox();
478
- try {
479
- ctx.rect(bb.xMin - bb.xLength, bb.yMin - bb.yLength, bb.xLength * 3, bb.yLength * 3);
480
- } catch { // Cairo
481
- ctx.rectangle(bb.xMin - bb.xLength, bb.yMin - bb.yLength, bb.xLength * 3, bb.yLength * 3);
482
- }
483
-
484
- // Draw the pattern/fill once over the composite clipped region
485
- this.createPattern(ctx, scale, bb);
486
-
487
- ctx.restore();
488
- }
489
-
490
- /**
491
- * Draw the hatch pattern to the context
492
- * @param {Object} ctx
493
- * @param {number} scale
494
- * @param {BoundingBox} boundingbox - bounding box of the hatch boundaries, used to size the pattern
495
- */
496
- createPattern(ctx, scale, boundingbox) {
497
- if (!Patterns.patternExists(this.patternName) || this.solid) {
498
- ctx.fill();
499
- return;
500
- }
501
-
502
- const boundingBox = boundingbox;
503
- const centerPoint = boundingBox.centerPoint;
504
- const bbXLength = boundingBox.xLength;
505
- const bbYLength = boundingBox.yLength;
506
-
507
- const pattern = Patterns.getPattern(this.patternName);
508
- pattern.forEach((patternLine) => {
509
- // Each pattern line is considered to be the first member of a line family,
510
- // Patterns are created by applying the delta offsets in both directions to generate an infinite family of parallel lines.
511
- // originX and originY values are the offsets of the line from the origin and are applied without rotation
512
- // The delta-x value indicates the displacement between members of the family in the direction of the line. It is used only for dashed lines.
513
- // The delta-y value indicates the spacing between members of the family; that is, it is measured perpendicular to the lines.
514
- // A line is considered to be of infinite length.
515
- // A dash pattern is superimposed on the line.
516
-
517
- // define dashlength - i.e. the length of each dash summed
518
- // where there is no dash defined use half the boundingbox xlength
519
- let dashLength = bbXLength / 2;
520
- if (patternLine.dashes.length) {
521
- dashLength = patternLine.getDashLength();
610
+ const childEntitiesDraw = this.getProperty(Property.Names.CHILDENTITIES);
611
+ if (!childEntitiesDraw.length) return;
612
+
613
+ // Build cache if stale (cachedPattern === null means never built or invalidated)
614
+ if (this.cachedPattern === null) this.buildPatternCache();
615
+
616
+ if (this.getProperty(Property.Names.PATTERNNAME) === 'SOLID') {
617
+ // Design renders solid hatches with a direct fill() call rather than the
618
+ // dense cross-hatch line pattern used by commercial CAD applications
619
+ // (Commercial CAD's SOLID pattern uses two line families at 0.0001-unit spacing).
620
+ // fill() is resolution-independent, handles curved boundaries exactly,
621
+ // and avoids the cost of generating and clipping thousands of segments.
622
+ renderer.beginPath();
623
+ for (const shape of childEntitiesDraw) {
624
+ if (!shape.points.length) continue;
625
+ renderer.tracePath(shape.toPolylinePoints());
626
+ renderer.closePath();
522
627
  }
523
-
524
- // copy dashes to avoid mutating the pattern line
525
- let dashes = [...patternLine.dashes];
526
- let dashOffset = 0;
527
-
528
- if (dashes[0] < 0) {
529
- // if the first dash is negative move it to the end
530
- const firstIndex = dashes.shift();
531
- dashOffset = Math.abs(firstIndex);
532
- dashes.push(firstIndex);
533
- }
534
-
535
- dashes = dashes.map((x) => Math.abs(x) + 0.00001);
536
-
537
- try {
538
- ctx.setLineDash(dashes, 0);
539
- ctx.lineWidth = (1 / scale / this.scale);
540
- ctx.beginPath();
541
- } catch { // Cairo
542
- ctx.setDash(dashes, 0);
543
- ctx.setLineWidth(1 / scale / this.scale);
544
- ctx.newPath();
628
+ renderer.applyPath({ fill: true, stroke: false, fillRule: 'evenodd' });
629
+ } else {
630
+ // Pattern hatch: draw pre-clipped segments directly.
631
+ for (const family of this.cachedPattern) {
632
+ renderer.setDash([], 0);
633
+ renderer.drawSegments(family.segments, family.dashes);
545
634
  }
635
+ }
546
636
 
547
- const rotation = Utils.degrees2radians(patternLine.angle + this.angle);
548
-
549
- // Project the bounding box onto the pattern's rotated local axes
550
- // to compute tight line counts instead of using the worst-case max dimension.
551
- const halfW = bbXLength / (2 * this.scale);
552
- const halfH = bbYLength / (2 * this.scale);
553
- const sinR = Math.abs(Math.sin(rotation));
554
- const cosR = Math.abs(Math.cos(rotation));
555
-
556
- // Half-extent along the line direction (X in local frame)
557
- const halfX = halfW * cosR + halfH * sinR;
558
- // Half-extent perpendicular to lines (Y in local frame)
559
- const halfY = halfW * sinR + halfH * cosR;
560
-
561
- const xIncrement = Math.ceil(halfX / dashLength);
562
- const yIncrement = Math.ceil(halfY / Math.abs(patternLine.yDelta));
563
-
564
- // Set transform once for all lines in this pattern line family
565
- ctx.save();
566
- // translate to the center of the shape
567
- // apply the origin offsets without rotation
568
- ctx.translate(centerPoint.x + patternLine.xOrigin * this.scale, centerPoint.y + patternLine.yOrigin * this.scale);
569
- // scale the context
570
- ctx.scale(this.scale, this.scale);
571
- // rotate the context
572
- ctx.rotate(rotation);
573
-
574
- for (let i = -yIncrement; i < yIncrement; i++) {
575
- // apply the deltaX offset for odd iterations
576
- // define offsets for the current iteration
577
- const xOffset = patternLine.xDelta * Math.abs(i) + dashLength * xIncrement;
578
- const yOffset = patternLine.yDelta * i;
579
-
580
- ctx.moveTo(-xOffset + dashOffset, yOffset);
581
- ctx.lineTo(xOffset, yOffset);
637
+ // Draw the boundary outline when hovered or selected.
638
+ const isSelected = DesignCore.Scene.selectionManager.selectedItems.includes(this);
639
+ const isHovered = DesignCore.Scene.hoverEntities.indexOf(this) !== -1;
640
+ if (isSelected || isHovered) {
641
+ renderer.setDash([], 0);
642
+ for (const shape of childEntitiesDraw) {
643
+ if (!shape.points.length) continue;
644
+ renderer.drawShape(shape.toPolylinePoints(), { closed: true });
582
645
  }
583
-
584
- ctx.stroke();
585
- ctx.restore();
586
- });
646
+ }
587
647
  }
588
648
 
589
649
  /**
@@ -592,14 +652,15 @@ export class Hatch extends Entity {
592
652
  */
593
653
  dxf(file) {
594
654
  // skip if no boundary shapes
595
- if (!this.childEntities.length) {
655
+ const childEntitiesDxf = this.getProperty(Property.Names.CHILDENTITIES);
656
+ if (!childEntitiesDxf.length) {
596
657
  return;
597
658
  }
598
659
 
599
660
  file.writeGroupCode('0', 'HATCH');
600
- file.writeGroupCode('5', this.handle, DXFFile.Version.R2000); // Handle
661
+ file.writeGroupCode('5', this.getProperty(Property.Names.HANDLE), DXFFile.Version.R2000); // Handle
601
662
  file.writeGroupCode('100', 'AcDbEntity', DXFFile.Version.R2000);
602
- file.writeGroupCode('8', this.layer);
663
+ file.writeGroupCode('8', this.getProperty(Property.Names.LAYER));
603
664
  file.writeGroupCode('100', 'AcDbHatch', DXFFile.Version.R2000);
604
665
  file.writeGroupCode('10', '0.0');
605
666
  file.writeGroupCode('20', '0.0');
@@ -609,15 +670,15 @@ export class Hatch extends Entity {
609
670
  file.writeGroupCode('220', '0.0'); // Extrusion Direction Y
610
671
  file.writeGroupCode('230', '1.0'); // Extrusion Direction Z
611
672
 
612
- file.writeGroupCode('2', this.patternName); // Hatch pattern name
613
- file.writeGroupCode('70', this.solid ? 1 : 0); // Solid Fill Flag (1 = solid, 0 = pattern)
673
+ file.writeGroupCode('2', this.getProperty(Property.Names.PATTERNNAME)); // Hatch pattern name
674
+ file.writeGroupCode('70', this.getProperty(Property.Names.PATTERNNAME) === 'SOLID' ? 1 : 0); // Solid Fill Flag (1 = solid, 0 = pattern)
614
675
  file.writeGroupCode('71', '0'); // Associativity flag (associative = 1; non-associative = 0); for MPolygon, solid-fill flag (has solidfill = 1; lacks solid fill = 0)
615
676
 
616
677
 
617
- file.writeGroupCode('91', this.childEntities.length); // Number of boundary path loops
678
+ file.writeGroupCode('91', childEntitiesDxf.length); // Number of boundary path loops
618
679
 
619
- for (let i = 0; i < this.childEntities.length; i++) {
620
- const shape = this.childEntities[i];
680
+ for (let i = 0; i < childEntitiesDxf.length; i++) {
681
+ const shape = childEntitiesDxf[i];
621
682
  file.writeGroupCode('92', '7'); // Boundary path type flag (bit coded): 0 = Default; 1 = External; 2 = Polyline 4 = Derived; 8 = Textbox; 16 = Outermost
622
683
  file.writeGroupCode('72', '1'); // Edge type (only if boundary is not a polyline): 1 = Line; 2 = Circular arc; 3 = Elliptic arc; 4 = Spline
623
684
  file.writeGroupCode('73', '1'); // For MPolygon, boundary annotation flag (boundary is an annotated boundary = 1; boundary is not an annotated boundary = 0)
@@ -635,25 +696,25 @@ export class Hatch extends Entity {
635
696
  file.writeGroupCode('75', '1'); // Hatch style: 0 = Hatch “odd parity” area (Normal style) 1 = Hatch outermost area only (Outer style) 2 = Hatch through entire area (Ignore style)
636
697
  file.writeGroupCode('76', '1'); // Hatch pattern type: 0 = User-defined; 1 = Predefined; 2 = Custom
637
698
 
638
- if (!this.solid) {
639
- file.writeGroupCode('52', this.angle); // Hatch Pattern angle
640
- file.writeGroupCode('41', this.scale); // Hatch Pattern scale
699
+ if (this.getProperty(Property.Names.PATTERNNAME) !== 'SOLID') {
700
+ file.writeGroupCode('52', this.getProperty(Property.Names.ANGLE)); // Hatch Pattern angle
701
+ file.writeGroupCode('41', this.getProperty(Property.Names.SCALE)); // Hatch Pattern scale
641
702
  file.writeGroupCode('77', '0'); // Hatch pattern double flag(pattern fill only): 0 = not double; 1 = double
642
- file.writeGroupCode('78', Patterns.getPatternLineCount(this.patternName)); // Number of pattern definition lines
703
+ file.writeGroupCode('78', Patterns.getPatternLineCount(this.getProperty(Property.Names.PATTERNNAME))); // Number of pattern definition lines
643
704
 
644
705
  // Pattern data
645
- const pattern = Patterns.getPattern(this.patternName);
706
+ const pattern = Patterns.getPattern(this.getProperty(Property.Names.PATTERNNAME));
646
707
  pattern.forEach((patternLine) => {
647
- file.writeGroupCode('53', patternLine.angle + this.angle); // Pattern line angle
708
+ file.writeGroupCode('53', patternLine.angle + this.getProperty(Property.Names.ANGLE)); // Pattern line angle
648
709
  file.writeGroupCode('43', patternLine.xOrigin); // Pattern line base X
649
710
  file.writeGroupCode('44', patternLine.yOrigin); // Pattern line base y
650
- const deltaPoint = new Point(patternLine.xDelta * this.scale, patternLine.yDelta * this.scale);
651
- const rotatedDelta = deltaPoint.rotate(new Point(), Utils.degrees2radians(patternLine.angle + this.angle));
711
+ const deltaPoint = new Point(patternLine.xDelta * this.getProperty(Property.Names.SCALE), patternLine.yDelta * this.getProperty(Property.Names.SCALE));
712
+ const rotatedDelta = deltaPoint.rotate(new Point(), Utils.degrees2radians(patternLine.angle + this.getProperty(Property.Names.ANGLE)));
652
713
  file.writeGroupCode('45', rotatedDelta.x); // Pattern line offset x
653
714
  file.writeGroupCode('46', rotatedDelta.y); // Pattern line offset y
654
715
  file.writeGroupCode('79', patternLine.dashes.length); // Number of dash length items
655
716
  patternLine.dashes.forEach((dash) => {
656
- file.writeGroupCode('49', dash * this.scale); // Dash length
717
+ file.writeGroupCode('49', dash * this.getProperty(Property.Names.SCALE)); // Dash length
657
718
  });
658
719
  });
659
720
  }
@@ -695,8 +756,9 @@ export class Hatch extends Entity {
695
756
  */
696
757
  isInside(P) {
697
758
  // P = P.subtract(this.points[0]);
698
- for (let i = 0; i < this.childEntities.length; i++) {
699
- const shape = this.childEntities[i];
759
+ const childEntities = this.getProperty(Property.Names.CHILDENTITIES);
760
+ for (let i = 0; i < childEntities.length; i++) {
761
+ const shape = childEntities[i];
700
762
 
701
763
  if (shape.boundingBox().isInside(P)) {
702
764
  const polyline = [...shape.points];
@@ -726,7 +788,8 @@ export class Hatch extends Entity {
726
788
  * @return {BoundingBox}
727
789
  */
728
790
  boundingBox() {
729
- if (this.childEntities.length === 0) {
791
+ const childEntities = this.getProperty(Property.Names.CHILDENTITIES);
792
+ if (childEntities.length === 0) {
730
793
  return new BoundingBox();
731
794
  }
732
795
 
@@ -735,8 +798,8 @@ export class Hatch extends Entity {
735
798
  let ymin = Infinity;
736
799
  let ymax = -Infinity;
737
800
 
738
- for (let i = 0; i < this.childEntities.length; i++) {
739
- const shape = this.childEntities[i];
801
+ for (let i = 0; i < childEntities.length; i++) {
802
+ const shape = childEntities[i];
740
803
 
741
804
  const boundingBox = shape.boundingBox();
742
805
 
@@ -758,8 +821,9 @@ export class Hatch extends Entity {
758
821
  * @return {boolean} true if touched
759
822
  */
760
823
  touched(selection) {
761
- for (let i = 0; i < this.childEntities.length; i++) {
762
- if (this.childEntities[i].touched(selection)) {
824
+ const childEntities = this.getProperty(Property.Names.CHILDENTITIES);
825
+ for (let i = 0; i < childEntities.length; i++) {
826
+ if (childEntities[i].touched(selection)) {
763
827
  return true;
764
828
  }
765
829
  }
@@ -773,10 +837,15 @@ export class Hatch extends Entity {
773
837
  * @param {any} value
774
838
  */
775
839
  setProperty(property, value) {
776
- if (this.hasOwnProperty(property)) {
777
- if (property === 'points') {
840
+ if (this.properties.has(property)) {
841
+ if (property === Property.Names.PATTERNNAME) {
842
+ const upper = String(value).toUpperCase();
843
+ if (this.getProperty(Property.Names.PATTERNNAME) === upper) return;
844
+ super.setProperty(property, upper);
845
+ } else if (property === Property.Names.POINTS) {
778
846
  // Special handling for hatch points to move child entities
779
847
  // Consider the changes from the hatch points to be an offset and rotation
848
+ const hatchChildren = this.getProperty(Property.Names.CHILDENTITIES);
780
849
  const ang = value[0].angle(value.at(-1));
781
850
  const theta = ang - this.points[0].angle(this.points.at(-1));
782
851
 
@@ -784,26 +853,30 @@ export class Hatch extends Entity {
784
853
  // set rotation center
785
854
  const center = this.points[0];
786
855
  // apply rotation to child entities points
787
- this.childEntities.forEach((child) => {
856
+ hatchChildren.forEach((child) => {
788
857
  const rotatedPoints = child.points.map((p) => new Point(p.x, p.y, p.bulge, p.sequence).rotate(center, theta));
789
- child.setProperty('points', rotatedPoints);
858
+ child.setProperty(Property.Names.POINTS, rotatedPoints);
790
859
  });
791
860
 
792
- this.angle+= Utils.radians2degrees(theta);
861
+ this.setProperty(Property.Names.ANGLE, this.getProperty(Property.Names.ANGLE) + Utils.radians2degrees(theta));
793
862
  }
794
863
 
795
864
  const delta = value[0].subtract(this.points[0]);
796
865
  if (delta.x !== 0 || delta.y !== 0) {
797
- // apply translation to child entities points
798
- this.childEntities.forEach((child) => {
866
+ // apply translation to child entities points
867
+ hatchChildren.forEach((child) => {
799
868
  const offsetPoints = child.points.map((p) => new Point(p.x, p.y, p.bulge, p.sequence).add(delta));
800
- child.setProperty('points', offsetPoints);
869
+ child.setProperty(Property.Names.POINTS, offsetPoints);
801
870
  });
802
871
  }
803
- }
804
872
 
805
- // other properties as normal
806
- this[property] = value;
873
+ super.setProperty(property, value);
874
+ } else {
875
+ super.setProperty(property, value);
876
+ }
877
+ // invalidate the pattern cache — geometry, scale, angle and boundary
878
+ // shape are all baked into the cache
879
+ this.cachedPattern = null;
807
880
  }
808
881
  }
809
882
  }