@design-core/core 51.2.0 → 52.0.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 (43) 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/tools/gripStretch.js +97 -0
  24. package/core/tools/offset.js +7 -1
  25. package/core/tools/tool.js +10 -0
  26. package/cspell.json +3 -0
  27. package/package.json +1 -1
  28. package/test/dimensions/alignedDimension.test.js +165 -0
  29. package/test/dimensions/angularDimension.test.js +149 -0
  30. package/test/dimensions/diametricDimension.test.js +76 -4
  31. package/test/dimensions/dimension.test.js +130 -0
  32. package/test/dimensions/dimensionBase.test.js +97 -4
  33. package/test/dimensions/radialDimension.test.js +33 -0
  34. package/test/dimensions/rotatedDimension.test.js +80 -0
  35. package/test/integration/exportIntegration.reference.dxf +3 -9
  36. package/test/integration/userInputIntegration.reference.dxf +3 -9
  37. package/test/lib/inputManager.test.js +316 -1
  38. package/test/lib/selectionManager.test.js +42 -0
  39. package/test/renderers/pdfRenderer.test.js +26 -0
  40. package/test/tables/dimstyle.test.js +32 -0
  41. package/test/tools/gripStretch.test.js +108 -0
  42. package/test/tools/offset.test.js +2 -0
  43. package/test/tools/tool.test.js +25 -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.0.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
+ });
@@ -7,6 +7,7 @@ import { DesignCore } from '../../core/designCore.js';
7
7
  import { SingleSelection } from '../../core/lib/selectionManager.js';
8
8
  import { Circle } from '../../core/entities/circle.js';
9
9
  import { Polyline } from '../../core/entities/polyline.js';
10
+ import { Utils } from '../../core/lib/utils.js';
10
11
 
11
12
 
12
13
  // initialise core
@@ -20,28 +21,28 @@ const scenarios = [
20
21
  selectedEntities: [new Circle({ points: [new Point(0, 0), new Point(10, 0)] })],
21
22
  expectedDimType: 3,
22
23
  dimensionValue: 20,
23
- dimensionEntities: 6,
24
+ dimensionEntities: 7,
24
25
  },
25
26
  { desc: 'Diametric dimension from circle selection - Text inside circle',
26
27
  input: [new SingleSelection(0, new Point()), new Point(0, 0)],
27
28
  selectedEntities: [new Circle({ points: [new Point(0, 0), new Point(10, 0)] })],
28
29
  expectedDimType: 3,
29
30
  dimensionValue: 20,
30
- dimensionEntities: 5,
31
+ dimensionEntities: 8,
31
32
  },
32
33
  { desc: 'Diametric dimension from arc selection',
33
34
  input: [new SingleSelection(0, new Point()), new Point(20, 10)],
34
35
  selectedEntities: [new Arc({ points: [new Point(0, 0), new Point(20, 0), new Point(20, 20)] })],
35
36
  expectedDimType: 3,
36
37
  dimensionValue: 40,
37
- dimensionEntities: 6,
38
+ dimensionEntities: 7,
38
39
  },
39
40
  { desc: 'Diametric dimension from polyline selection',
40
41
  input: [new SingleSelection(0, new Point(30, 10)), new Point(30, 10)],
41
42
  selectedEntities: [new Polyline({ points: [new Point(0, 0), new Point(20, 0, 1), new Point(20, 20)] })],
42
43
  expectedDimType: 3,
43
44
  dimensionValue: 20,
44
- dimensionEntities: 6,
45
+ dimensionEntities: 7,
45
46
  },
46
47
  ];
47
48
 
@@ -69,6 +70,29 @@ test.each(scenarios)('DiametricDimension.execute handles $desc', async (scenario
69
70
  }, { selectedEntities });
70
71
  });
71
72
 
