@design-core/core 0.0.1 → 0.0.3

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 (254) hide show
  1. package/.eslintrc.json +14 -7
  2. package/.github/workflows/DesignCoreQA.yml +3 -1
  3. package/README.md +11 -4
  4. package/TODO.md +19 -0
  5. package/core/core/core.js +49 -42
  6. package/core/designCore.js +61 -2
  7. package/core/dimensions/alignedDimension.js +142 -0
  8. package/core/dimensions/angularDimension.js +550 -0
  9. package/core/dimensions/baseCircularDimension.js +244 -0
  10. package/core/dimensions/baseDimension.js +558 -0
  11. package/core/dimensions/baseLinearDimension.js +371 -0
  12. package/core/dimensions/diametricDimension.js +82 -0
  13. package/core/dimensions/dimension.js +298 -0
  14. package/core/dimensions/linearDimension.js +29 -0
  15. package/core/dimensions/radialDimension.js +78 -0
  16. package/core/dimensions/rotatedDimension.js +167 -0
  17. package/core/entities/arc.js +314 -60
  18. package/core/entities/arctext.js +642 -0
  19. package/core/entities/basePolyline.js +553 -94
  20. package/core/entities/circle.js +181 -54
  21. package/core/entities/entity.js +154 -74
  22. package/core/entities/hatch.js +807 -0
  23. package/core/entities/insert.js +200 -39
  24. package/core/entities/line.js +196 -134
  25. package/core/entities/lwpolyline.js +21 -44
  26. package/core/entities/point.js +194 -29
  27. package/core/entities/polyline.js +54 -7
  28. package/core/entities/rectangle.js +43 -13
  29. package/core/entities/solid.js +43 -6
  30. package/core/entities/text.js +324 -181
  31. package/core/lib/boundingBox.js +73 -13
  32. package/core/lib/canvas.js +256 -65
  33. package/core/lib/clipboard.js +198 -0
  34. package/core/lib/colour.js +144 -0
  35. package/core/lib/colours.js +367 -301
  36. package/core/lib/commandLine.js +16 -28
  37. package/core/lib/commandManager.js +115 -58
  38. package/core/lib/constants.js +23 -0
  39. package/core/lib/dxf/dxf.js +249 -34
  40. package/core/lib/dxf/dxfFile.js +23 -38
  41. package/core/lib/dxf/dxfIterator.js +17 -5
  42. package/core/lib/dxf/dxfRead.js +23 -7
  43. package/core/lib/dxf/dxfWrite.js +39 -129
  44. package/core/lib/dxf/handleManager.js +97 -0
  45. package/core/lib/dxf/sections/blocks.js +16 -2
  46. package/core/lib/dxf/sections/entities.js +17 -3
  47. package/core/lib/dxf/sections/header.js +10 -1
  48. package/core/lib/dxf/sections/objects.js +36 -0
  49. package/core/lib/dxf/sections/section.js +43 -5
  50. package/core/lib/dxf/sections/tables.js +17 -3
  51. package/core/lib/entityManager.js +178 -0
  52. package/core/lib/fileio.js +14 -4
  53. package/core/lib/headers.js +229 -0
  54. package/core/lib/input.js +46 -0
  55. package/core/lib/inputManager.js +199 -194
  56. package/core/lib/intersect.js +341 -468
  57. package/core/lib/logging.js +25 -1
  58. package/core/lib/matrix.js +6 -8
  59. package/core/lib/mouse.js +24 -79
  60. package/core/lib/patterns.js +690 -0
  61. package/core/lib/promptOptions.js +133 -0
  62. package/core/lib/scene.js +71 -174
  63. package/core/lib/selectionManager.js +74 -48
  64. package/core/lib/selectionWindow.js +24 -10
  65. package/core/lib/settings.js +18 -11
  66. package/core/lib/snapping.js +129 -21
  67. package/core/lib/stateManager.js +288 -0
  68. package/core/lib/strings.js +57 -5
  69. package/core/lib/utils.js +95 -11
  70. package/core/objects/dictionary.js +43 -0
  71. package/core/objects/dictionaryManager.js +80 -0
  72. package/core/properties/dimType.js +117 -0
  73. package/core/properties/flags.js +66 -0
  74. package/core/properties/property.js +26 -0
  75. package/core/properties/propertyManager.js +65 -19
  76. package/core/tables/appId.js +31 -0
  77. package/core/tables/appIdManager.js +44 -0
  78. package/core/tables/block.js +323 -0
  79. package/core/tables/blockManager.js +48 -0
  80. package/core/tables/blockRecord.js +29 -0
  81. package/core/tables/blockRecordManager.js +37 -0
  82. package/core/tables/dimStyle.js +627 -0
  83. package/core/tables/dimStyleManager.js +187 -0
  84. package/core/tables/endblk.js +28 -0
  85. package/core/tables/layer.js +219 -0
  86. package/core/tables/layerManager.js +47 -0
  87. package/core/tables/ltype.js +85 -0
  88. package/core/tables/ltypeManager.js +65 -0
  89. package/core/tables/style.js +145 -0
  90. package/core/tables/styleManager.js +55 -0
  91. package/core/tables/styleManagerBase.js +110 -0
  92. package/core/tables/tableManagerBase.js +354 -0
  93. package/core/tables/ucs.js +28 -0
  94. package/core/tables/ucsManager.js +44 -0
  95. package/core/tables/view.js +28 -0
  96. package/core/tables/viewManager.js +44 -0
  97. package/core/tables/vport.js +70 -0
  98. package/core/tables/vportManager.js +65 -0
  99. package/core/tools/chamfer.js +343 -0
  100. package/core/tools/chamferFilletBase.js +162 -0
  101. package/core/tools/copy.js +68 -40
  102. package/core/tools/copybase.js +65 -0
  103. package/core/tools/copyclip.js +59 -0
  104. package/core/tools/cornerEntity.js +138 -0
  105. package/core/tools/cutclip.js +72 -0
  106. package/core/tools/distance.js +37 -9
  107. package/core/tools/erase.js +43 -13
  108. package/core/tools/explode.js +117 -0
  109. package/core/tools/extend.js +71 -32
  110. package/core/tools/fillet.js +336 -0
  111. package/core/tools/identify.js +33 -6
  112. package/core/tools/matchProp.js +106 -0
  113. package/core/tools/mirror.js +141 -0
  114. package/core/tools/move.js +65 -32
  115. package/core/tools/offset.js +321 -0
  116. package/core/tools/pan.js +54 -0
  117. package/core/tools/pasteclip.js +93 -0
  118. package/core/tools/purge.js +90 -0
  119. package/core/tools/rotate.js +105 -70
  120. package/core/tools/scale.js +166 -0
  121. package/core/tools/tool.js +14 -4
  122. package/core/tools/trim.js +71 -32
  123. package/core/tools/zoom.js +152 -0
  124. package/package.json +3 -305
  125. package/test/dimensions/alignedDimension.test.js +211 -0
  126. package/test/dimensions/angularDimension.test.js +243 -0
  127. package/test/dimensions/baseDimension.test.js +239 -0
  128. package/test/dimensions/diametricDimension.test.js +203 -0
  129. package/test/dimensions/dimension.test.js +102 -0
  130. package/test/dimensions/radialDimension.test.js +203 -0
  131. package/test/dimensions/rotatedDimension.test.js +224 -0
  132. package/test/entities/arc.test.js +463 -41
  133. package/test/entities/arctext.test.js +329 -0
  134. package/test/entities/basePolyline.test.js +459 -10
  135. package/test/entities/circle.test.js +312 -7
  136. package/test/entities/entity.test.js +28 -9
  137. package/test/entities/hatch.test.js +606 -0
  138. package/test/entities/insert.test.js +90 -0
  139. package/test/entities/line.test.js +182 -6
  140. package/test/entities/point.test.js +375 -37
  141. package/test/entities/solid.test.js +156 -0
  142. package/test/entities/text.test.js +533 -7
  143. package/test/integration/exportIntegration.reference.dxf +1706 -0
  144. package/test/integration/exportIntegration.test.js +186 -0
  145. package/test/integration/importExportIntegration.test.js +126 -0
  146. package/test/integration/intersectionIntegration.test.js +880 -0
  147. package/test/integration/userInputIntegration.reference.dxf +1410 -0
  148. package/test/integration/userInputIntegration.test.js +269 -0
  149. package/test/lib/boundingBox.test.js +26 -2
  150. package/test/lib/canvas.test.js +613 -5
  151. package/test/lib/clipboard.test.js +70 -0
  152. package/test/lib/colour.test.js +114 -0
  153. package/test/lib/colours.test.js +63 -18
  154. package/test/lib/commandLine.test.js +159 -22
  155. package/test/lib/commandManager.test.js +4 -4
  156. package/test/lib/constants.test.js +9 -0
  157. package/test/lib/dxf/dxfFile.test.js +6 -2
  158. package/test/lib/dxf/dxfIterator.test.js +1 -1
  159. package/test/lib/dxf/dxfWrite.test.js +173 -0
  160. package/test/lib/dxf/handleManager.test.js +137 -0
  161. package/test/lib/dxf/sections/blocks.test.js +5 -5
  162. package/test/lib/dxf/sections/entities.test.js +4 -4
  163. package/test/lib/dxf/sections/header.test.js +18 -4
  164. package/test/lib/dxf/sections/objects.test.js +132 -0
  165. package/test/lib/dxf/sections/section.test.js +29 -29
  166. package/test/lib/dxf/sections/tables.test.js +3 -3
  167. package/test/lib/entityManager.test.js +163 -0
  168. package/test/lib/headers.test.js +233 -0
  169. package/test/lib/inputManager.test.js +1144 -5
  170. package/test/lib/intersect.test.js +454 -331
  171. package/test/lib/logging.test.js +1 -1
  172. package/test/lib/matrix.test.js +1 -1
  173. package/test/lib/mouse.test.js +327 -4
  174. package/test/lib/patterns.test.js +105 -0
  175. package/test/lib/promptOptions.test.js +179 -0
  176. package/test/lib/scene.test.js +16 -52
  177. package/test/lib/selectionManager.test.js +17 -5
  178. package/test/lib/settings.test.js +111 -5
  179. package/test/lib/snapping.test.js +142 -4
  180. package/test/lib/stateManager.test.js +155 -0
  181. package/test/lib/utils.test.js +81 -1
  182. package/test/objects/dictionary.test.js +81 -0
  183. package/test/objects/dictionaryManager.test.js +34 -0
  184. package/test/properties/dimType.test.js +62 -0
  185. package/test/properties/flags.test.js +164 -0
  186. package/test/properties/property.test.js +13 -0
  187. package/test/properties/propertyManager.test.js +10 -11
  188. package/test/tables/appId.test.js +46 -0
  189. package/test/tables/appIdManager.test.js +72 -0
  190. package/test/tables/block.test.js +157 -0
  191. package/test/tables/blockManager.test.js +52 -0
  192. package/test/tables/blockRecord.test.js +39 -0
  193. package/test/tables/blockRecordManager.test.js +60 -0
  194. package/test/tables/dimstyle.test.js +72 -0
  195. package/test/tables/endblk.test.js +30 -0
  196. package/test/tables/layer.test.js +95 -0
  197. package/test/tables/layerManager.test.js +172 -0
  198. package/test/{styles → tables}/ltype.test.js +7 -7
  199. package/test/{styles → tables}/ltypeManager.test.js +2 -2
  200. package/test/tables/style.test.js +44 -0
  201. package/test/tables/stylemanagerbase.test.js +220 -0
  202. package/test/tables/tableManagerBase.test.js +194 -0
  203. package/test/tables/ucs.test.js +37 -0
  204. package/test/tables/ucsManager.test.js +48 -0
  205. package/test/tables/view.test.js +37 -0
  206. package/test/tables/viewManager.test.js +48 -0
  207. package/test/tables/vport.test.js +117 -0
  208. package/test/tables/vportManager.test.js +54 -0
  209. package/test/test-helpers/test-helpers.js +66 -0
  210. package/test/tools/chamfer.test.js +970 -0
  211. package/test/tools/chamferFilletBase.test.js +446 -0
  212. package/test/tools/copy.test.js +22 -14
  213. package/test/tools/copybase.test.js +93 -0
  214. package/test/tools/copyclip.test.js +92 -0
  215. package/test/tools/cornerEntity.test.js +201 -0
  216. package/test/tools/cutclip.test.js +94 -0
  217. package/test/tools/distance.test.js +140 -18
  218. package/test/tools/erase.test.js +12 -12
  219. package/test/tools/explode.test.js +45 -0
  220. package/test/tools/extend.test.js +326 -44
  221. package/test/tools/fillet.test.js +831 -0
  222. package/test/tools/identify.test.js +115 -16
  223. package/test/tools/matchProp.test.js +84 -0
  224. package/test/tools/mirror.test.js +475 -0
  225. package/test/tools/move.test.js +75 -19
  226. package/test/tools/offset.test.js +748 -0
  227. package/test/tools/pan.test.js +72 -0
  228. package/test/tools/pasteclip.test.js +100 -0
  229. package/test/tools/purge.test.js +89 -0
  230. package/test/tools/rotate.test.js +89 -23
  231. package/test/tools/scale.test.js +529 -0
  232. package/test/tools/trim.test.js +419 -84
  233. package/test/tools/zoom.test.js +504 -0
  234. package/core/entities/alignedDimension.js +0 -279
  235. package/core/entities/angularDimension.js +0 -194
  236. package/core/entities/baseDimension.js +0 -227
  237. package/core/entities/block.js +0 -247
  238. package/core/entities/diametricDimension.js +0 -133
  239. package/core/entities/dimension.js +0 -202
  240. package/core/entities/radialDimension.js +0 -126
  241. package/core/styles/dimStyle.js +0 -719
  242. package/core/styles/dimStyleManager.js +0 -47
  243. package/core/styles/layer.js +0 -133
  244. package/core/styles/layerManager.js +0 -40
  245. package/core/styles/ltype.js +0 -86
  246. package/core/styles/ltypeManager.js +0 -50
  247. package/core/styles/style.js +0 -158
  248. package/core/styles/styleManager.js +0 -43
  249. package/core/styles/styleManagerBase.js +0 -299
  250. package/test/entities/block.test.js +0 -22
  251. package/test/styles/dimstyle.test.js +0 -19
  252. package/test/styles/layerManager.test.js +0 -150
  253. package/test/styles/style.test.js +0 -33
  254. package/test/styles/stylemanagerbase.test.js +0 -219
