@design-core/core 50.1.0 → 51.2.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.
@@ -0,0 +1,40 @@
1
+ # Publishes @design-core/core to npm when a semver tag (e.g. v51.1.0) is pushed.
2
+ # Requires an NPM_TOKEN repository secret (an "Automation" type npm access token).
3
+ name: Publish to npm
4
+
5
+ on:
6
+ push:
7
+ tags:
8
+ - 'v*.*.*'
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - uses: actions/checkout@v3
16
+
17
+ - name: Use Node.js
18
+ uses: actions/setup-node@v3
19
+ with:
20
+ node-version: 18.x
21
+ registry-url: 'https://registry.npmjs.org'
22
+
23
+ - run: npm ci
24
+ - run: npm test -- --coverage
25
+ - run: npx eslint . --ext .js
26
+
27
+ # Fail fast if the tag and package.json version have drifted apart
28
+ - name: Verify tag matches package.json version
29
+ run: |
30
+ PKG_VERSION=$(node -p "require('./package.json').version")
31
+ TAG_VERSION=${GITHUB_REF_NAME#v}
32
+ if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
33
+ echo "Tag v$TAG_VERSION does not match package.json version $PKG_VERSION"
34
+ exit 1
35
+ fi
36
+
37
+ - name: Publish to npm
38
+ run: npm publish --access public
39
+ env:
40
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -14,10 +14,23 @@ Design-Core uses the industry standard Drawing Exchange Format (DXF) to read and
14
14
  * Design-Web: https://github.com/dubstar-04/Design-Web
15
15
  * HTML Canvas: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
16
16
  * Cairo: https://www.cairographics.org/
17
+ * npm package: https://www.npmjs.com/package/@design-core/core
17
18
 
18
19
 
19
20
  ## Publishing
20
21
 
21
- npm login
22
+ Releases are published to npm automatically by the [Publish to npm](.github/workflows/publish.yml) workflow whenever a `v*.*.*` tag is pushed:
23
+
24
+ ```bash
25
+ npm version patch # or minor / major, bumps package.json and creates a git tag
26
+ git push --follow-tags
27
+ ```
28
+
29
+ The workflow runs the test suite and linter, verifies the tag matches `package.json`'s version, then publishes.
22
30
 
31
+ To publish manually instead:
32
+
33
+ ```bash
34
+ npm login
23
35
  npm publish --access public
36
+ ```
@@ -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);
@@ -300,7 +305,9 @@ export class Canvas {
300
305
  */
301
306
  exportTo(renderer, plotOptions) {
302
307
  let area;
303
- if (plotOptions.plotArea === PlotOptions.Area.DISPLAY) {
308
+ if (plotOptions.plotArea === PlotOptions.Area.WINDOW) {
309
+ area = new BoundingBox(plotOptions.windowArea.point1, plotOptions.windowArea.point2);
310
+ } else if (plotOptions.plotArea === PlotOptions.Area.DISPLAY) {
304
311
  const viewport = this.getSceneOffset();
305
312
  area = new BoundingBox(new Point(viewport.xmin, viewport.ymin), new Point(viewport.xmax, viewport.ymax));
306
313
  } else {
@@ -313,6 +320,7 @@ export class Canvas {
313
320
  pageWidth: plotOptions.pageWidth,
314
321
  pageHeight: plotOptions.pageHeight,
315
322
  plotScale: plotOptions.plotScale,
323
+ margin: plotOptions.margin,
316
324
  });
317
325
  if (!matrix) return false;
318
326
 
@@ -43,6 +43,9 @@ import { Purge } from '../tools/purge.js';
43
43
  import { Pan } from '../tools/pan.js';
44
44
  import { Zoom } from '../tools/zoom.js';
45
45
 
46
+ // Internal tools (not shown in any toolbar, invoked programmatically)
47
+ import { WindowPick } from '../tools/windowPick.js';
48
+
46
49
  // Clipboard tools
47
50
  import { Copybase } from '../tools/copybase.js';
48
51
  import { Copyclip } from '../tools/copyclip.js';
@@ -99,6 +102,8 @@ const classes = {
99
102
  Zoom,
100
103
  Pan,
101
104
 
105
+ WindowPick,
106
+
102
107
  Copybase,
103
108
  Copyclip,
104
109
  Cutclip,
@@ -39,6 +39,7 @@ export class InputManager {
39
39
  }
40
40
 
41
41
  this.promptOption = undefined;
42
+ DesignCore.Mouse.reset();
42
43
  DesignCore.Scene.reset();
43
44
  DesignCore.Canvas.setCursorForInputTypes([]);
44
45
  }
package/core/lib/mouse.js CHANGED
@@ -15,6 +15,16 @@ export class Mouse {
15
15
  this.lastButton = 0; // last button for double click check
16
16
  }
17
17
 
18
+ /**
19
+ * Clear button state - guards against a stuck button when a mouseUp
20
+ * never reaches the canvas (e.g. focus stolen by another window mid-click)
21
+ */
22
+ reset() {
23
+ this.buttonOneDown = false;
24
+ this.buttonTwoDown = false;
25
+ this.buttonThreeDown = false;
26
+ }
27
+
18
28
  /**
19
29
  * Returns the point on the design canvas
20
30
  * @return {Point}
@@ -9,6 +9,7 @@ export class PlotOptions {
9
9
  static Area = {
10
10
  EXTENTS: 'Extents',
11
11
  DISPLAY: 'Display',
12
+ WINDOW: 'Window',
12
13
  };
13
14
 
14
15
  static #fileTypes = ['pdf', 'svg'];
@@ -22,6 +23,8 @@ export class PlotOptions {
22
23
  this.setOption('pageHeight', pageHeight);
23
24
  this.plotScale = null;
24
25
  this.plotArea = PlotOptions.Area.EXTENTS;
26
+ this.windowArea = null;
27
+ this.margin = 40;
25
28
  this.style = RendererBase.Styles.NONE;
26
29
  this.fileType = 'pdf';
27
30
  }
@@ -49,6 +52,20 @@ export class PlotOptions {
49
52
  throw new Error(`PlotOptions: plotArea must be one of: ${Object.values(PlotOptions.Area).join(', ')}`);
50
53
  }
51
54
  break;
55
+ case 'windowArea':
56
+ // required when plotArea is WINDOW - two points defining the plot window, e.g. { point1, point2 }
57
+ if (value !== null && (!value || value.point1 === undefined || value.point2 === undefined)) {
58
+ throw new Error('PlotOptions: windowArea must be null or an object with point1 and point2');
59
+ }
60
+ break;
61
+ case 'margin':
62
+ if (!Number.isFinite(value) || value < 0) {
63
+ throw new Error('PlotOptions: margin must be a non-negative finite number');
64
+ }
65
+ if (this.pageWidth - 2 * value <= 0 || this.pageHeight - 2 * value <= 0) {
66
+ throw new Error('PlotOptions: margin leaves no usable page area');
67
+ }
68
+ break;
52
69
  case 'style':
53
70
  if (typeof value !== 'function') {
54
71
  throw new Error('PlotOptions: style must be a function');
@@ -110,6 +110,8 @@ export class Strings {
110
110
  DESTINATIONSET: 'Select destination object(s)',
111
111
  MIRRORFIRST: 'Specify first point of mirror line',
112
112
  MIRRORSECOND: 'Specify second point of mirror line',
113
+ FIRSTCORNER: 'Specify first corner',
114
+ SECONDCORNER: 'Specify second corner',
113
115
  ERASESOURCE: 'Erase source objects?',
114
116
  SCALEFACTOR: 'Specify scale factor',
115
117
  REFERENCELENGTH: 'Specify reference length',
@@ -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;
@@ -0,0 +1,75 @@
1
+ import { Tool } from './tool.js';
2
+ import { Input, PromptOptions } from '../lib/inputManager.js';
3
+ import { Strings } from '../lib/strings.js';
4
+ import { Logging } from '../lib/logging.js';
5
+ import { Point } from '../entities/point.js';
6
+
7
+ import { DesignCore } from '../designCore.js';
8
+
9
+ /**
10
+ * WindowPick Command Class
11
+ * Internal tool used to interactively pick two corner points defining a rectangular window (e.g. for the plot window area).
12
+ * Not shown in any toolbar; invoked programmatically.
13
+ * @extends Tool
14
+ */
15
+ export class WindowPick extends Tool {
16
+ static type = 'WindowPick';
17
+
18
+ /** Create a WindowPick command */
19
+ constructor() {
20
+ super();
21
+ }
22
+
23
+ /**
24
+ * Register the command
25
+ * @return {Object}
26
+ * command = name of the command
27
+ */
28
+ static register() {
29
+ const command = { command: 'WindowPick' };
30
+ return command;
31
+ }
32
+
33
+ /**
34
+ * Execute method
35
+ * requests the two corner points defining the plot window
36
+ */
37
+ async execute() {
38
+ try {
39
+ const op1 = new PromptOptions(Strings.Input.FIRSTCORNER, [Input.Type.POINT]);
40
+ const pt1 = await DesignCore.Scene.inputManager.requestInput(op1);
41
+ if (pt1 === undefined) return;
42
+ this.points.push(pt1);
43
+
44
+ const op2 = new PromptOptions(Strings.Input.SECONDCORNER, [Input.Type.POINT]);
45
+ const pt2 = await DesignCore.Scene.inputManager.requestInput(op2);
46
+ if (pt2 === undefined) return;
47
+ this.points.push(pt2);
48
+
49
+ DesignCore.Scene.inputManager.executeCommand();
50
+ } catch (err) {
51
+ Logging.instance.error(`${this.type} - ${err}`);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Preview the command during execution
57
+ */
58
+ preview() {
59
+ if (this.points.length === 1) {
60
+ const mousePoint = DesignCore.Mouse.pointOnScene();
61
+ const p1 = this.points[0];
62
+ const p2 = mousePoint;
63
+
64
+ const points = [
65
+ new Point(p1.x, p1.y),
66
+ new Point(p2.x, p1.y),
67
+ new Point(p2.x, p2.y),
68
+ new Point(p1.x, p2.y),
69
+ new Point(p1.x, p1.y),
70
+ ];
71
+
72
+ DesignCore.Scene.previewEntities.create('Polyline', { points: points });
73
+ }
74
+ }
75
+ }