@design-core/core 51.2.0 → 52.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.
Files changed (51) hide show
  1. package/core/dimensions/alignedDimension.js +3 -1
  2. package/core/dimensions/angularDimension.js +118 -36
  3. package/core/dimensions/circularDimensionBase.js +32 -12
  4. package/core/dimensions/diametricDimension.js +29 -1
  5. package/core/dimensions/dimension.js +58 -5
  6. package/core/dimensions/dimensionBase.js +107 -4
  7. package/core/dimensions/linearDimensionBase.js +75 -37
  8. package/core/dimensions/radialDimension.js +26 -1
  9. package/core/dimensions/rotatedDimension.js +6 -3
  10. package/core/entities/entity.js +9 -0
  11. package/core/lib/auxiliary/snapPoint.js +9 -0
  12. package/core/lib/canvas.js +22 -0
  13. package/core/lib/commandManager.js +4 -4
  14. package/core/lib/dxf/dxf.js +8 -2
  15. package/core/lib/inputManager.js +23 -3
  16. package/core/lib/renderers/pdfRenderer.js +17 -1
  17. package/core/lib/scene.js +2 -0
  18. package/core/lib/selectionManager.js +28 -0
  19. package/core/lib/strings.js +1 -0
  20. package/core/properties/dimType.js +14 -0
  21. package/core/properties/property.js +1 -0
  22. package/core/tables/dimStyle.js +32 -2
  23. package/core/tables/dimStyleManager.js +2 -0
  24. package/core/tables/styleManagerBase.js +17 -3
  25. package/core/tables/tableManagerBase.js +24 -2
  26. package/core/tools/gripStretch.js +97 -0
  27. package/core/tools/offset.js +7 -1
  28. package/core/tools/tool.js +10 -0
  29. package/cspell.json +3 -0
  30. package/package.json +1 -1
  31. package/test/dimensions/alignedDimension.test.js +165 -0
  32. package/test/dimensions/angularDimension.test.js +149 -0
  33. package/test/dimensions/diametricDimension.test.js +77 -4
  34. package/test/dimensions/dimension.test.js +130 -0
  35. package/test/dimensions/dimensionBase.test.js +97 -4
  36. package/test/dimensions/radialDimension.test.js +34 -0
  37. package/test/dimensions/rotatedDimension.test.js +80 -0
  38. package/test/integration/exportIntegration.reference.dxf +3 -9
  39. package/test/integration/exportIntegration.test.js +1 -0
  40. package/test/integration/userInputIntegration.reference.dxf +3 -9
  41. package/test/integration/userInputIntegration.test.js +1 -0
  42. package/test/lib/inputManager.test.js +316 -1
  43. package/test/lib/selectionManager.test.js +42 -0
  44. package/test/renderers/pdfRenderer.test.js +26 -0
  45. package/test/tables/dimStyleManager.test.js +90 -0
  46. package/test/tables/dimstyle.test.js +32 -0
  47. package/test/tables/tableManagerBase.test.js +167 -0
  48. package/test/tools/gripStretch.test.js +108 -0
  49. package/test/tools/offset.test.js +2 -0
  50. package/test/tools/purge.test.js +3 -1
  51. package/test/tools/tool.test.js +25 -0