@@ -0,0 +1,748 @@
1
+ import { Core } from '../../core/core/core.js';
2
+ import { Point } from '../../core/entities/point.js';
3
+ import { Offset } from '../../core/tools/offset.js';
4
+ import { Line } from '../../core/entities/line.js';
5
+ import { Arc } from '../../core/entities/arc.js';
6
+ import { Circle } from '../../core/entities/circle.js';
7
+ import { expect, jest } from '@jest/globals';
8
+
9
+ const core = new Core();
10
+
11
+ const inputScenarios = [
12
+ {
13
+ desc: 'offset line to the left by distance 5',
14
+ entityType: 'Line',
15
+ entityData: { points: [new Point(0, 0), new Point(10, 0)] },
16
+ inputs: [5, { selectedItemIndex: 0 }, new Point(5, 5)],
17
+ checkIndex: 1,
18
+ expectedPoints: [new Point(0, 5), new Point(10, 5)],
19
+ },
20
+ {
21
+ desc: 'offset line to the right by distance 5',
22
+ entityType: 'Line',
23
+ entityData: { points: [new Point(0, 0), new Point(10, 0)] },
24
+ inputs: [5, { selectedItemIndex: 0 }, new Point(5, -5)],
25
+ checkIndex: 1,
26
+ expectedPoints: [new Point(0, -5), new Point(10, -5)],
27
+ },
28
+ {
29
+ desc: 'offset circle outward by distance 5',
30
+ entityType: 'Circle',
31
+ entityData: { points: [new Point(0, 0), new Point(10, 0)] },
32
+ inputs: [5, { selectedItemIndex: 0 }, new Point(20, 0)],
33
+ checkIndex: 1,
34
+ checkRadius: 15,
35
+ },
36
+ {
37
+ desc: 'offset circle inward by distance 3',
38
+ entityType: 'Circle',
39
+ entityData: { points: [new Point(0, 0), new Point(10, 0)] },
40
+ inputs: [3, { selectedItemIndex: 0 }, new Point(5, 0)],
41
+ checkIndex: 1,
42
+ checkRadius: 7,
43
+ },
44
+ {
45
+ desc: 'offset arc outward by distance 5',
46
+ entityType: 'Arc',
47
+ entityData: { points: [new Point(0, 0), new Point(10, 0), new Point(0, 10)] },
48
+ inputs: [5, { selectedItemIndex: 0 }, new Point(20, 0)],
49
+ checkIndex: 1,
50
+ checkRadius: 15,
51
+ },
52
+ ];
53
+
54
+ test.each(inputScenarios)('Offset.execute handles $desc', async (scenario) => {
55
+ const { entityType, entityData, inputs, checkIndex, expectedPoints, checkRadius } = scenario;
56
+
57
+ core.scene.clear();
58
+ core.scene.selectionManager.reset();
59
+
60
+ core.scene.addItem(entityType, entityData);
61
+
62
+ const origInputManager = core.scene.inputManager;
63
+ let callCount = 0;
64
+ core.scene.inputManager = {
65
+ requestInput: async () => {
66
+ if (callCount < inputs.length) {
67
+ return inputs[callCount++];
68
+ }
69
+ },
70
+ actionCommand: () => {
71
+ offset.action();
72
+ },
73
+ executeCommand: () => {},
74
+ reset: () => {},
75
+ };
76
+
77
+ const offset = new Offset();
78
+ await offset.execute();
79
+
80
+ const entity = core.scene.entities.get(checkIndex);
81
+ expect(entity).toBeDefined();
82
+
83
+ if (expectedPoints) {
84
+ expect(entity.points[0].x).toBeCloseTo(expectedPoints[0].x);
85
+ expect(entity.points[0].y).toBeCloseTo(expectedPoints[0].y);
86
+ expect(entity.points[1].x).toBeCloseTo(expectedPoints[1].x);
87
+ expect(entity.points[1].y).toBeCloseTo(expectedPoints[1].y);
88
+ }
89
+
90
+ if (checkRadius !== undefined) {
91
+ const radius = entity.points[0].distance(entity.points[1]);
92
+ expect(radius).toBeCloseTo(checkRadius);
93
+ }
94
+
95
+ core.scene.inputManager = origInputManager;
96
+ });
97
+
98
+ test('Offset.execute - cancel at distance prompt does not offset', async () => {
99
+ core.scene.clear();
100
+ core.scene.selectionManager.reset();
101
+
102
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
103
+
104
+ const origInputManager = core.scene.inputManager;
105
+ core.scene.inputManager = {
106
+ requestInput: async () => undefined,
107
+ executeCommand: () => {},
108
+ reset: () => {},
109
+ };
110
+
111
+ const offset = new Offset();
112
+ await offset.execute();
113
+
114
+ expect(core.scene.entities.count()).toBe(1);
115
+
116
+ core.scene.inputManager = origInputManager;
117
+ });
118
+
119
+ test('Offset.execute - cancel at entity selection does not offset', async () => {
120
+ core.scene.clear();
121
+ core.scene.selectionManager.reset();
122
+
123
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
124
+
125
+ const origInputManager = core.scene.inputManager;
126
+ let callCount = 0;
127
+ const inputs = [5]; // provides distance, then undefined on selection
128
+ core.scene.inputManager = {
129
+ requestInput: async () => {
130
+ if (callCount < inputs.length) {
131
+ return inputs[callCount++];
132
+ }
133
+ },
134
+ executeCommand: () => {},
135
+ reset: () => {},
136
+ };
137
+
138
+ const offset = new Offset();
139
+ await offset.execute();
140
+
141
+ expect(core.scene.entities.count()).toBe(1);
142
+
143
+ core.scene.inputManager = origInputManager;
144
+ });
145
+
146
+ test('Offset.execute - cancel at side point does not offset', async () => {
147
+ core.scene.clear();
148
+ core.scene.selectionManager.reset();
149
+
150
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
151
+
152
+ const origInputManager = core.scene.inputManager;
153
+ let callCount = 0;
154
+ const inputs = [5, { selectedItemIndex: 0 }]; // distance + selection, then undefined
155
+ core.scene.inputManager = {
156
+ requestInput: async () => {
157
+ if (callCount < inputs.length) {
158
+ return inputs[callCount++];
159
+ }
160
+ },
161
+ executeCommand: () => {},
162
+ reset: () => {},
163
+ };
164
+
165
+ const offset = new Offset();
166
+ await offset.execute();
167
+
168
+ expect(core.scene.entities.count()).toBe(1);
169
+ // selectedItem must be cleared so a stale action() call cannot create an unintended entity
170
+ expect(offset.selectedItem).toBeNull();
171
+
172
+ core.scene.inputManager = origInputManager;
173
+ });
174
+
175
+ test('Offset.execute - entity count increases by one per offset', async () => {
176
+ core.scene.clear();
177
+ core.scene.selectionManager.reset();
178
+
179
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
180
+ core.scene.addItem('Circle', { points: [new Point(0, 0), new Point(10, 0)] });
181
+
182
+ const entityCount = core.scene.entities.count();
183
+
184
+ const origInputManager = core.scene.inputManager;
185
+ let callCount = 0;
186
+ const inputs = [5, { selectedItemIndex: 0 }, new Point(5, 5)];
187
+ core.scene.inputManager = {
188
+ requestInput: async () => {
189
+ if (callCount < inputs.length) {
190
+ return inputs[callCount++];
191
+ }
192
+ },
193
+ actionCommand: () => {
194
+ offset.action();
195
+ },
196
+ executeCommand: () => {},
197
+ reset: () => {},
198
+ };
199
+
200
+ const offset = new Offset();
201
+ await offset.execute();
202
+
203
+ expect(core.scene.entities.count()).toBe(entityCount + 1);
204
+
205
+ core.scene.inputManager = origInputManager;
206
+ });
207
+
208
+ test('Test Offset.action - offset line perpendicular', () => {
209
+ core.scene.clear();
210
+ core.scene.selectionManager.reset();
211
+
212
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
213
+
214
+ const offset = new Offset();
215
+ offset.selectedItem = core.scene.entities.get(0);
216
+ core.scene.headers.offsetDistance = 5;
217
+ offset.points = [new Point(5, 5)]; // side point above line
218
+
219
+ offset.action();
220
+
221
+ expect(core.scene.entities.count()).toBe(2);
222
+
223
+ const newLine = core.scene.entities.get(1);
224
+ expect(newLine.points[0].x).toBeCloseTo(0);
225
+ expect(newLine.points[0].y).toBeCloseTo(5);
226
+ expect(newLine.points[1].x).toBeCloseTo(10);
227
+ expect(newLine.points[1].y).toBeCloseTo(5);
228
+ });
229
+
230
+ test('Test Offset.action - offset vertical line', () => {
231
+ core.scene.clear();
232
+ core.scene.selectionManager.reset();
233
+
234
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(0, 10)] });
235
+
236
+ const offset = new Offset();
237
+ offset.selectedItem = core.scene.entities.get(0);
238
+ core.scene.headers.offsetDistance = 3;
239
+ offset.points = [new Point(5, 5)]; // side point to the right
240
+
241
+ offset.action();
242
+
243
+ const newLine = core.scene.entities.get(1);
244
+ expect(newLine.points[0].x).toBeCloseTo(3);
245
+ expect(newLine.points[0].y).toBeCloseTo(0);
246
+ expect(newLine.points[1].x).toBeCloseTo(3);
247
+ expect(newLine.points[1].y).toBeCloseTo(10);
248
+ });
249
+
250
+ test('Test Offset.action - offset circle outward', () => {
251
+ core.scene.clear();
252
+ core.scene.selectionManager.reset();
253
+
254
+ core.scene.addItem('Circle', { points: [new Point(0, 0), new Point(10, 0)] });
255
+
256
+ const offset = new Offset();
257
+ offset.selectedItem = core.scene.entities.get(0);
258
+ core.scene.headers.offsetDistance = 5;
259
+ offset.points = [new Point(20, 0)]; // outside the circle
260
+
261
+ offset.action();
262
+
263
+ expect(core.scene.entities.count()).toBe(2);
264
+
265
+ const newCircle = core.scene.entities.get(1);
266
+ // Centre should remain the same
267
+ expect(newCircle.points[0].x).toBeCloseTo(0);
268
+ expect(newCircle.points[0].y).toBeCloseTo(0);
269
+ // New radius should be 15
270
+ expect(newCircle.radius).toBeCloseTo(15);
271
+ });
272
+
273
+ test('Test Offset.action - offset circle inward', () => {
274
+ core.scene.clear();
275
+ core.scene.selectionManager.reset();
276
+
277
+ core.scene.addItem('Circle', { points: [new Point(0, 0), new Point(10, 0)] });
278
+
279
+ const offset = new Offset();
280
+ offset.selectedItem = core.scene.entities.get(0);
281
+ core.scene.headers.offsetDistance = 3;
282
+ offset.points = [new Point(5, 0)]; // inside the circle
283
+
284
+ offset.action();
285
+
286
+ const newCircle = core.scene.entities.get(1);
287
+ expect(newCircle.points[0].x).toBeCloseTo(0);
288
+ expect(newCircle.points[0].y).toBeCloseTo(0);
289
+ expect(newCircle.radius).toBeCloseTo(7);
290
+ });
291
+
292
+ test('Test Offset.action - offset circle inward returns null when radius would be zero', () => {
293
+ core.scene.clear();
294
+ core.scene.selectionManager.reset();
295
+
296
+ core.scene.addItem('Circle', { points: [new Point(0, 0), new Point(10, 0)] });
297
+
298
+ const offset = new Offset();
299
+ offset.selectedItem = core.scene.entities.get(0);
300
+ core.scene.headers.offsetDistance = 15; // larger than radius
301
+ offset.points = [new Point(5, 0)]; // inside
302
+
303
+ offset.action();
304
+
305
+ // No new entity should be created
306
+ expect(core.scene.entities.count()).toBe(1);
307
+ });
308
+
309
+ test('Test Offset.action - offset arc outward preserves angles', () => {
310
+ core.scene.clear();
311
+ core.scene.selectionManager.reset();
312
+
313
+ const center = new Point(0, 0);
314
+ const start = center.project(0, 10); // radius 10
315
+ const end = center.project(Math.PI / 2, 10);
316
+
317
+ core.scene.addItem('Arc', { points: [center, start, end] });
318
+
319
+ const offset = new Offset();
320
+ offset.selectedItem = core.scene.entities.get(0);
321
+ core.scene.headers.offsetDistance = 5;
322
+ offset.points = [new Point(20, 0)]; // outside
323
+
324
+ offset.action();
325
+
326
+ const newArc = core.scene.entities.get(1);
327
+ // New radius should be 15
328
+ const newRadius = newArc.points[0].distance(newArc.points[1]);
329
+ expect(newRadius).toBeCloseTo(15);
330
+
331
+ // Start and end angles should be preserved
332
+ const originalStartAngle = center.angle(start);
333
+ const originalEndAngle = center.angle(end);
334
+ const newStartAngle = newArc.points[0].angle(newArc.points[1]);
335
+ const newEndAngle = newArc.points[0].angle(newArc.points[2]);
336
+ expect(newStartAngle).toBeCloseTo(originalStartAngle);
337
+ expect(newEndAngle).toBeCloseTo(originalEndAngle);
338
+ });
339
+
340
+ test('Test Offset.action - unsupported entity type does not create new entity', () => {
341
+ core.scene.clear();
342
+ core.scene.selectionManager.reset();
343
+
344
+ core.scene.addItem('Rectangle', { points: [new Point(0, 0), new Point(10, 10)] });
345
+
346
+ const offset = new Offset();
347
+ offset.selectedItem = core.scene.entities.get(0);
348
+ core.scene.headers.offsetDistance = 5;
349
+ offset.points = [new Point(20, 20)];
350
+
351
+ offset.action();
352
+
353
+ // No new entity should be created for unsupported types
354
+ expect(core.scene.entities.count()).toBe(1);
355
+ });
356
+
357
+ test('Test Offset.getOffsetPoints - diagonal line via polyline path', () => {
358
+ const offset = new Offset();
359
+
360
+ const entity = new Line({ points: [new Point(0, 0), new Point(10, 10)] });
361
+ const sidePoint = new Point(0, 10); // above-left
362
+ const result = offset.getOffsetPoints(entity, sidePoint, 5);
363
+
364
+ expect(result).not.toBeNull();
365
+ // Reconstruct and check the offset line
366
+ const rebuilt = new Line({});
367
+ rebuilt.fromPolylinePoints(result);
368
+
369
+ // For a 45° line, the normal offset of 5 should shift by ~3.535 in each axis
370
+ const expected = 5 / Math.sqrt(2);
371
+ expect(rebuilt.points[0].x).toBeCloseTo(-expected);
372
+ expect(rebuilt.points[0].y).toBeCloseTo(expected);
373
+ expect(rebuilt.points[1].x).toBeCloseTo(10 - expected);
374
+ expect(rebuilt.points[1].y).toBeCloseTo(10 + expected);
375
+ });
376
+
377
+ test('Test Offset.getThroughDistance - line', () => {
378
+ const offset = new Offset();
379
+ const entity = new Line({ points: [new Point(0, 0), new Point(10, 0)] });
380
+
381
+ const throughPoint = new Point(5, 7);
382
+ const result = offset.getThroughDistance(entity, throughPoint);
383
+ expect(result).toBeCloseTo(7);
384
+ });
385
+
386
+ test('Test Offset.getThroughDistance - circle', () => {
387
+ const offset = new Offset();
388
+ const entity = new Circle({ points: [new Point(0, 0), new Point(10, 0)] });
389
+
390
+ const throughPoint = new Point(15, 0);
391
+ const result = offset.getThroughDistance(entity, throughPoint);
392
+ expect(result).toBeCloseTo(5);
393
+ });
394
+
395
+ // ─── getThroughDistance ───────────────────────────────────────────────────────
396
+
397
+ test('Test Offset.getThroughDistance - arc uses same radial distance as circle', () => {
398
+ const offset = new Offset();
399
+ // Arc: center (0,0), radius 10
400
+ const entity = new Arc({ points: [new Point(0, 0), new Point(10, 0), new Point(0, 10)] });
401
+
402
+ // Point inside the arc radius
403
+ const throughPoint = new Point(4, 0);
404
+ const result = offset.getThroughDistance(entity, throughPoint);
405
+ expect(result).toBeCloseTo(6); // |4 - 10|
406
+ });
407
+
408
+ test('Test Offset.getThroughDistance - polyline uses closestPoint distance', () => {
409
+ core.scene.clear();
410
+ core.scene.selectionManager.reset();
411
+ core.scene.addItem('Polyline', { points: [new Point(0, 0), new Point(10, 0)] });
412
+ const entity = core.scene.entities.get(0);
413
+
414
+ const offset = new Offset();
415
+ const throughPoint = new Point(5, 3);
416
+ const result = offset.getThroughDistance(entity, throughPoint);
417
+ expect(result).toBeCloseTo(3);
418
+ });
419
+
420
+ test('Test Offset.getThroughDistance - unsupported entity returns 0', () => {
421
+ const offset = new Offset();
422
+ const entity = { points: [] };
423
+ expect(offset.getThroughDistance(entity, new Point(5, 5))).toBe(0);
424
+ });
425
+
426
+ // ─── action edge cases ────────────────────────────────────────────────────────
427
+
428
+ test('Test Offset.action - returns early when selectedItem is null', () => {
429
+ core.scene.clear();
430
+ core.scene.selectionManager.reset();
431
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
432
+
433
+ const offset = new Offset();
434
+ offset.selectedItem = null;
435
+ core.scene.headers.offsetDistance = 5;
436
+ offset.action();
437
+
438
+ expect(core.scene.entities.count()).toBe(1);
439
+ });
440
+
441
+ test('Test Offset.action - returns early when offsetDistance is 0', () => {
442
+ core.scene.clear();
443
+ core.scene.selectionManager.reset();
444
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
445
+
446
+ const offset = new Offset();
447
+ offset.selectedItem = core.scene.entities.get(0);
448
+ core.scene.headers.offsetDistance = 0;
449
+ offset.points = [new Point(5, 5)];
450
+ offset.action();
451
+
452
+ expect(core.scene.entities.count()).toBe(1);
453
+ });
454
+
455
+ // ─── getOffsetPoints - arc ────────────────────────────────────────────────────
456
+
457
+ test('Test Offset.getOffsetPoints - arc inward offset shrinks radius', () => {
458
+ const offset = new Offset();
459
+ const entity = new Arc({ points: [new Point(0, 0), new Point(10, 0), new Point(0, 10)] });
460
+
461
+ // sidePoint inside the arc (between center and circumference)
462
+ const result = offset.getOffsetPoints(entity, new Point(5, 0), 3);
463
+
464
+ expect(result).not.toBeNull();
465
+ const rebuilt = new Arc({});
466
+ rebuilt.fromPolylinePoints(result);
467
+ expect(rebuilt.radius).toBeCloseTo(7);
468
+ });
469
+
470
+ test('Test Offset.getOffsetPoints - arc returns null when inward offset collapses radius', () => {
471
+ const offset = new Offset();
472
+ const entity = new Arc({ points: [new Point(0, 0), new Point(10, 0), new Point(0, 10)] });
473
+
474
+ // distance (15) > radius (10) when going inward
475
+ const result = offset.getOffsetPoints(entity, new Point(5, 0), 15);
476
+
477
+ expect(result).toBeNull();
478
+ });
479
+
480
+ // ─── getOffsetPolylinePoints ──────────────────────────────────────────────────
481
+
482
+ test('Test Offset.getOffsetPolylinePoints - returns null for fewer than 2 points', () => {
483
+ const offset = new Offset();
484
+ expect(offset.getOffsetPolylinePoints([new Point(0, 0)], false, new Point(0, 5), 1)).toBeNull();
485
+ });
486
+
487
+ test('Test Offset.getOffsetPolylinePoints - open L-shape offset produces 3 points', () => {
488
+ const offset = new Offset();
489
+ // L-shape: (0,0) → (10,0) → (10,10)
490
+ const points = [new Point(0, 0), new Point(10, 0), new Point(10, 10)];
491
+
492
+ // sidePoint above the bottom segment → offset upward/left
493
+ const result = offset.getOffsetPolylinePoints(points, false, new Point(5, 2), 1);
494
+
495
+ expect(result).not.toBeNull();
496
+ expect(result.length).toBe(3);
497
+ // First point: start of bottom segment offset upward
498
+ expect(result[0].x).toBeCloseTo(0);
499
+ expect(result[0].y).toBeCloseTo(1);
500
+ // Last point: end of right segment offset left
501
+ expect(result[2].x).toBeCloseTo(9);
502
+ expect(result[2].y).toBeCloseTo(10);
503
+ });
504
+
505
+ test('Test Offset.getOffsetPolylinePoints - open polyline offset to other side', () => {
506
+ const offset = new Offset();
507
+ const points = [new Point(0, 0), new Point(10, 0), new Point(10, 10)];
508
+
509
+ // sidePoint below the bottom segment → offset downward/right
510
+ const result = offset.getOffsetPolylinePoints(points, false, new Point(5, -2), 1);
511
+
512
+ expect(result).not.toBeNull();
513
+ expect(result.length).toBe(3);
514
+ expect(result[0].y).toBeCloseTo(-1);
515
+ expect(result[2].x).toBeCloseTo(11);
516
+ });
517
+
518
+ test('Test Offset.getOffsetPolylinePoints - closed rectangle offset outward', () => {
519
+ const offset = new Offset();
520
+ // Closed rectangle: (0,0) → (10,0) → (10,10) → (0,10), closed
521
+ const points = [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)];
522
+
523
+ // sidePoint outside the left edge
524
+ const result = offset.getOffsetPolylinePoints(points, true, new Point(-5, 5), 1);
525
+
526
+ expect(result).not.toBeNull();
527
+ expect(result.length).toBe(4); // closed: junction per segment, same count as point count
528
+ // All result corners should be further from the origin than the originals
529
+ const xs = result.map((p) => p.x);
530
+ const ys = result.map((p) => p.y);
531
+ expect(Math.min(...xs)).toBeCloseTo(-1);
532
+ expect(Math.max(...xs)).toBeCloseTo(11);
533
+ expect(Math.min(...ys)).toBeCloseTo(-1);
534
+ expect(Math.max(...ys)).toBeCloseTo(11);
535
+ });
536
+
537
+ test('Test Offset.getOffsetPolylinePoints - arc segment (bulge) offset outward grows radius', () => {
538
+ const offset = new Offset();
539
+ // CW semicircle (bulge=-1) from (0,0) to (10,0): traces the TOP half through (5,5)
540
+ // center=(5,0), radius=5
541
+ const start = new Point(0, 0, -1); // bulge=-1 → CW, arc goes upward
542
+ const end = new Point(10, 0);
543
+ const points = [start, end];
544
+
545
+ // sidePoint above the arc (outside), offset outward by 1 → new radius = 6
546
+ const result = offset.getOffsetPolylinePoints(points, false, new Point(5, 10), 1);
547
+
548
+ expect(result).not.toBeNull();
549
+ expect(result.length).toBe(2);
550
+ const arcCenter = new Point(5, 0);
551
+ const newRadius = arcCenter.distance(result[0]);
552
+ expect(newRadius).toBeCloseTo(6);
553
+ });
554
+
555
+ // ─── findJunction ─────────────────────────────────────────────────────────────
556
+
557
+ test('Offset.findJunction - line/line returns intersection point', () => {
558
+ const offset = new Offset();
559
+ // Two offset line segments that would meet at (5, 2)
560
+ const seg = { A: new Point(0, 2), B: new Point(10, 2), bulge: 0 };
561
+ const nextSeg = { A: new Point(5, 0), B: new Point(5, 10), bulge: 0 };
562
+ const result = offset.findJunction(seg, nextSeg);
563
+ expect(result.x).toBeCloseTo(5);
564
+ expect(result.y).toBeCloseTo(2);
565
+ });
566
+
567
+ test('Offset.findJunction - parallel line/line falls back to midpoint', () => {
568
+ const offset = new Offset();
569
+ // Parallel lines — no intersection, midpoint between seg.B and nextSeg.A
570
+ const seg = { A: new Point(0, 1), B: new Point(4, 1), bulge: 0 };
571
+ const nextSeg = { A: new Point(6, 1), B: new Point(10, 1), bulge: 0 };
572
+ const result = offset.findJunction(seg, nextSeg);
573
+ expect(result.x).toBeCloseTo(5);
574
+ expect(result.y).toBeCloseTo(1);
575
+ });
576
+
577
+ test('Offset.findJunction - arc/line returns circle-line intersection', () => {
578
+ const offset = new Offset();
579
+ // Arc: A=(-5,0) B=(5,0) bulge=1 → center (0,0), radius 5 via bulgeCentrePoint
580
+ // Line: vertical at x=3 → circle intersects at (3,4) and (3,-4)
581
+ const seg = { A: new Point(-5, 0), B: new Point(5, 0), bulge: 1 };
582
+ const nextSeg = { A: new Point(3, 5), B: new Point(3, 10), bulge: 0 };
583
+ const result = offset.findJunction(seg, nextSeg);
584
+ // midpoint of seg.B(5,0) and nextSeg.A(3,5) = (4,2.5) → closest intersection is (3,4)
585
+ expect(result.x).toBeCloseTo(3);
586
+ expect(result.y).toBeCloseTo(4);
587
+ });
588
+
589
+ test('Offset.findJunction - line/arc returns line-circle intersection', () => {
590
+ const offset = new Offset();
591
+ // Line: y=0. Arc: A=(10,3) B=(0,3) bulge=1 → center (5,3), radius 5 via bulgeCentrePoint
592
+ // Circle intersects y=0 at x=1 and x=9
593
+ const seg = { A: new Point(0, 0), B: new Point(6, 0), bulge: 0 };
594
+ const nextSeg = { A: new Point(10, 3), B: new Point(0, 3), bulge: 1 };
595
+ const result = offset.findJunction(seg, nextSeg);
596
+ // midpoint of seg.B(6,0) and nextSeg.A(10,3) = (8,1.5) → closest intersection is (9,0)
597
+ expect(result.x).toBeCloseTo(9);
598
+ expect(result.y).toBeCloseTo(0);
599
+ });
600
+
601
+ test('Offset.findJunction - arc/arc returns circle-circle intersection', () => {
602
+ const offset = new Offset();
603
+ // Circle 1: A=(-4,0) B=(4,0) bulge=1 → center (0,0), radius 4 via bulgeCentrePoint
604
+ // Circle 2: A=(0,4) B=(6,4) bulge=1 → center (3,4), radius 3 via bulgeCentrePoint
605
+ // Intersections: (0,4) and (96/25, 28/25)
606
+ const seg = { A: new Point(-4, 0), B: new Point(4, 0), bulge: 1 };
607
+ const nextSeg = { A: new Point(0, 4), B: new Point(6, 4), bulge: 1 };
608
+ const result = offset.findJunction(seg, nextSeg);
609
+ // midpoint of seg.B(4,0) and nextSeg.A(0,4) = (2,2) → closest is (96/25, 28/25) ≈ (3.84, 1.12)
610
+ expect(result.x).toBeCloseTo(3.84);
611
+ expect(result.y).toBeCloseTo(1.12);
612
+ });
613
+
614
+ test('Offset.findJunction - non-intersecting arc/arc falls back to midpoint', () => {
615
+ const offset = new Offset();
616
+ // Two circles too far apart to intersect
617
+ // A=(0,2) B=(4,2) bulge=1 → center (2,2), radius 2 via bulgeCentrePoint
618
+ // A=(16,4) B=(20,4) bulge=1 → center (18,4), radius 2 via bulgeCentrePoint
619
+ const seg = { A: new Point(0, 2), B: new Point(4, 2), bulge: 1 };
620
+ const nextSeg = { A: new Point(16, 4), B: new Point(20, 4), bulge: 1 };
621
+ const result = offset.findJunction(seg, nextSeg);
622
+ expect(result.x).toBeCloseTo(10);
623
+ expect(result.y).toBeCloseTo(3);
624
+ });
625
+
626
+ // ─── preselection ───────────────────────────────────────────────────────────
627
+
628
+ test('Offset.execute - single preselection skips entity selection prompt', async () => {
629
+ core.scene.clear();
630
+ core.scene.selectionManager.reset();
631
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
632
+ // Preselect the entity
633
+ core.scene.selectionManager.addToSelectionSet(0);
634
+
635
+ const origInputManager = core.scene.inputManager;
636
+ let callCount = 0;
637
+ // Only distance and side-point inputs — no selection prompt should be requested
638
+ const inputs = [5, new Point(5, 5)];
639
+ const offset = new Offset();
640
+ core.scene.inputManager = {
641
+ requestInput: async () => {
642
+ if (callCount < inputs.length) return inputs[callCount++];
643
+ },
644
+ actionCommand: () => {
645
+ offset.action();
646
+ },
647
+ executeCommand: () => {},
648
+ reset: () => {},
649
+ };
650
+
651
+ await offset.execute();
652
+
653
+ expect(core.scene.entities.count()).toBe(2);
654
+ core.scene.inputManager = origInputManager;
655
+ });
656
+
657
+ test('Offset.execute - multi-preselection clears selection and prompts', async () => {
658
+ core.scene.clear();
659
+ core.scene.selectionManager.reset();
660
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
661
+ core.scene.addItem('Line', { points: [new Point(0, 5), new Point(10, 5)] });
662
+ // Preselect two entities
663
+ core.scene.selectionManager.addToSelectionSet(0);
664
+ core.scene.selectionManager.addToSelectionSet(1);
665
+
666
+ const origInputManager = core.scene.inputManager;
667
+ let callCount = 0;
668
+ // After clearing, distance + selection + side-point
669
+ const inputs = [5, { selectedItemIndex: 0 }, new Point(5, 5)];
670
+ const offset = new Offset();
671
+ core.scene.inputManager = {
672
+ requestInput: async () => {
673
+ if (callCount < inputs.length) return inputs[callCount++];
674
+ },
675
+ actionCommand: () => {
676
+ offset.action();
677
+ },
678
+ executeCommand: () => {},
679
+ reset: () => {},
680
+ };
681
+
682
+ await offset.execute();
683
+
684
+ expect(core.scene.entities.count()).toBe(3);
685
+ core.scene.inputManager = origInputManager;
686
+ });
687
+
688
+ // ─── execute edge cases ───────────────────────────────────────────────────────
689
+
690
+ test('Offset.execute - distance <= 0 notifies and does not offset', async () => {
691
+ core.scene.clear();
692
+ core.scene.selectionManager.reset();
693
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
694
+
695
+ const notifySpy = jest.spyOn(core, 'notify').mockImplementation(() => {});
696
+
697
+ const origInputManager = core.scene.inputManager;
698
+ core.scene.inputManager = {
699
+ requestInput: async () => -5,
700
+ executeCommand: () => {},
701
+ reset: () => {},
702
+ };
703
+
704
+ const offset = new Offset();
705
+ await offset.execute();
706
+
707
+ expect(notifySpy).toHaveBeenCalled();
708
+ expect(core.scene.entities.count()).toBe(1);
709
+
710
+ notifySpy.mockRestore();
711
+ core.scene.inputManager = origInputManager;
712
+ });
713
+
714
+ test('Offset.execute - Through mode offsets entity to the through point distance', async () => {
715
+ core.scene.clear();
716
+ core.scene.selectionManager.reset();
717
+ core.scene.addItem('Line', { points: [new Point(0, 0), new Point(10, 0)] });
718
+
719
+ const origInputManager = core.scene.inputManager;
720
+ let callCount = 0;
721
+ // 'Through' → select entity at index 0 → through point at (5, 7) → undefined exits loop
722
+ const inputs = ['Through', { selectedItemIndex: 0 }, new Point(5, 7)];
723
+
724
+ const offset = new Offset();
725
+ core.scene.inputManager = {
726
+ requestInput: async () => {
727
+ if (callCount < inputs.length) return inputs[callCount++];
728
+ },
729
+ actionCommand: () => {
730
+ offset.action();
731
+ },
732
+ executeCommand: () => {},
733
+ reset: () => {},
734
+ };
735
+
736
+ await offset.execute();
737
+
738
+ // getThroughDistance(line, (5,7)) = perpendicular distance = 7
739
+ expect(core.scene.headers.offsetDistance).toBeCloseTo(7);
740
+ // Offset entity should have been created
741
+ expect(core.scene.entities.count()).toBe(2);
742
+ // The new line should be at y ≈ 7
743
+ const newLine = core.scene.entities.get(1);
744
+ expect(newLine.points[0].y).toBeCloseTo(7);
745
+ expect(newLine.points[1].y).toBeCloseTo(7);
746
+
747
+ core.scene.inputManager = origInputManager;
748
+ });