73
+ // Regression test: Pt11 (text position) not lying on the Pt10-Pt15 segment used to be
74
+ // snapped back onto the dimension line via a world-horizontal ray intersection, which
75
+ // silently fails (no intersection) whenever the dimension line itself is near-horizontal -
76
+ // perpendicular projection has no such degenerate case at any rotation angle
77
+ test.each([0, 30, 45, 90, 135, 180, 270])('off-segment text stays connected at %s degree dimension line rotation', (rotationDeg) => {
78
+ const centre = new Point(0, 0);
79
+ const circle = new Circle({ points: [centre, new Point(10, 0)] });
80
+ // text placed off to the side, not on the Pt10-Pt15 line
81
+ const textPos = new Point(20, 15);
82
+
83
+ const theta = Utils.degrees2radians(rotationDeg);
84
+ const points = DiametricDimension.getPointsFromSelection([circle], textPos)
85
+ .map((p) => new Point(p.x, p.y, p.bulge, p.sequence).rotate(centre, theta));
86
+
87
+ const dim = new DiametricDimension({ points });
88
+ const entities = dim.buildDimension();
89
+
90
+ expect(entities.length).toBeGreaterThan(0);
91
+ const text = entities.find((e) => e.type === 'Text');
92
+ expect(text).toBeDefined();
93
+ expect(Number(text.getProperty('string').replace(/[^0-9.-]+/g, ''))).toBeCloseTo(20);
94
+ });
95
+
72
96
  test('constructor sets default properties', () => {
73
97
  const dim = new DiametricDimension();
74
98
  expect(dim).toBeInstanceOf(DiametricDimension);
@@ -89,6 +113,39 @@ test('getPointsFromSelection returns correct sequenced points', () => {
89
113
  expect(result[2].sequence).toBe(11); // text position
90
114
  });
91
115
 
116
+ // Regression test: dragging the Pt11 (text) grip must recompute Pt10/Pt15 (the circle attach
117
+ // points) from the unchanged centre/radius - previously they were left frozen at their
118
+ // creation-time angle, so the leader line tracked the new Pt11 but attached to a stale point
119
+ test('getGrips recomputes Pt10/Pt15 (not stale) when the Pt11 grip moves', () => {
120
+ const centre = new Point(0, 0);
121
+ const item = { points: [centre], getRadius: () => 10 };
122
+ const points = DiametricDimension.getPointsFromSelection([item], new Point(10, 0));
123
+ const dim = new DiametricDimension({ points });
124
+
125
+ const originalPt15 = dim.getPointBySequence(dim.points, 15);
126
+
127
+ const grips = dim.getGrips();
128
+ expect(grips).toHaveLength(1);
129
+ expect(grips[0].point.x).toBeCloseTo(10);
130
+ expect(grips[0].point.y).toBeCloseTo(0);
131
+
132
+ const newPoints = grips[0].apply(new Point(0, -10));
133
+ const newPt11 = newPoints.find((p) => p.sequence === 11);
134
+ const newPt15 = newPoints.find((p) => p.sequence === 15);
135
+
136
+ expect(newPt11.x).toBeCloseTo(0);
137
+ expect(newPt11.y).toBeCloseTo(-10);
138
+ // Pt15 must track the moved Pt11 around the circle, not stay frozen at its original angle
139
+ expect(newPt15.x).not.toBeCloseTo(originalPt15.x);
140
+ expect(newPt15.y).not.toBeCloseTo(originalPt15.y);
141
+ expect(centre.distance(newPt15)).toBeCloseTo(10);
142
+
143
+ // the measured value (the diameter, driven by radius, unchanged) must still be correct
144
+ const rebuilt = new DiametricDimension({ points: newPoints });
145
+ const text = rebuilt.buildDimension().find((e) => e.type === 'Text');
146
+ expect(Number(text.getProperty('string').replace(/[^0-9.-]+/g, ''))).toBeCloseTo(20);
147
+ });
148
+
92
149
  test('DiametricDimension.preview runs without error and calls createTempItem', () => {
93
150
  const origCreateTempItem = DesignCore.Scene.previewEntities.create;
94
151
  const origPointOnScene = DesignCore.Mouse.pointOnScene;
@@ -201,3 +258,18 @@ AcDbDiametricDimension
201
258
  newDimension.dxf(file);
202
259
  expect(file.contents).toEqual(dxfString);
203
260
  });
261
+
262
+ test('inside text keeps the Pt15 arrow connected to the text and adds an opposite segment', () => {
263
+ const dim = new DiametricDimension({
264
+ points: [new Point(-10, 0, 0, 10), new Point(10, 0, 0, 15), new Point(2, 0, 0, 11)],
265
+ });
266
+ const lines = dim.buildDimension().filter((e) => e.type === 'Line');
267
+ const segments = lines.map((l) => l.points.map((p) => p.x).sort((a, b) => a - b));
268
+
269
+ // Pt15 to text position
270
+ expect(segments.some(([a, b]) => a === 2 && b === 10)).toBe(true);
271
+ // opposite side segment ends at Pt10 and leaves a gap for the text
272
+ const toPt10 = segments.find(([a]) => a === -10);
273
+ expect(toPt10).toBeDefined();
274
+ expect(toPt10[1]).toBeLessThan(2);
275
+ });