@@ -307,9 +307,24 @@ export class TableManagerBase {
307
307
  * @param {string} newItemName
308
308
  */
309
309
  updateSceneItem(oldItemName, newItemName) {
310
+ const target = typeof oldItemName === 'string' ? oldItemName.toUpperCase() : undefined;
311
+ if (target === undefined) {
312
+ return;
313
+ }
314
+
310
315
  for (let i = 0; i < DesignCore.Scene.entities.count(); i++) {
311
- if (DesignCore.Scene.entities.get(i)[this.itemProperty] === oldItemName) {
312
- DesignCore.Scene.entities.get(i)[this.itemProperty] = newItemName;
316
+ const entity = DesignCore.Scene.entities.get(i);
317
+ const isProperty = entity.properties?.has(this.itemProperty) ?? false;
318
+ const value = isProperty ? entity.getProperty(this.itemProperty) : entity[this.itemProperty];
319
+
320
+ if (typeof value !== 'string' || value.toUpperCase() !== target) {
321
+ continue;
322
+ }
323
+
324
+ if (isProperty) {
325
+ entity.setProperty(this.itemProperty, newItemName);
326
+ } else {
327
+ entity[this.itemProperty] = newItemName;
313
328
  }
314
329
  }
315
330
  }
@@ -344,7 +359,14 @@ export class TableManagerBase {
344
359
  /** Purge unused items */
345
360
  purge() {
346
361
  const itemsToPurge = [];
362
+ // only style managers have a current style
363
+ const currentStyle = this.getCstyle?.()?.toUpperCase();
364
+
347
365
  this.items.forEach((item, index) => {
366
+ if (item.name.toUpperCase() === currentStyle) {
367
+ return;
368
+ }
369
+
348
370
  const searchTerm = this.itemProperty === 'block' ? item : item.name;
349
371
  const items = DesignCore.Scene.entities.find('ANY', this.itemProperty, searchTerm);
350
372
  if (items.length === 0) {
@@ -0,0 +1,97 @@
1
+ import { Tool } from './tool.js';
2
+ import { Strings } from '../lib/strings.js';
3
+ import { Input } from '../lib/input.js';
4
+ import { PromptOptions } from '../lib/promptOptions.js';
5
+ import { Logging } from '../lib/logging.js';
6
+ import { UpdateState } from '../lib/stateManager.js';
7
+
8
+ import { DesignCore } from '../designCore.js';
9
+
10
+ /**
11
+ * GripStretch Command Class
12
+ * Not shown in any toolbar and never started via the command line - only InputManager creates it.
13
+ * @extends Tool
14
+ */
15
+ export class GripStretch extends Tool {
16
+ static type = 'GripStretch';
17
+
18
+ /**
19
+ * Create a GripStretch command
20
+ * @param {number} entityIndex - index of the entity in the selection set that owns the grip
21
+ * @param {Object} grip - { point, apply } descriptor returned by Entity.getGrips()
22
+ */
23
+ constructor(entityIndex, grip) {
24
+ super();
25
+ this.entityIndex = entityIndex;
26
+ this.grip = grip;
27
+ }
28
+
29
+ /**
30
+ * Execute method
31
+ * requests the stretch point, then commits it - keeping the entity selected afterwards
32
+ * (with its grips still visible), unlike most commands which deselect on completion
33
+ */
34
+ async execute() {
35
+ try {
36
+ const op = new PromptOptions(Strings.Input.STRETCHPOINT, [Input.Type.POINT]);
37
+ const newPoint = await DesignCore.Scene.inputManager.requestInput(op);
38
+ if (newPoint === undefined) return;
39
+ this.points.push(newPoint);
40
+
41
+ const inputManager = DesignCore.Scene.inputManager;
42
+ inputManager.actionCommand();
43
+
44
+ // finish without the standard executeCommand()/reset() combo, which would deselect the
45
+ // entity via Scene.reset() - grip editing must leave it selected (grips still visible)
46
+ inputManager.activeCommand = undefined;
47
+ inputManager.promptOption = undefined;
48
+ // reset() normally clears this - without it the next command's first point (or the next
49
+ // grip drag's tracking line) would stay anchored to this drop, not start fresh
50
+ inputManager.inputPoint = null;
51
+ DesignCore.CommandLine.resetPrompt();
52
+ DesignCore.Scene.selectionManager.reloadSelectedEntities();
53
+ DesignCore.Canvas.requestPaint();
54
+ } catch (err) {
55
+ Logging.instance.error(`${this.type} - ${err}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Preview the live grip position while dragging
61
+ */
62
+ preview() {
63
+ const newPoints = this.grip.apply(DesignCore.Mouse.pointOnScene());
64
+ const previewIndex = DesignCore.Scene.selectionManager.selectionSet.selectionSet.indexOf(this.entityIndex);
65
+ if (previewIndex !== -1) {
66
+ DesignCore.Scene.selectionManager.selectedEntities[previewIndex].setProperty('points', newPoints);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Perform the command - commits the grip's new (dropped) position
72
+ */
73
+ action() {
74
+ const entity = DesignCore.Scene.entities.get(this.entityIndex);
75
+ const newPoints = this.grip.apply(this.points[0]);
76
+ const properties = { points: newPoints };
77
+ // all grips currently implemented reposition dimension text - record the flag
78
+ // explicitly so its pre-drag value is captured by the UpdateState undo snapshot too
79
+ if (entity.dimType) {
80
+ properties.userPositionedText = true;
81
+ }
82
+ DesignCore.Scene.commit([new UpdateState(entity, properties)]);
83
+ }
84
+
85
+ /**
86
+ * Cancel the in-progress stretch - unlike Tool's default (full reset), this must not deselect
87
+ * the entity, and must resolve the suspended execute()'s pending requestInput() promise so it
88
+ * doesn't stay forever awaiting a prompt nobody can respond to or cancel again
89
+ */
90
+ cancel() {
91
+ const inputManager = DesignCore.Scene.inputManager;
92
+ inputManager.promptOption?.cancel();
93
+ inputManager.promptOption = undefined;
94
+ inputManager.activeCommand = undefined;
95
+ DesignCore.Scene.selectionManager.reloadSelectedEntities();
96
+ }
97
+ }
@@ -151,7 +151,13 @@ export class Offset extends Tool {
151
151
  const isClosed = polyPts.length >= 3 && polyPts[0].isSame(polyPts.at(-1));
152
152
  const points = isClosed ? polyPts.slice(0, -1) : polyPts;
153
153
 
154
- return this.getOffsetPolylinePoints(points, isClosed, sidePoint, distance);
154
+ const offsetPoints = this.getOffsetPolylinePoints(points, isClosed, sidePoint, distance);
155
+ if (offsetPoints && isClosed) {
156
+ // restore the closing point so closed entities (e.g. Circle) are rebuilt as closed, not as an Arc
157
+ offsetPoints.push(new Point(offsetPoints[0].x, offsetPoints[0].y));
158
+ }
159
+
160
+ return offsetPoints;
155
161
  }
156
162
 
157
163
  /**
@@ -1,3 +1,5 @@
1
+ import { DesignCore } from '../designCore.js';
2
+
1
3
  /**
2
4
  * Tool Class
3
5
  */
@@ -28,5 +30,13 @@ export class Tool {
28
30
  action() {
29
31
  // action function to be overridden by implementation
30
32
  };
33
+
34
+ /**
35
+ * Cancel this command while it is active - overridden by implementations that need to
36
+ * clean up differently to the default full reset (e.g. GripStretch, which must not deselect)
37
+ */
38
+ cancel() {
39
+ DesignCore.Scene.inputManager.reset();
40
+ };
31
41
  }
32
42
 
package/cspell.json CHANGED
@@ -45,6 +45,7 @@
45
45
  "dimstyles",
46
46
  "dimline",
47
47
  "DIMENSIONSTYLE",
48
+ "USERPOSITIONEDTEXT",
48
49
  "DIMADEC",
49
50
  "DIMALI",
50
51
  "DIMALT",
@@ -127,6 +128,7 @@
127
128
  "DIMTSZ",
128
129
  "DIMTVP",
129
130
  "DIMTXSTY",
131
+ "DIMTXSTYISHANDLE",
130
132
  "DIMTXT",
131
133
  "DIMTXTDIRECTION",
132
134
  "DIMTZIN",
@@ -197,6 +199,7 @@
197
199
  "SCALEFACTOR",
198
200
  "SECONDCORNER",
199
201
  "selectedchild",
202
+ "STRETCHPOINT",
200
203
  "SELECTIONSET",
201
204
  "SEQEND",
202
205
  "SINGLESELECTION",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-core/core",
3
- "version": "51.2.0",
3
+ "version": "52.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
@@ -3,6 +3,8 @@ import { Point } from '../../core/entities/point.js';
3
3
  import { DesignCore } from '../../core/designCore.js';
4
4
  import { Core } from '../../core/core/core.js';
5
5
  import { Line } from '../../core/entities/line.js';
6
+ import { DxfIterator } from '../../core/lib/dxf/dxfIterator.js';
7
+ import { Entities } from '../../core/lib/dxf/sections/entities.js';
6
8
  import { File, withMockInput } from '../test-helpers/test-helpers.js';
7
9
 
8
10
  // initialise core
@@ -103,6 +105,154 @@ describe('AlignedDimension.getPointsFromSelection', () => {
103
105
  });
104
106
  });
105
107
 
108
+ // Regression test: dragging the Pt11 (text) grip must recompute Pt10 (the dimension line's
109
+ // far end) from the unchanged Pt13/Pt14 - previously Pt10 was left frozen at its creation-time
110
+ // value, so moving Pt11 alone produced no visible change (text falls back to the midpoint,
111
+ // and the dimension line/value are driven entirely by the stale Pt10)
112
+ test('getGrips recomputes Pt10 (not stale) when the Pt11 grip moves', () => {
113
+ const pt13 = new Point(0, 0);
114
+ const pt14 = new Point(10, 0);
115
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [pt13, pt14] })], new Point(5, 5));
116
+ const dim = new AlignedDimension({ points });
117
+
118
+ const originalPt10 = dim.getPointBySequence(dim.points, 10);
119
+ expect(originalPt10.x).toBeCloseTo(10);
120
+ expect(originalPt10.y).toBeCloseTo(5);
121
+
122
+ const grips = dim.getGrips();
123
+ expect(grips).toHaveLength(1);
124
+ expect(grips[0].point.x).toBeCloseTo(5);
125
+ expect(grips[0].point.y).toBeCloseTo(5);
126
+
127
+ const newPoints = grips[0].apply(new Point(5, 20));
128
+ const newPt11 = newPoints.find((p) => p.sequence === 11);
129
+ const newPt10 = newPoints.find((p) => p.sequence === 10);
130
+
131
+ expect(newPt11.x).toBeCloseTo(5);
132
+ expect(newPt11.y).toBeCloseTo(20);
133
+ // Pt10 must track the moved Pt11, not stay frozen at its original position
134
+ expect(newPt10.y).not.toBeCloseTo(originalPt10.y);
135
+ expect(newPt10.x).toBeCloseTo(10);
136
+ expect(newPt10.y).toBeCloseTo(20);
137
+
138
+ // the measured value (driven by Pt13/Pt14, unchanged) must still be correct
139
+ const rebuilt = new AlignedDimension({ points: newPoints });
140
+ const text = rebuilt.buildDimension().find((e) => e.type === 'Text');
141
+ expect(Number(text.getProperty('string'))).toBeCloseTo(10);
142
+ });
143
+
144
+ // Regression test: committing a grip-dragged Pt11 (the InputManager drop path - setProperty('points', ...))
145
+ // must actually move the rendered text, not just the stored point. DIMJUST=0 (the default) hardcodes
146
+ // textPosition to the midpoint when text fits inside the extension lines, ignoring Pt11 entirely -
147
+ // unless the dimension is flagged as user-positioned (DXF bit 128), which setProperty() must now detect
148
+ test('dragging the Pt11 grip via setProperty moves the rendered text, not just the stored point', () => {
149
+ const pt13 = new Point(0, 0);
150
+ const pt14 = new Point(10, 0);
151
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [pt13, pt14] })], new Point(5, 1));
152
+ const dim = new AlignedDimension({ points });
153
+
154
+ // sanity check: with the default DIMJUST=0 and short text fitting inside, text starts at the midpoint
155
+ const textBefore = dim.buildDimension().find((e) => e.type === 'Text');
156
+ expect(textBefore.points[0].x).toBeCloseTo(5);
157
+
158
+ expect(dim.dimType.hasUserPositionedText()).toBe(false);
159
+
160
+ const grip = dim.getGrips()[0];
161
+ const newPoints = grip.apply(new Point(5, 30));
162
+
163
+ // exactly what InputManager's drop handler does: entity.setProperty('points', newPoints)
164
+ dim.setProperty('points', newPoints);
165
+
166
+ expect(dim.dimType.hasUserPositionedText()).toBe(true);
167
+
168
+ const textAfter = dim.buildDimension().find((e) => e.type === 'Text');
169
+ expect(textAfter.points[0].x).toBeCloseTo(5);
170
+ expect(textAfter.points[0].y).toBeCloseTo(30);
171
+ });
172
+
173
+ // Regression test: DIMTAD's vertical offset must still apply on top of a user-positioned
174
+ // (grip-dragged) text anchor - user-positioning only replaces the DIMJUST auto-anchor, it
175
+ // doesn't bypass DIMTAD entirely
176
+ test('DIMTAD vertical offset still applies on top of a user-positioned text', () => {
177
+ const pt13 = new Point(0, 0);
178
+ const pt14 = new Point(10, 0);
179
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [pt13, pt14] })], new Point(5, 5));
180
+ const dim = new AlignedDimension({ points });
181
+
182
+ const realStyle = dim.getDimensionStyle();
183
+ const DIMTXT = realStyle.getValue('DIMTXT');
184
+ const DIMGAP = realStyle.getValue('DIMGAP');
185
+ // DIMTAD = 4: places the dimension text below the dimension line
186
+ dim.getDimensionStyle = () => ({
187
+ getValue: (prop) => (prop === 'DIMTAD' ? 4 : realStyle.getValue(prop)),
188
+ });
189
+
190
+ const grip = dim.getGrips()[0];
191
+ const newPoints = grip.apply(new Point(5, 30));
192
+ dim.setProperty('points', newPoints);
193
+
194
+ expect(dim.dimType.hasUserPositionedText()).toBe(true);
195
+
196
+ const text = dim.buildDimension().find((e) => e.type === 'Text');
197
+ // anchored at the user-positioned Pt11 (x=5, y=30), then offset below by DIMTAD=4
198
+ expect(text.points[0].x).toBeCloseTo(5);
199
+ expect(text.points[0].y).toBeCloseTo(30 - (DIMTXT * 0.5 + DIMGAP));
200
+ });
201
+
202
+ // Regression test: userPositionedText (DXF group 70 bit 128) must survive a real write -> parse ->
203
+ // reconstruct DXF round-trip. AlignedDimension's constructor used to call dimType.setDimType(1) after
204
+ // super(data), which clobbered the flags super(data) had just loaded from group 70, reverting a
205
+ // grip-edited dimension back to automatic text placement when the file was reopened
206
+ test('grip-edited dimension preserves userPositionedText through a DXF round-trip', () => {
207
+ const pt13 = new Point(0, 0);
208
+ const pt14 = new Point(10, 0);
209
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [pt13, pt14] })], new Point(5, 5));
210
+ const dim = new AlignedDimension({ points, handle: '1' });
211
+
212
+ // grip-drag Pt11 away from the DIMJUST-computed anchor
213
+ const grip = dim.getGrips()[0];
214
+ const newPoints = grip.apply(new Point(5, 30));
215
+ dim.setProperty('points', newPoints);
216
+ expect(dim.dimType.hasUserPositionedText()).toBe(true);
217
+
218
+ // write the grip-edited dimension to DXF text
219
+ const file = new File();
220
+ dim.dxf(file);
221
+
222
+ // parse the written text back through the real DXF entity reader (not an object-spread shortcut)
223
+ const iterator = new DxfIterator();
224
+ iterator.loadFile(`0\nENTITIES\n${file.contents}0\nENDSEC\n0\nEOF`);
225
+ const [rawEntity] = new Entities().read(iterator);
226
+ rawEntity.points = rawEntity.points.map((p) => new Point(p.x, p.y, p.bulge, p.sequence));
227
+
228
+ // reconstruct through the same command dispatch path used when loading a DXF file
229
+ const reloaded = DesignCore.CommandManager.createNew(rawEntity[0], rawEntity);
230
+
231
+ expect(reloaded).toBeInstanceOf(AlignedDimension);
232
+ expect(reloaded.dimType.hasUserPositionedText()).toBe(true);
233
+
234
+ const text = reloaded.buildDimension().find((e) => e.type === 'Text');
235
+ expect(text.points[0].x).toBeCloseTo(5);
236
+ expect(text.points[0].y).toBeCloseTo(30);
237
+ });
238
+
239
+ // Regression test: a rigid whole-dimension Move (all points translated together, including Pt11)
240
+ // must NOT be mistaken for a grip-drag - auto text placement should keep working normally
241
+ test('a rigid Move (all points translated together) does not set userPositionedText', () => {
242
+ const pt13 = new Point(0, 0);
243
+ const pt14 = new Point(10, 0);
244
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [pt13, pt14] })], new Point(5, 5));
245
+ const dim = new AlignedDimension({ points });
246
+
247
+ const delta = new Point(100, 100);
248
+ const movedPoints = dim.points.map((p) => new Point(p.x + delta.x, p.y + delta.y, p.bulge, p.sequence));
249
+
250
+ dim.setProperty('points', movedPoints);
251
+
252
+ expect(dim.dimType.hasUserPositionedText()).toBe(false);
253
+ });
254
+
255
+
106
256
  test('AlignedDimension.preview runs without error and calls createTempItem', () => {
107
257
  const origAddAux = DesignCore.Scene.auxiliaryEntities.add;
108
258
  const origCreateTempItem = DesignCore.Scene.previewEntities.create;
@@ -216,3 +366,18 @@ AcDbAlignedDimension
216
366
  newDimension.dxf(file);
217
367
  expect(file.contents).toEqual(dxfString);
218
368
  });
369
+
370
+ test('snaps returns END snaps at the dimension line endpoints', () => {
371
+ const points = AlignedDimension.getPointsFromSelection([new Line({ points: [new Point(0, 0), new Point(10, 0)] })], new Point(5, 5));
372
+ const dim = new AlignedDimension({ points });
373
+ const snaps = dim.snaps(new Point(), 1);
374
+
375
+ expect(snaps.length).toBe(2);
376
+ expect(snaps.every((snap) => snap.type === 'end')).toBe(true);
377
+ expect(snaps[0].snapPoint.isSame(new Point(0, 5))).toBe(true);
378
+ expect(snaps[1].snapPoint.isSame(new Point(10, 5))).toBe(true);
379
+ });
380
+
381
+ test('snaps returns no snaps when the dimension has no points', () => {
382
+ expect(new AlignedDimension().snaps(new Point(), 1)).toEqual([]);
383
+ });
@@ -6,6 +6,7 @@ import { Polyline } from '../../core/entities/polyline.js';
6
6
  import { Core } from '../../core/core/core.js';
7
7
  import { DesignCore } from '../../core/designCore.js';
8
8
  import { SingleSelection } from '../../core/lib/selectionManager.js';
9
+ import { Utils } from '../../core/lib/utils.js';
9
10
 
10
11
  import { File, withMockInput } from '../test-helpers/test-helpers.js';
11
12
 
@@ -77,6 +78,29 @@ test.each(scenarios)('Dimension.execute handles $desc', async (scenario) => {
77
78
  }, { selectedEntities });
78
79
  });
79
80
 
81
+ // Regression test: rigidly rotating an AngularDimension (points, as the Rotate tool
82
+ // does) must not change the measured angle - buildDimension() derives everything from
83
+ // relative angles between the points, not world-axis alignment, so this should already
84
+ // be rotation-invariant (confirms it, mirroring the RotatedDimension/DiametricDimension checks)
85
+ test.each([0, 30, 45, 90, 137, 200])('measured angle is rotation-invariant at %s degrees', (rotationDeg) => {
86
+ const line1 = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
87
+ const line2 = new Line({ points: [new Point(0, 0), new Point(10, 10)] }); // 45 degree angle
88
+ const textPos = new Point(5, 5);
89
+
90
+ const points = AngularDimension.getPointsFromSelection([line1, line2], textPos);
91
+ const dim = new AngularDimension({ points });
92
+
93
+ const theta = Utils.degrees2radians(rotationDeg);
94
+ const centre = new Point(0, 0);
95
+ const rotatedPoints = dim.points.map((p) => new Point(p.x, p.y, p.bulge, p.sequence).rotate(centre, theta));
96
+ const rotatedDim = new AngularDimension({ points: rotatedPoints });
97
+
98
+ const entities = rotatedDim.buildDimension();
99
+ const text = entities.find((e) => e.type === 'Text');
100
+ expect(text).toBeDefined();
101
+ expect(Number(text.getProperty('string').replace(/[^0-9.-]+/g, ''))).toBeCloseTo(45);
102
+ });
103
+
80
104
  test('constructor sets default properties', () => {
81
105
  const dim = new AngularDimension();
82
106
  expect(dim).toBeInstanceOf(AngularDimension);
@@ -99,6 +123,114 @@ test('getPointsFromSelection returns correct sequenced points', () => {
99
123
  expect(result.length).toBeGreaterThanOrEqual(4);
100
124
  });
101
125
 
126
+ // Regression test: dragging the Pt11 (text) grip must recompute Pt16 (the dimension arc's
127
+ // radial position) from the unchanged defining lines - previously Pt16 was a frozen copy of
128
+ // Pt11 taken at creation time, so the arc stayed at its original radius when only Pt11 moved
129
+ test('getGrips recomputes Pt16 (not stale) when the Pt11 grip moves', () => {
130
+ const lineOne = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
131
+ const lineTwo = new Line({ points: [new Point(0, 0), new Point(10, 10)] });
132
+ const points = AngularDimension.getPointsFromSelection([lineOne, lineTwo], new Point(5, 5));
133
+ const dim = new AngularDimension({ points });
134
+
135
+ const originalPt16 = dim.getPointBySequence(dim.points, 16);
136
+
137
+ const grips = dim.getGrips();
138
+ expect(grips).toHaveLength(1);
139
+ expect(grips[0].point.x).toBeCloseTo(5);
140
+ expect(grips[0].point.y).toBeCloseTo(5);
141
+
142
+ const newPoints = grips[0].apply(new Point(20, 20));
143
+ const newPt11 = newPoints.find((p) => p.sequence === 11);
144
+ const newPt16 = newPoints.find((p) => p.sequence === 16);
145
+
146
+ expect(newPt11.x).toBeCloseTo(20);
147
+ expect(newPt11.y).toBeCloseTo(20);
148
+ // Pt16 must track the moved Pt11 (further out along the angle), not stay at its original radius
149
+ expect(newPt16.x).not.toBeCloseTo(originalPt16.x);
150
+ expect(newPt16.y).not.toBeCloseTo(originalPt16.y);
151
+
152
+ // the measured angle (driven by the two defining lines, unchanged) must still be correct
153
+ const rebuilt = new AngularDimension({ points: newPoints });
154
+ const text = rebuilt.buildDimension().find((e) => e.type === 'Text');
155
+ expect(Number(text.getProperty('string').replace(/[^0-9.-]+/g, ''))).toBeCloseTo(45);
156
+ });
157
+
158
+ // Regression test: committing a grip-dragged Pt11 (the InputManager drop path - setProperty('points', ...))
159
+ // must actually move the rendered text, not just the stored point - the default auto-placement
160
+ // (arcMiddle) ignores Pt11 entirely unless the dimension is flagged as user-positioned (DXF bit 128)
161
+ test('dragging the Pt11 grip via setProperty moves the rendered text, not just the stored point', () => {
162
+ const lineOne = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
163
+ const lineTwo = new Line({ points: [new Point(0, 0), new Point(10, 10)] });
164
+ const points = AngularDimension.getPointsFromSelection([lineOne, lineTwo], new Point(5, 5));
165
+ const dim = new AngularDimension({ points });
166
+
167
+ expect(dim.dimType.hasUserPositionedText()).toBe(false);
168
+
169
+ const grip = dim.getGrips()[0];
170
+ const newPoints = grip.apply(new Point(20, 20));
171
+
172
+ // exactly what InputManager's drop handler does: entity.setProperty('points', newPoints)
173
+ dim.setProperty('points', newPoints);
174
+
175
+ expect(dim.dimType.hasUserPositionedText()).toBe(true);
176
+
177
+ const text = dim.buildDimension().find((e) => e.type === 'Text');
178
+ expect(text.points[0].x).toBeCloseTo(20);
179
+ expect(text.points[0].y).toBeCloseTo(20);
180
+ });
181
+
182
+ // Regression test: DIMTAD's vertical offset must still apply on top of a user-positioned
183
+ // (grip-dragged) text anchor - user-positioning only replaces the DIMJUST auto-anchor, it
184
+ // doesn't bypass DIMTAD entirely
185
+ test('DIMTAD vertical offset still applies on top of a user-positioned text', () => {
186
+ const lineOne = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
187
+ const lineTwo = new Line({ points: [new Point(0, 0), new Point(10, 10)] });
188
+ const points = AngularDimension.getPointsFromSelection([lineOne, lineTwo], new Point(5, 5));
189
+ const dim = new AngularDimension({ points });
190
+
191
+ const realStyle = dim.getDimensionStyle();
192
+ // DIMTAD = 4: places the dimension text below the dimension line
193
+ dim.getDimensionStyle = () => ({
194
+ getValue: (prop) => (prop === 'DIMTAD' ? 4 : realStyle.getValue(prop)),
195
+ });
196
+
197
+ const grip = dim.getGrips()[0];
198
+ const newPoints = grip.apply(new Point(20, 20));
199
+ dim.setProperty('points', newPoints);
200
+
201
+ expect(dim.dimType.hasUserPositionedText()).toBe(true);
202
+
203
+ const text = dim.buildDimension().find((e) => e.type === 'Text');
204
+ // DIMTAD=4 must offset the text away from the pure user-positioned anchor (20,20)
205
+ const distanceFromAnchor = text.points[0].distance(new Point(20, 20));
206
+ expect(distanceFromAnchor).toBeGreaterThan(0.01);
207
+ });
208
+
209
+ // Regression test: a grip-dragged text's rotation must follow the tangent at its new
210
+ // (dragged) position, not stay frozen at the pre-drag DIMJUST-computed rotation
211
+ test('text rotation follows the dragged position, not the pre-drag rotation', () => {
212
+ const lineOne = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
213
+ const lineTwo = new Line({ points: [new Point(0, 0), new Point(10, 10)] });
214
+ const points = AngularDimension.getPointsFromSelection([lineOne, lineTwo], new Point(5, 5));
215
+ const dim = new AngularDimension({ points });
216
+
217
+ const realStyle = dim.getDimensionStyle();
218
+ // DIMTIH/DIMTOH = 0: text follows the dimension line's direction rather than staying horizontal
219
+ dim.getDimensionStyle = () => ({
220
+ getValue: (prop) => ((prop === 'DIMTIH' || prop === 'DIMTOH') ? 0 : realStyle.getValue(prop)),
221
+ });
222
+
223
+ const grip = dim.getGrips()[0];
224
+ const dragTo = new Point(-20, 5);
225
+ const newPoints = grip.apply(dragTo);
226
+ dim.setProperty('points', newPoints);
227
+
228
+ const text = dim.buildDimension().find((e) => e.type === 'Text');
229
+ // expected rotation is the tangent at the dragged point, not the original chord-based angle
230
+ const expectedRotation = Utils.radians2degrees(dim.getTextDirection(new Point(0, 0).angle(dragTo) + Math.PI / 2)) % 360;
231
+ expect(text.getProperty('rotation')).toBeCloseTo(expectedRotation);
232
+ });
233
+
102
234
  test('AngularDimension.preview runs without error and calls createTempItem', () => {
103
235
  const origCreateTempItem = DesignCore.Scene.previewEntities.create;
104
236
  const origPointOnScene = DesignCore.Mouse.pointOnScene;
@@ -241,3 +373,20 @@ AcDb2LineAngularDimension
241
373
  });
242
374
 
243
375
 
376
+ test('snaps returns END snaps at the dimension arc end points', () => {
377
+ const lineOne = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
378
+ const lineTwo = new Line({ points: [new Point(0, 0), new Point(0, 10)] });
379
+ const points = AngularDimension.getPointsFromSelection([lineOne, lineTwo], new Point(5, 5));
380
+ const dim = new AngularDimension({ points });
381
+ const snaps = dim.snaps(new Point(), 1);
382
+ const radius = Math.hypot(5, 5);
383
+
384
+ expect(snaps.length).toBe(2);
385
+ expect(snaps.every((snap) => snap.type === 'end')).toBe(true);
386
+ expect(snaps.some((snap) => snap.snapPoint.isSame(new Point(radius, 0)))).toBe(true);
387
+ expect(snaps.some((snap) => snap.snapPoint.isSame(new Point(0, radius)))).toBe(true);
388
+ });
389
+
390
+ test('snaps returns no snaps when the dimension has no points', () => {
391
+ expect(new AngularDimension().snaps(new Point(), 1)).toEqual([]);
392
+ });