@design-core/core 50.1.0 → 51.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ import { Line } from './line.js';
19
19
  import { Arc } from './arc.js';
20
20
  import { Polyline } from './polyline.js';
21
21
  import { Property } from '../properties/property.js';
22
+ import { PatternLine } from '../lib/patterns.js';
22
23
 
23
24
  /**
24
25
  * Hatch Entity Class
@@ -41,6 +42,14 @@ export class Hatch extends Entity {
41
42
  enumerable: false,
42
43
  });
43
44
 
45
+ // resolved PatternLine array — populated at construction from inline DXF
46
+ // data (type 0/2) or from the Patterns library (type 1)
47
+ Object.defineProperty(this, 'patternLines', {
48
+ value: [],
49
+ writable: true,
50
+ enumerable: false,
51
+ });
52
+
44
53
  // Boundary shapes stored in EntityProperties so all entity data lives in one place.
45
54
  this.properties.add(Property.Names.CHILDENTITIES, {
46
55
  type: Property.Type.ENTITIES,
@@ -53,14 +62,24 @@ export class Hatch extends Entity {
53
62
 
54
63
  const rawPatternName = String(Property.loadValue([data?.[2], data?.patternName], 'ANSI31')).toUpperCase();
55
64
  // DXF Groupcode 70 - Solid Fill Flag (1 = solid, 0 = pattern): if set, override pattern name
56
- const resolvedPatternName = data?.[70] === 1 ? 'SOLID' : rawPatternName;
65
+ // DXF Groupcode 76 - Hatch pattern type: 0 = User-defined ('U'); 1 = Predefined; 2 = Custom
66
+ const patternType = data?.[76] ?? 1;
67
+ // User-defined hatches (76=0) are normalised to the sentinel name 'U'
68
+ // so the pattern type is implied by the name rather than stored separately.
69
+ const resolvedPatternName = data?.[70] === 1 ? 'SOLID' : patternType === 0 ? 'U' : rawPatternName;
70
+ if (patternType === 1 && resolvedPatternName !== 'SOLID' && !Patterns.patternExists(resolvedPatternName)) {
71
+ Logging.instance.warn(`Hatch: pattern '${resolvedPatternName}' not found`);
72
+ }
57
73
 
58
74
  // DXF Groupcode 2 - Hatch pattern name
59
75
  this.properties.add(Property.Names.PATTERNNAME, {
60
76
  type: Property.Type.LIST,
61
77
  value: resolvedPatternName,
62
78
  dxfCode: 2,
63
- options: () => Object.keys(Patterns.hatch_patterns).map((name) => ({ display: name, value: name })),
79
+ options: () => [
80
+ { display: 'User Defined', value: 'U' },
81
+ ...Object.keys(Patterns.hatch_patterns).map((name) => ({ display: name, value: name })),
82
+ ],
64
83
  });
65
84
  // DXF Groupcode 41 - Hatch pattern scale
66
85
  this.properties.add(Property.Names.SCALE, {
@@ -74,6 +93,28 @@ export class Hatch extends Entity {
74
93
  value: Property.loadValue([data?.[52], data?.angle], 0),
75
94
  dxfCode: 52,
76
95
  });
96
+ // DXF Groupcode 47 - Line spacing (user-defined hatches only)
97
+ this.properties.add(Property.Names.HATCHSPACING, {
98
+ type: Property.Type.NUMBER,
99
+ value: Property.loadValue([data?.[47]], 1),
100
+ dxfCode: 47,
101
+ readOnly: (entity) => entity.getProperty(Property.Names.PATTERNNAME) !== 'U',
102
+ });
103
+ // DXF Groupcode 77 - Double flag (user-defined hatches only): 0 = not double; 1 = double
104
+ this.properties.add(Property.Names.HATCHDOUBLE, {
105
+ type: Property.Type.BOOLEAN,
106
+ value: (data?.[77] ?? 0) === 1,
107
+ dxfCode: 77,
108
+ readOnly: (entity) => entity.getProperty(Property.Names.PATTERNNAME) !== 'U',
109
+ });
110
+
111
+ // Populate patternLines: inline data for user-defined (76=0) and custom
112
+ // (76=2) patterns; Patterns library for predefined (76=1, default).
113
+ if ((patternType === 0 || patternType === 2) && data) {
114
+ this.patternLines = this.#buildInlinePatternLines(data);
115
+ } else if (resolvedPatternName !== 'SOLID' && Patterns.patternExists(resolvedPatternName)) {
116
+ this.patternLines = this._copyLibraryPattern(resolvedPatternName);
117
+ }
77
118
 
78
119
  // add a single point to this.points if no other points exist
79
120
  if (!this.points.length) {
@@ -103,6 +144,116 @@ export class Hatch extends Entity {
103
144
  }
104
145
  }
105
146
 
147
+ /**
148
+ * Return unfrozen copies of library PatternLine objects for a named pattern.
149
+ * Patterns.getPattern() returns frozen objects (shared cache); cloneObject()
150
+ * uses Reflect.ownKeys and would fail writing into frozen instances.
151
+ * @param {string} name - upper-case pattern name
152
+ * @return {Array<PatternLine>} array of mutable PatternLine copies
153
+ */
154
+ _copyLibraryPattern(name) {
155
+ return Patterns.getPattern(name).map((pl) => {
156
+ const copy = new PatternLine();
157
+ copy.angle = pl.angle;
158
+ copy.xOrigin = pl.xOrigin;
159
+ copy.yOrigin = pl.yOrigin;
160
+ copy.xDelta = pl.xDelta;
161
+ copy.yDelta = pl.yDelta;
162
+ copy.dashes = [...pl.dashes];
163
+ return copy;
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Synthesise PatternLine objects for a user-defined hatch from line spacing.
169
+ * Used when the pattern name is 'U' and the pattern has no explicit family data.
170
+ * @param {number} spacing - perpendicular distance between parallel lines
171
+ * @param {boolean} isDouble - when true, add a second family at 90° to the first
172
+ * @return {Array<PatternLine>}
173
+ */
174
+ _buildSpacingPatternLines(spacing, isDouble) {
175
+ const pl = new PatternLine();
176
+ pl.angle = 0;
177
+ pl.xOrigin = 0;
178
+ pl.yOrigin = 0;
179
+ pl.xDelta = 0;
180
+ pl.yDelta = spacing;
181
+ pl.dashes = [];
182
+ const result = [pl];
183
+ if (isDouble) {
184
+ const pl2 = new PatternLine();
185
+ pl2.angle = 90;
186
+ pl2.xOrigin = 0;
187
+ pl2.yOrigin = 0;
188
+ pl2.xDelta = 0;
189
+ pl2.yDelta = spacing;
190
+ pl2.dashes = [];
191
+ result.push(pl2);
192
+ }
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Build PatternLine objects from inline DXF pattern data (group code 76 = 0 or 2).
198
+ * DXF stores deltas pre-multiplied by scale; we divide by scale to recover the
199
+ * normalised (scale=1) values that buildPatternCache expects.
200
+ * @param {Object} data - raw DXF group-code data for the HATCH entity
201
+ * @return {Array<PatternLine>} array of PatternLine objects
202
+ */
203
+ #buildInlinePatternLines(data) {
204
+ const scale = data[41] ?? 1;
205
+ // DXF group code 53 per-family angles are written by AutoCAD as absolute
206
+ // angles that already include the entity rotation (group code 52). Subtract
207
+ // the entity angle here so that buildPatternCache's addition of ANGLE
208
+ // restores the correct absolute rotation without doubling it.
209
+ const entityAngle = data[52] ?? 0;
210
+ const angles = [].concat(data[53] ?? []);
211
+ const dxDeltas = [].concat(data[45] ?? []);
212
+ const dyDeltas = [].concat(data[46] ?? []);
213
+ const dashCounts = [].concat(data[79] ?? []);
214
+ const allDashes = [].concat(data[49] ?? []);
215
+
216
+ // Pure user-defined hatch (76=0): no inline family data — synthesise
217
+ // from group code 47 (line spacing) and 77 (double flag).
218
+ if (!angles.length) {
219
+ return this._buildSpacingPatternLines((data[47] ?? 1) / scale, data[77] === 1);
220
+ }
221
+
222
+ const patternLines = [];
223
+ let dashIdx = 0;
224
+
225
+ angles.forEach((angle, i) => {
226
+ const pl = new PatternLine();
227
+ pl.angle = angle - entityAngle;
228
+ pl.xOrigin = 0;
229
+ pl.yOrigin = 0;
230
+ // DXF deltas are already in drawing units, pre-multiplied by scale; divide out.
231
+ pl.xDelta = (dxDeltas[i] ?? 0) / scale;
232
+ pl.yDelta = (dyDeltas[i] ?? 0) / scale;
233
+
234
+ const count = dashCounts[i] ?? 0;
235
+ pl.dashes = allDashes.slice(dashIdx, dashIdx + count).map((d) => d / scale);
236
+ dashIdx += count;
237
+
238
+ patternLines.push(pl);
239
+ });
240
+
241
+ // DXF group code 77: double flag — add a perpendicular family
242
+ if (data[77] === 1 && patternLines.length > 0) {
243
+ const first = patternLines[0];
244
+ const pl = new PatternLine();
245
+ pl.angle = first.angle + 90;
246
+ pl.xOrigin = 0;
247
+ pl.yOrigin = 0;
248
+ pl.xDelta = first.xDelta;
249
+ pl.yDelta = first.yDelta;
250
+ pl.dashes = [...first.dashes];
251
+ patternLines.push(pl);
252
+ }
253
+
254
+ return patternLines;
255
+ }
256
+
106
257
  /**
107
258
  * Build and cache world-space line segments for the current pattern.
108
259
  * For pattern hatches, segments are pre-clipped against the boundary using
@@ -114,7 +265,7 @@ export class Hatch extends Entity {
114
265
  return;
115
266
  }
116
267
 
117
- if (this.getProperty(Property.Names.PATTERNNAME) === 'SOLID' || !Patterns.patternExists(this.getProperty(Property.Names.PATTERNNAME))) {
268
+ if (!this.patternLines.length) {
118
269
  this.cachedPattern = [];
119
270
  return;
120
271
  }
@@ -147,7 +298,7 @@ export class Hatch extends Entity {
147
298
  const s = this.getProperty(Property.Names.SCALE);
148
299
  const lines = [];
149
300
 
150
- const pattern = Patterns.getPattern(this.getProperty(Property.Names.PATTERNNAME));
301
+ const pattern = this.patternLines;
151
302
  pattern.forEach((patternLine) => {
152
303
  let dashLength = bbXLength / 2;
153
304
  if (patternLine.dashes.length) {
@@ -613,7 +764,7 @@ export class Hatch extends Entity {
613
764
  // Build cache if stale (cachedPattern === null means never built or invalidated)
614
765
  if (this.cachedPattern === null) this.buildPatternCache();
615
766
 
616
- if (this.getProperty(Property.Names.PATTERNNAME) === 'SOLID') {
767
+ if (this.cachedPattern.length === 0 && this.getProperty(Property.Names.PATTERNNAME) === 'SOLID') {
617
768
  // Design renders solid hatches with a direct fill() call rather than the
618
769
  // dense cross-hatch line pattern used by commercial CAD applications
619
770
  // (Commercial CAD's SOLID pattern uses two line families at 0.0001-unit spacing).
@@ -694,17 +845,20 @@ export class Hatch extends Entity {
694
845
  file.writeGroupCode('97', '0'); // Number of source boundary objects
695
846
  }
696
847
  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)
697
- file.writeGroupCode('76', '1'); // Hatch pattern type: 0 = User-defined; 1 = Predefined; 2 = Custom
848
+ // Hatch pattern type: 0 = User-defined; 1 = Predefined; 2 = Custom
849
+ const patternName = this.getProperty(Property.Names.PATTERNNAME);
850
+ const isUserDefined = patternName === 'U';
851
+ const isLibraryPattern = !isUserDefined && patternName !== 'SOLID' && Patterns.patternExists(patternName);
852
+ file.writeGroupCode('76', isUserDefined ? '0' : isLibraryPattern ? '1' : '2');
698
853
 
699
- if (this.getProperty(Property.Names.PATTERNNAME) !== 'SOLID') {
854
+ if (patternName !== 'SOLID') {
700
855
  file.writeGroupCode('52', this.getProperty(Property.Names.ANGLE)); // Hatch Pattern angle
701
856
  file.writeGroupCode('41', this.getProperty(Property.Names.SCALE)); // Hatch Pattern scale
702
- file.writeGroupCode('77', '0'); // Hatch pattern double flag(pattern fill only): 0 = not double; 1 = double
703
- file.writeGroupCode('78', Patterns.getPatternLineCount(this.getProperty(Property.Names.PATTERNNAME))); // Number of pattern definition lines
857
+ file.writeGroupCode('77', this.getProperty(Property.Names.HATCHDOUBLE) ? '1' : '0'); // Hatch pattern double flag(pattern fill only): 0 = not double; 1 = double
858
+ file.writeGroupCode('78', this.patternLines.length); // Number of pattern definition lines
704
859
 
705
860
  // Pattern data
706
- const pattern = Patterns.getPattern(this.getProperty(Property.Names.PATTERNNAME));
707
- pattern.forEach((patternLine) => {
861
+ this.patternLines.forEach((patternLine) => {
708
862
  file.writeGroupCode('53', patternLine.angle + this.getProperty(Property.Names.ANGLE)); // Pattern line angle
709
863
  file.writeGroupCode('43', patternLine.xOrigin); // Pattern line base X
710
864
  file.writeGroupCode('44', patternLine.yOrigin); // Pattern line base y
@@ -719,7 +873,7 @@ export class Hatch extends Entity {
719
873
  });
720
874
  }
721
875
 
722
- file.writeGroupCode('47', '0.5'); // pixel size
876
+ file.writeGroupCode('47', isUserDefined ? this.getProperty(Property.Names.HATCHSPACING) : '0.5'); // line spacing (user-defined) / pixel size
723
877
  file.writeGroupCode('98', '1'); // Number of seed points
724
878
  // seed points
725
879
  file.writeGroupCode('10', '1');
@@ -842,6 +996,22 @@ export class Hatch extends Entity {
842
996
  const upper = String(value).toUpperCase();
843
997
  if (this.getProperty(Property.Names.PATTERNNAME) === upper) return;
844
998
  super.setProperty(property, upper);
999
+ if (upper === 'U') {
1000
+ this.patternLines = this._buildSpacingPatternLines(
1001
+ this.getProperty(Property.Names.HATCHSPACING),
1002
+ this.getProperty(Property.Names.HATCHDOUBLE),
1003
+ );
1004
+ } else {
1005
+ this.patternLines = (upper !== 'SOLID' && Patterns.patternExists(upper)) ? this._copyLibraryPattern(upper) : [];
1006
+ }
1007
+ } else if (property === Property.Names.HATCHSPACING || property === Property.Names.HATCHDOUBLE) {
1008
+ super.setProperty(property, value);
1009
+ if (this.getProperty(Property.Names.PATTERNNAME) === 'U') {
1010
+ this.patternLines = this._buildSpacingPatternLines(
1011
+ this.getProperty(Property.Names.HATCHSPACING),
1012
+ this.getProperty(Property.Names.HATCHDOUBLE),
1013
+ );
1014
+ }
845
1015
  } else if (property === Property.Names.POINTS) {
846
1016
  // Special handling for hatch points to move child entities
847
1017
  // Consider the changes from the hatch points to be an offset and rotation
@@ -51,7 +51,11 @@ export class Insert extends Entity {
51
51
  // DXF Groupcode 2 - Block name
52
52
  if (data.blockName !== undefined) {
53
53
  const block = DesignCore.Scene.blockManager.getItemByName(data.blockName);
54
- this.block = block;
54
+ if (block) {
55
+ this.block = block;
56
+ } else {
57
+ Logging.instance.warn(`Insert: block '${data.blockName}' not found`);
58
+ }
55
59
  }
56
60
 
57
61
  // Named scalar rotation (internal API); DXF code 50 is handled by fromDxf
@@ -247,6 +251,7 @@ export class Insert extends Entity {
247
251
  */
248
252
  boundingBox() {
249
253
  const blockBB = this.block.boundingBox();
254
+ if (!blockBB) return new BoundingBox();
250
255
  const topLeft = blockBB.pt1.add(this.points[0]);
251
256
  const bottomRight = blockBB.pt2.add(this.points[0]);
252
257
  return new BoundingBox(topLeft, bottomRight);
@@ -222,16 +222,21 @@ export class Canvas {
222
222
  const width = xMax - xMin;
223
223
  const height = yMax - yMin;
224
224
 
225
- if (width === 0 || height === 0) {
225
+ if (width === 0 && height === 0) {
226
226
  return;
227
227
  }
228
228
 
229
229
  const selectionCenter = new Point(xMin + width / 2, yMin + height / 2);
230
230
  const screenCenter = new Point(this.width / 2, this.height / 2);
231
231
  const translateDelta = DesignCore.Mouse.transformToScene(screenCenter).subtract(selectionCenter);
232
- const targetScale = Math.min((this.width / width), (this.height / height));
232
+ // fall back to the non-zero axis when the window is a single line
233
+ const targetScale = width === 0 ? this.height / height :
234
+ height === 0 ? this.width / width :
235
+ Math.min((this.width / width), (this.height / height));
233
236
  const scaleDelta = targetScale / this.getScale() * 0.9;
234
237
 
238
+ if (!isFinite(scaleDelta) || scaleDelta <= 0) return;
239
+
235
240
  this.matrix.scale(scaleDelta, scaleDelta);
236
241
  this.matrix.translate((selectionCenter.x / scaleDelta) - selectionCenter.x, (selectionCenter.y / scaleDelta) - selectionCenter.y);
237
242
  this.matrix.translate(translateDelta.x / scaleDelta, translateDelta.y / scaleDelta);
@@ -58,7 +58,8 @@ export class EntityProperties {
58
58
  set(name, value, entity) {
59
59
  const prop = this._store[name];
60
60
  if (!prop) return;
61
- if (prop.readOnly) return;
61
+ const isReadOnly = typeof prop.readOnly === 'function' ? prop.readOnly(entity) : prop.readOnly;
62
+ if (isReadOnly) return;
62
63
  if (prop.set) {
63
64
  prop.set(entity, value);
64
65
  } else {
@@ -28,6 +28,18 @@ export class Flags {
28
28
  return this.flags;
29
29
  }
30
30
 
31
+ /**
32
+ * Serialise as a plain number so JSON.stringify produces e.g. `"flags":1`
33
+ * instead of `"flags":{"flags":1}`. Without this, the round-trip through
34
+ * clipboard stringify/parse loses the flag value (e.g. the closed flag on a
35
+ * polyline), because the reconstructed plain object is not a Flags instance
36
+ * and Property.loadValue cannot extract the numeric value from it.
37
+ * @return {number}
38
+ */
39
+ toJSON() {
40
+ return this.flags;
41
+ }
42
+
31
43
  /**
32
44
  * Get the backwards value
33
45
  * @param {number} flagValue
@@ -29,9 +29,12 @@ export class Property {
29
29
  return this._value;
30
30
  }
31
31
 
32
- /** @param {any} val - new value; ignored when readOnly */
32
+ /** @param {any} val - new value; ignored when readOnly is true.
33
+ * Callable readOnly is evaluated by the caller (entityProperties.set) with
34
+ * the entity in scope; treat it as writable here to avoid stale closure issues. */
33
35
  set value(val) {
34
- if (!this.readOnly) {
36
+ const isReadOnly = typeof this.readOnly === 'function' ? false : this.readOnly;
37
+ if (!isReadOnly) {
35
38
  this._value = val;
36
39
  }
37
40
  }
@@ -83,6 +86,8 @@ export class Property {
83
86
  // ── Hatch ─────────────────────────────────────────────────────────────────
84
87
  CHILDENTITIES: 'childEntities',
85
88
  PATTERNNAME: 'patternName',
89
+ HATCHSPACING: 'hatchSpacing',
90
+ HATCHDOUBLE: 'hatchDouble',
86
91
  // ── Text ──────────────────────────────────────────────────────────────────
87
92
  BACKWARDS: 'backwards',
88
93
  HORIZONTALALIGNMENT: 'horizontalAlignment',
@@ -151,6 +151,21 @@ export class PropertyManager {
151
151
  return DesignCore.Scene.entities.get(index).properties.definition(property);
152
152
  }
153
153
 
154
+ /**
155
+ * Get the representative entity for a named property from the selection set.
156
+ * Used alongside getEntityPropertyDefinition when readOnly callbacks need an entity.
157
+ * @param {string} entityType
158
+ * @param {string} property
159
+ * @return {Object|undefined}
160
+ */
161
+ getEntityForProperty(entityType, property) {
162
+ const index = DesignCore.Scene.selectionManager.selectionSet.selectionSet.find((i) => {
163
+ const entity = DesignCore.Scene.entities.get(i);
164
+ return (entityType === 'All' || entity.type === entityType) && entity.properties?.has(property);
165
+ });
166
+ return index !== undefined ? DesignCore.Scene.entities.get(index) : undefined;
167
+ }
168
+
154
169
  /**
155
170
  * Get a list the property values
156
171
  * @param {string} entityType
@@ -256,6 +256,8 @@ export class Block extends Entity {
256
256
  * @return {BoundingBox}
257
257
  */
258
258
  boundingBox() {
259
+ if (!this.entities.length) return null;
260
+
259
261
  let xmin = Infinity;
260
262
  let xmax = -Infinity;
261
263
  let ymin = Infinity;
package/cspell.json CHANGED
@@ -141,6 +141,8 @@
141
141
  "EXTMIN",
142
142
  "FILLETRAD",
143
143
  "FONTNAME",
144
+ "HATCHDOUBLE",
145
+ "HATCHSPACING",
144
146
  "groupcode",
145
147
  "groupcodes",
146
148
  "groupvalue",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-core/core",
3
- "version": "50.1.0",
3
+ "version": "51.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
@@ -1,4 +1,5 @@
1
1
  import { Hatch } from '../../core/entities/hatch.js';
2
+ import { PatternLine } from '../../core/lib/patterns.js';
2
3
  import { Point } from '../../core/entities/point.js';
3
4
  import { DesignCore } from '../../core/designCore.js';
4
5
 
@@ -10,6 +11,8 @@ import { Arc } from '../../core/entities/arc.js';
10
11
  import { Text } from '../../core/entities/text.js';
11
12
 
12
13
  import { File, withMockInput } from '../test-helpers/test-helpers.js';
14
+ import { Logging } from '../../core/lib/logging.js';
15
+ import { jest } from '@jest/globals';
13
16
 
14
17
  import { Core } from '../../core/core/core.js';
15
18
 
@@ -1066,6 +1069,40 @@ test('Hatch.draw HONEY dashed family sets lineDashOffset and strokes once per se
1066
1069
  expect(ctx.getStrokeCalls()).toBe(expectedSegs);
1067
1070
  });
1068
1071
 
1072
+ // ── unknown pattern ───────────────────────────────────────────────────────────
1073
+
1074
+ describe('unknown hatch pattern', () => {
1075
+ test('logs a warning when pattern name is not recognised', () => {
1076
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1077
+ try {
1078
+ new Hatch({ patternName: 'NOTAPATTERN' });
1079
+ expect(warnSpy).toHaveBeenCalledWith('Hatch: pattern \'NOTAPATTERN\' not found');
1080
+ } finally {
1081
+ warnSpy.mockRestore();
1082
+ }
1083
+ });
1084
+
1085
+ test('retains the original pattern name when pattern is not recognised', () => {
1086
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1087
+ try {
1088
+ const h = new Hatch({ patternName: 'NOTAPATTERN' });
1089
+ expect(h.getProperty('patternName')).toBe('NOTAPATTERN');
1090
+ } finally {
1091
+ warnSpy.mockRestore();
1092
+ }
1093
+ });
1094
+
1095
+ test('does not log a warning for a known pattern', () => {
1096
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1097
+ try {
1098
+ new Hatch({ patternName: 'ANSI31' });
1099
+ expect(warnSpy).not.toHaveBeenCalled();
1100
+ } finally {
1101
+ warnSpy.mockRestore();
1102
+ }
1103
+ });
1104
+ });
1105
+
1069
1106
  // ── processSelection: uncloseable items ──────────────────────────────────────
1070
1107
 
1071
1108
  test('Hatch.processSelection returns empty array when items cannot form a closed loop', () => {
@@ -1077,3 +1114,289 @@ test('Hatch.processSelection returns empty array when items cannot form a closed
1077
1114
  const h = new Hatch();
1078
1115
  expect(h.processSelection(disconnected)).toEqual([]);
1079
1116
  });
1117
+
1118
+ // ── patternLines: library (76=1 default) ─────────────────────────────────────
1119
+
1120
+ test('Hatch.patternLines is non-enumerable', () => {
1121
+ const h = new Hatch({ patternName: 'ANSI31' });
1122
+ const descriptor = Object.getOwnPropertyDescriptor(h, 'patternLines');
1123
+ expect(descriptor.enumerable).toBe(false);
1124
+ });
1125
+
1126
+ test('Hatch.patternLines populated from library for known predefined pattern', () => {
1127
+ const h = new Hatch({ patternName: 'ANSI31' });
1128
+ expect(h.patternLines.length).toBeGreaterThan(0);
1129
+ expect(h.patternLines[0]).toBeInstanceOf(PatternLine);
1130
+ });
1131
+
1132
+ test('Hatch.patternLines is empty for unknown pattern', () => {
1133
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1134
+ try {
1135
+ const h = new Hatch({ patternName: 'NOTAPATTERN' });
1136
+ expect(h.patternLines).toEqual([]);
1137
+ } finally {
1138
+ warnSpy.mockRestore();
1139
+ }
1140
+ });
1141
+
1142
+ test('Hatch.patternLines is empty for SOLID pattern', () => {
1143
+ const h = new Hatch({ patternName: 'SOLID' });
1144
+ expect(h.patternLines).toEqual([]);
1145
+ });
1146
+
1147
+ test('Hatch.patternLines rebuilt when patternName changes via setProperty', () => {
1148
+ const h = new Hatch({ patternName: 'ANSI31' });
1149
+ const originalLength = h.patternLines.length;
1150
+ expect(originalLength).toBeGreaterThan(0);
1151
+
1152
+ h.setProperty('patternName', 'HONEY');
1153
+ expect(h.patternLines.length).toBeGreaterThan(0);
1154
+ // HONEY has 3 families; ANSI31 has 1
1155
+ expect(h.patternLines.length).toBeGreaterThan(originalLength);
1156
+ });
1157
+
1158
+ test('Hatch.patternLines becomes empty when patternName set to unknown', () => {
1159
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1160
+ try {
1161
+ const h = new Hatch({ patternName: 'ANSI31' });
1162
+ h.setProperty('patternName', 'NOTAPATTERN');
1163
+ expect(h.patternLines).toEqual([]);
1164
+ } finally {
1165
+ warnSpy.mockRestore();
1166
+ }
1167
+ });
1168
+
1169
+ // ── patternLines: user-defined inline (76=0) ─────────────────────────────────
1170
+
1171
+ test('Hatch with 76=0 uses inline pattern lines, not library', () => {
1172
+ const data = {
1173
+ '76': 0,
1174
+ '41': 1,
1175
+ '52': 0,
1176
+ '78': 1,
1177
+ '53': 0,
1178
+ '45': 0,
1179
+ '46': 10, // dy delta (10 inches)
1180
+ '79': 0,
1181
+ };
1182
+ const h = new Hatch(data);
1183
+ expect(h.patternLines.length).toBe(1);
1184
+ expect(h.patternLines[0]).toBeInstanceOf(PatternLine);
1185
+ expect(h.patternLines[0].angle).toBe(0);
1186
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5); // 10 (drawing units)
1187
+ expect(h.patternLines[0].dashes).toEqual([]);
1188
+ });
1189
+
1190
+ test('Hatch with 76=0 correctly scales delta by dividing out the scale factor', () => {
1191
+ // scale=2: DXF stores delta pre-multiplied, so raw dy=20 → 20/2=10 (drawing units)
1192
+ const data = {
1193
+ '76': 0,
1194
+ '41': 2,
1195
+ '52': 0,
1196
+ '78': 1,
1197
+ '53': 0,
1198
+ '45': 0,
1199
+ '46': 20,
1200
+ '79': 0,
1201
+ };
1202
+ const h = new Hatch(data);
1203
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1204
+ });
1205
+
1206
+ test('Hatch with 76=0 and 77=1 (double flag) adds a perpendicular family', () => {
1207
+ const data = {
1208
+ '76': 0,
1209
+ '77': 1,
1210
+ '41': 1,
1211
+ '52': 0,
1212
+ '78': 1,
1213
+ '53': 0,
1214
+ '45': 0,
1215
+ '46': 10,
1216
+ '79': 0,
1217
+ };
1218
+ const h = new Hatch(data);
1219
+ expect(h.patternLines.length).toBe(2);
1220
+ expect(h.patternLines[1].angle).toBe(90);
1221
+ expect(h.patternLines[1].yDelta).toBeCloseTo(h.patternLines[0].yDelta, 5);
1222
+ });
1223
+
1224
+ test('Hatch with 76=0 and dashes parses and converts to mm', () => {
1225
+ // [5, -5] → [5, -5] (drawing units)
1226
+ const data = {
1227
+ '76': 0,
1228
+ '41': 1,
1229
+ '52': 0,
1230
+ '78': 1,
1231
+ '53': 0,
1232
+ '45': 0,
1233
+ '46': 10,
1234
+ '79': 2,
1235
+ '49': [5, -5],
1236
+ };
1237
+ const h = new Hatch(data);
1238
+ expect(h.patternLines[0].dashes.length).toBe(2);
1239
+ expect(h.patternLines[0].dashes[0]).toBeCloseTo(5, 5);
1240
+ expect(h.patternLines[0].dashes[1]).toBeCloseTo(-5, 5);
1241
+ });
1242
+
1243
+ test('Hatch with 76=0 and multiple line families parses each correctly', () => {
1244
+ const data = {
1245
+ '76': 0,
1246
+ '41': 1,
1247
+ '52': 0,
1248
+ '78': 2,
1249
+ '53': [0, 90],
1250
+ '45': [0, 0],
1251
+ '46': [10, 10],
1252
+ '79': [0, 0],
1253
+ };
1254
+ const h = new Hatch(data);
1255
+ expect(h.patternLines.length).toBe(2);
1256
+ expect(h.patternLines[0].angle).toBe(0);
1257
+ expect(h.patternLines[1].angle).toBe(90);
1258
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1259
+ expect(h.patternLines[1].yDelta).toBeCloseTo(10, 5);
1260
+ });
1261
+
1262
+ test('Hatch with 76=0 and no inline families synthesises from spacing (47)', () => {
1263
+ const data = { '76': 0, '41': 1, '52': 45, '47': 5 };
1264
+ const h = new Hatch(data);
1265
+ expect(h.patternLines.length).toBe(1);
1266
+ expect(h.patternLines[0].angle).toBe(0);
1267
+ expect(h.patternLines[0].yDelta).toBeCloseTo(5, 5);
1268
+ expect(h.patternLines[0].dashes).toEqual([]);
1269
+ });
1270
+
1271
+ test('Hatch with 76=0 explicit families: data[53] absolute angle is stored relative to entity angle', () => {
1272
+ // AutoCAD writes data[53]=45 as the absolute line angle when entity angle (52) is 45.
1273
+ // Subtracting entityAngle makes it 0 so buildPatternCache's addition yields the correct 45°.
1274
+ const data = { '76': 0, '41': 1, '52': 45, '78': 1, '53': 45, '45': 0, '46': 10, '79': 0 };
1275
+ const h = new Hatch(data);
1276
+ expect(h.patternLines[0].angle).toBeCloseTo(0, 5); // 45 - 45 = 0 (relative)
1277
+ });
1278
+
1279
+ test('Hatch with 76=0, no inline families, double flag (77=1) synthesises two families', () => {
1280
+ const data = { '76': 0, '77': 1, '41': 1, '52': 0, '47': 5 };
1281
+ const h = new Hatch(data);
1282
+ expect(h.patternLines.length).toBe(2);
1283
+ expect(h.patternLines[0].angle).toBe(0);
1284
+ expect(h.patternLines[1].angle).toBe(90);
1285
+ expect(h.patternLines[1].yDelta).toBeCloseTo(5, 5);
1286
+ });
1287
+
1288
+ // ── patternLines: custom embedded (76=2) ─────────────────────────────────────
1289
+
1290
+ test('Hatch with 76=2 uses inline pattern lines', () => {
1291
+ const data = {
1292
+ '76': 2,
1293
+ '41': 1,
1294
+ '52': 0,
1295
+ '78': 1,
1296
+ '53': 45,
1297
+ '45': 0,
1298
+ '46': 10,
1299
+ '79': 0,
1300
+ };
1301
+ const h = new Hatch(data);
1302
+ expect(h.patternLines.length).toBe(1);
1303
+ expect(h.patternLines[0].angle).toBe(45);
1304
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1305
+ });
1306
+
1307
+ // ── dxf() output: 76 group code ──────────────────────────────────────────────
1308
+
1309
+ test('Hatch.dxf writes 76=1 for library patterns', () => {
1310
+ const h = new Hatch({ patternName: 'ANSI31', handle: '1' });
1311
+ h.setProperty('childEntities', makeSquare());
1312
+ const file = new File();
1313
+ h.dxf(file);
1314
+ const match = file.contents.match(/^76\n(\d+)/m);
1315
+ expect(match).not.toBeNull();
1316
+ expect(match[1]).toBe('1');
1317
+ });
1318
+
1319
+ test('Hatch.dxf writes 76=0 for user-defined patterns', () => {
1320
+ const data = {
1321
+ '76': 0,
1322
+ '41': 1,
1323
+ '52': 0,
1324
+ '78': 1,
1325
+ '53': 0,
1326
+ '45': 0,
1327
+ '46': 10,
1328
+ '79': 0,
1329
+ 'handle': '1',
1330
+ };
1331
+ const h = new Hatch(data);
1332
+ h.setProperty('childEntities', makeSquare());
1333
+ const file = new File();
1334
+ h.dxf(file);
1335
+ const match = file.contents.match(/^76\n(\d+)/m);
1336
+ expect(match).not.toBeNull();
1337
+ expect(match[1]).toBe('0');
1338
+ });
1339
+
1340
+ test('Hatch.dxf 76=0 double-flag round-trip writes correct family count', () => {
1341
+ const data = {
1342
+ '76': 0,
1343
+ '77': 1,
1344
+ '41': 1,
1345
+ '52': 0,
1346
+ '78': 1,
1347
+ '53': 0,
1348
+ '45': 0,
1349
+ '46': 10,
1350
+ '79': 0,
1351
+ 'handle': '1',
1352
+ };
1353
+ const h = new Hatch(data);
1354
+ h.setProperty('childEntities', makeSquare());
1355
+ const file = new File();
1356
+ h.dxf(file);
1357
+ const match = file.contents.match(/^78\n(\d+)/m);
1358
+ expect(match).not.toBeNull();
1359
+ expect(match[1]).toBe('2');
1360
+ });
1361
+
1362
+ // ── buildPatternCache with inline patterns ────────────────────────────────────
1363
+
1364
+ test('Hatch with 76=0 inline pattern renders segments inside boundary', () => {
1365
+ const data = {
1366
+ '76': 0,
1367
+ '41': 1,
1368
+ '52': 0,
1369
+ '78': 1,
1370
+ '53': 0,
1371
+ '45': 0,
1372
+ '46': 1, // 1 (drawing unit) spacing
1373
+ '79': 0,
1374
+ };
1375
+ const h = new Hatch(data);
1376
+ h.setProperty('childEntities', makeSquare());
1377
+ h.buildPatternCache();
1378
+
1379
+ expect(h.cachedPattern.length).toBe(1);
1380
+ expect(h.cachedPattern[0].segments.length).toBeGreaterThan(0);
1381
+ });
1382
+
1383
+ test('Hatch with 76=0 double flag renders two families of segments', () => {
1384
+ const data = {
1385
+ '76': 0,
1386
+ '77': 1,
1387
+ '41': 1,
1388
+ '52': 0,
1389
+ '78': 1,
1390
+ '53': 0,
1391
+ '45': 0,
1392
+ '46': 1,
1393
+ '79': 0,
1394
+ };
1395
+ const h = new Hatch(data);
1396
+ h.setProperty('childEntities', makeSquare());
1397
+ h.buildPatternCache();
1398
+
1399
+ expect(h.cachedPattern.length).toBe(2);
1400
+ expect(h.cachedPattern[0].segments.length).toBeGreaterThan(0);
1401
+ expect(h.cachedPattern[1].segments.length).toBeGreaterThan(0);
1402
+ });
@@ -4,6 +4,8 @@ import { Point } from '../../core/entities/point.js';
4
4
  import { DesignCore } from '../../core/designCore.js';
5
5
  import { Logging } from '../../core/lib/logging.js';
6
6
  import { jest } from '@jest/globals';
7
+ import { Polyline } from '../../core/entities/polyline.js';
8
+ import { Text } from '../../core/entities/text.js';
7
9
 
8
10
  // Initialize core (sets DesignCore.Core)
9
11
  new Core();
@@ -67,4 +69,75 @@ describe('Clipboard', () => {
67
69
  expect(fresh.Entities).toHaveLength(0);
68
70
  errorSpy.mockRestore();
69
71
  });
72
+
73
+ describe('stringify/parse flag survival', () => {
74
+ test('closed Polyline retains closed flag after stringify/parse roundtrip', () => {
75
+ // Create a closed polyline (flags bit 1 = closed)
76
+ const poly = new Polyline({
77
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)],
78
+ flags: 1,
79
+ });
80
+ expect(poly.getProperty('closed')).toBe(true);
81
+
82
+ DesignCore.Clipboard.Entities = [poly];
83
+ const json = DesignCore.Clipboard.stringify();
84
+
85
+ const fresh = new Clipboard();
86
+ fresh.parse(json);
87
+
88
+ expect(fresh.Entities).toHaveLength(1);
89
+ expect(fresh.Entities[0].getProperty('closed')).toBe(true);
90
+ });
91
+
92
+ test('closed Polyline retains correct point count after stringify/parse roundtrip', () => {
93
+ // A closed polyline stores 4 points (not 5 — the closing duplicate is removed)
94
+ const poly = new Polyline({
95
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)],
96
+ flags: 1,
97
+ });
98
+ expect(poly.points).toHaveLength(4);
99
+
100
+ DesignCore.Clipboard.Entities = [poly];
101
+ const json = DesignCore.Clipboard.stringify();
102
+
103
+ const fresh = new Clipboard();
104
+ fresh.parse(json);
105
+
106
+ expect(fresh.Entities[0].points).toHaveLength(4);
107
+ });
108
+
109
+ test('backwards (mirrored) Text retains backwards flag after stringify/parse roundtrip', () => {
110
+ // Text flags bit 2 = backwards
111
+ const text = new Text({
112
+ points: [new Point(0, 0), new Point(10, 0)],
113
+ textValue: 'hello',
114
+ flags: 2,
115
+ });
116
+ expect(text.getBackwards()).toBe(true);
117
+
118
+ DesignCore.Clipboard.Entities = [text];
119
+ const json = DesignCore.Clipboard.stringify();
120
+
121
+ const fresh = new Clipboard();
122
+ fresh.parse(json);
123
+
124
+ expect(fresh.Entities).toHaveLength(1);
125
+ expect(fresh.Entities[0].getBackwards()).toBe(true);
126
+ });
127
+
128
+ test('non-flagged entities are unaffected', () => {
129
+ const poly = new Polyline({
130
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10)],
131
+ });
132
+ expect(poly.getProperty('closed')).toBe(false);
133
+
134
+ DesignCore.Clipboard.Entities = [poly];
135
+ const json = DesignCore.Clipboard.stringify();
136
+
137
+ const fresh = new Clipboard();
138
+ fresh.parse(json);
139
+
140
+ expect(fresh.Entities[0].getProperty('closed')).toBe(false);
141
+ });
142
+ });
70
143
  });
@@ -162,3 +162,27 @@ test('Test flags.removeValue', () => {
162
162
  // 2 shouldn't exist again
163
163
  expect(flags.hasFlag(2)).toBe(false);
164
164
  });
165
+
166
+ test('Test flags.toJSON returns the numeric flag value', () => {
167
+ const flags = new Flags();
168
+ expect(flags.toJSON()).toBe(0);
169
+
170
+ flags.setFlagValue(7);
171
+ expect(flags.toJSON()).toBe(7);
172
+ });
173
+
174
+ test('Test flags.toJSON round-trips through JSON.stringify/parse', () => {
175
+ const flags = new Flags();
176
+ flags.setFlagValue(1);
177
+
178
+ // JSON.stringify should produce the plain number, not a nested object
179
+ const json = JSON.stringify({ flags });
180
+ expect(json).toBe('{"flags":1}');
181
+
182
+ // The parsed value is a plain number — Flags constructor accepts it via setFlagValue
183
+ const parsed = JSON.parse(json);
184
+ const restored = new Flags();
185
+ restored.setFlagValue(parsed.flags);
186
+ expect(restored.getFlagValue()).toBe(1);
187
+ expect(restored.hasFlag(1)).toBe(true);
188
+ });
@@ -113,3 +113,74 @@ test('Test propertyManager.getEntityPropertyValue', () => {
113
113
  propertyValues = propertiesManager.getEntityPropertyValue('All', 'colour');
114
114
  expect(propertyValues).toBe('Varies');
115
115
  });
116
+
117
+ test('Test propertyManager.getEntityPropertyDefinition', () => {
118
+ // clear the selection set
119
+ DesignCore.Scene.reset();
120
+
121
+ // returns undefined when nothing is selected
122
+ let def = propertiesManager.getEntityPropertyDefinition('All', 'colour');
123
+ expect(def).toBeUndefined();
124
+
125
+ // select the line entity
126
+ DesignCore.Scene.selectionManager.selectionSet.selectionSet.push(0);
127
+
128
+ // returns a Property descriptor for a valid property
129
+ def = propertiesManager.getEntityPropertyDefinition('All', 'colour');
130
+ expect(def).toBeDefined();
131
+ expect(typeof def.type).toBe('string');
132
+
133
+ // returns undefined for a property the entity does not have
134
+ def = propertiesManager.getEntityPropertyDefinition('All', 'non-existent-prop');
135
+ expect(def).toBeUndefined();
136
+
137
+ // filters by entityType — Circle not selected, so returns undefined
138
+ def = propertiesManager.getEntityPropertyDefinition('Circle', 'colour');
139
+ expect(def).toBeUndefined();
140
+ });
141
+
142
+ test('Test propertyManager.getEntityForProperty', () => {
143
+ // clear the selection set
144
+ DesignCore.Scene.reset();
145
+
146
+ // returns undefined when nothing is selected
147
+ let entity = propertiesManager.getEntityForProperty('All', 'colour');
148
+ expect(entity).toBeUndefined();
149
+
150
+ // select the line (index 0) and circle (index 1)
151
+ DesignCore.Scene.selectionManager.selectionSet.selectionSet.push(0);
152
+ DesignCore.Scene.selectionManager.selectionSet.selectionSet.push(1);
153
+
154
+ // returns an entity for 'All' and a known property
155
+ entity = propertiesManager.getEntityForProperty('All', 'colour');
156
+ expect(entity).toBeDefined();
157
+ expect(entity.type === 'Line' || entity.type === 'Circle').toBe(true);
158
+
159
+ // filters by entityType — only Circle entities
160
+ entity = propertiesManager.getEntityForProperty('Circle', 'colour');
161
+ expect(entity).toBeDefined();
162
+ expect(entity.type).toBe('Circle');
163
+
164
+ // returns undefined when the property does not exist on any selected entity
165
+ entity = propertiesManager.getEntityForProperty('All', 'non-existent-prop');
166
+ expect(entity).toBeUndefined();
167
+ });
168
+
169
+ test('Test propertyManager.setPropertyCallbackFunction and selectionSetChanged', () => {
170
+ let callCount = 0;
171
+ const callback = () => {
172
+ callCount++;
173
+ };
174
+
175
+ // selectionSetChanged with no callback set should not throw
176
+ propertiesManager.selectionSetChanged();
177
+
178
+ propertiesManager.setPropertyCallbackFunction(callback);
179
+
180
+ // callback is invoked when selectionSetChanged is called
181
+ propertiesManager.selectionSetChanged();
182
+ expect(callCount).toBe(1);
183
+
184
+ propertiesManager.selectionSetChanged();
185
+ expect(callCount).toBe(2);
186
+ });
@@ -181,40 +181,38 @@ test('Test Canvas.zoomToWindow - reversed points', () => {
181
181
  expect(finalScale).not.toBe(initialScale);
182
182
  });
183
183
 
184
- test('Test Canvas.zoomToWindow - zero width (should not zoom)', () => {
184
+ test('Test Canvas.zoomToWindow - zero width (zooms using height)', () => {
185
185
  core.canvas.width = 800;
186
186
  core.canvas.height = 600;
187
187
  core.canvas.matrix.scale(1, 1);
188
188
 
189
- const initialScale = core.canvas.getScale();
190
-
191
- // Points with same x coordinate (zero width)
189
+ // Points with same x coordinate (zero width, i.e. a vertical line)
192
190
  const pt1 = new Point(50, 0);
193
191
  const pt2 = new Point(50, 100);
194
192
 
195
193
  core.canvas.zoomToWindow(pt1, pt2);
196
194
 
197
- // Should not change scale
195
+ // Resulting scale depends only on the window size, not the prior scale,
196
+ // so assert the expected value rather than comparing against initialScale
198
197
  const finalScale = core.canvas.getScale();
199
- expect(finalScale).toBe(initialScale);
198
+ expect(finalScale).toBeCloseTo((core.canvas.height / 100) * 0.9);
200
199
  });
201
200
 
202
- test('Test Canvas.zoomToWindow - zero height (should not zoom)', () => {
201
+ test('Test Canvas.zoomToWindow - zero height (zooms using width)', () => {
203
202
  core.canvas.width = 800;
204
203
  core.canvas.height = 600;
205
204
  core.canvas.matrix.scale(1, 1);
206
205
 
207
- const initialScale = core.canvas.getScale();
208
-
209
- // Points with same y coordinate (zero height)
206
+ // Points with same y coordinate (zero height, i.e. a horizontal line)
210
207
  const pt1 = new Point(0, 50);
211
208
  const pt2 = new Point(100, 50);
212
209
 
213
210
  core.canvas.zoomToWindow(pt1, pt2);
214
211
 
215
- // Should not change scale
212
+ // Resulting scale depends only on the window size, not the prior scale,
213
+ // so assert the expected value rather than comparing against initialScale
216
214
  const finalScale = core.canvas.getScale();
217
- expect(finalScale).toBe(initialScale);
215
+ expect(finalScale).toBeCloseTo((core.canvas.width / 100) * 0.9);
218
216
  });
219
217
 
220
218
  test('Test Canvas.zoomToWindow - same point (should not zoom)', () => {