@toolpath/dfm 0.4.0 → 1.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.
@@ -0,0 +1,3422 @@
1
+ // src/model/dfm-metrics.ts
2
+ import { MM_PER_INCH } from "@toolpath/tool-support";
3
+
4
+ // src/model/direction-label.ts
5
+ var EPSILON = 1e-6;
6
+ var trim = (value) => Number.parseFloat(value.toFixed(3)).toString();
7
+ var directionLabel = (direction) => {
8
+ const axes = [
9
+ ["X", direction.x],
10
+ ["Y", direction.y],
11
+ ["Z", direction.z]
12
+ ];
13
+ const nonZero = axes.filter(([, value]) => Math.abs(value) > EPSILON);
14
+ const [axis] = nonZero;
15
+ if (nonZero.length === 1 && axis && Math.abs(Math.abs(axis[1]) - 1) < EPSILON) {
16
+ return `${axis[1] > 0 ? "+" : "\u2212"}${axis[0]}`;
17
+ }
18
+ return axes.map(([, value]) => trim(value)).join(", ");
19
+ };
20
+
21
+ // src/model/geometry.ts
22
+ var dfmFeatures = (features) => {
23
+ const read = features.map(
24
+ ({ featureTag, featureType, machiningDirection: { x, y, z }, regionIdxs }) => ({
25
+ tag: featureTag,
26
+ featureType,
27
+ machiningDirection: { x, y, z },
28
+ regionIdxs
29
+ })
30
+ );
31
+ const directions = [];
32
+ read.forEach(({ machiningDirection }) => {
33
+ if (!directions.some((each) => sameDirection(each, machiningDirection)))
34
+ directions.push(machiningDirection);
35
+ });
36
+ const numbered = directions.flatMap(
37
+ (direction) => read.filter((feature) => sameDirection(feature.machiningDirection, direction))
38
+ );
39
+ const numberOf = new Map(numbered.map((feature, index) => [feature, index + 1]));
40
+ return read.map((feature) => ({ ...feature, number: numberOf.get(feature) ?? 0 }));
41
+ };
42
+ var TOLERANCE = 1e-6;
43
+ var sameDirection = (a, b) => Math.abs(a.x - b.x) < TOLERANCE && Math.abs(a.y - b.y) < TOLERANCE && Math.abs(a.z - b.z) < TOLERANCE;
44
+ var featureTypeLabel = (featureType) => {
45
+ const words = featureType.replace(/_/g, " ").trim();
46
+ return words.charAt(0).toUpperCase() + words.slice(1);
47
+ };
48
+ var partTop = (features, sheets, direction) => {
49
+ let top = null;
50
+ for (const other of features) {
51
+ const zMax = sheets[other.tag.toLowerCase()]?.extendedZMax;
52
+ if (zMax !== void 0 && sameDirection(other.machiningDirection, direction)) {
53
+ top = top === null ? zMax : Math.max(top, zMax);
54
+ }
55
+ }
56
+ return top;
57
+ };
58
+ var FLAT_TIP_DEG = 0.5;
59
+ var isDrilled = (sheet) => sheet?.diameter !== void 0 && sheet.diameter > 0 && !(sheet.tipAngle !== void 0 && sheet.tipAngle >= 180 - FLAT_TIP_DEG);
60
+ var featureLd = (features, feature, sheets) => {
61
+ const sheet = sheets[feature.tag.toLowerCase()];
62
+ const top = sheet ? partTop(features, sheets, feature.machiningDirection) : null;
63
+ const bottom = sheet?.zMin;
64
+ if (!sheet || top === null || bottom === void 0) return null;
65
+ const drilling = isDrilled(sheet);
66
+ const [basis, across] = sheet.diameter !== void 0 ? ["bore", sheet.diameter] : sheet.pinchDiameter !== void 0 ? ["pinch", sheet.pinchDiameter] : [
67
+ "radius",
68
+ sheet.sharpCorners || sheet.cornerRadius === void 0 ? void 0 : 2 * sheet.cornerRadius
69
+ ];
70
+ if (across === void 0 || across <= 0) return null;
71
+ const atLeast = basis !== "bore" && sheet.cornerRadiusUnresolved === true;
72
+ return {
73
+ ratio: (top - bottom) / across,
74
+ ...sheet.zMax !== void 0 ? { featureRatio: (sheet.zMax - bottom) / across } : {},
75
+ drilling,
76
+ atLeast,
77
+ basis,
78
+ across
79
+ };
80
+ };
81
+
82
+ // src/model/dfm-metrics.ts
83
+ var define = ({ start, path, ...spec }) => ({
84
+ ...spec,
85
+ start: typeof start === "number" ? { mm: start, inch: start } : start,
86
+ ...path === void 0 ? {} : { path: typeof path === "string" ? [path] : path }
87
+ });
88
+ var SMALL = { mm: 1, inch: 1.27 };
89
+ var TOOL = { mm: 3, inch: 3.175 };
90
+ var DEEP = { mm: 50, inch: 50.8 };
91
+ var AREA = { mm: 1e3, inch: 645.16 };
92
+ var cd = (band, bound) => [
93
+ `facts.cd.${band}.${bound}`,
94
+ `facts.three.cd.${band}.${bound}`
95
+ ];
96
+ var ALWAYS = "Always";
97
+ var SIZE = "Size and depth";
98
+ var TOOLS = "Tool access";
99
+ var AREAS = "Surface area";
100
+ var MAKE = "Machining";
101
+ var HOLE = "Holes";
102
+ var THREAD = "Threads";
103
+ var CHAMFER = "Chamfers";
104
+ var OUTLINES = "Floors, fillets and outlines";
105
+ var FACES = "Faces and profiles";
106
+ var SURFACE = "3D surfaces";
107
+ var UNDERCUT = "Undercuts";
108
+ var PRESENCE_METRIC = define({
109
+ key: "present",
110
+ label: "any size",
111
+ group: ALWAYS,
112
+ quantity: "presence",
113
+ op: "gte",
114
+ start: 0,
115
+ hint: "Flags every feature of this type, whatever its geometry"
116
+ });
117
+ var LD_METRIC = define({
118
+ key: "ld",
119
+ label: "L/D to top of part",
120
+ group: SIZE,
121
+ quantity: "ratio",
122
+ op: "gte",
123
+ start: 5,
124
+ hint: "How far down the tool reaches: depth below the top of the part over its minimum clearance \u2014 a hole\u2019s over its bore; twice its inside corner radius where there are no pinch points. None for a sharp corner with no clearance"
125
+ });
126
+ var FEATURE_LD_METRIC = define({
127
+ key: "featureLd",
128
+ label: "feature L/D",
129
+ group: SIZE,
130
+ quantity: "ratio",
131
+ op: "gte",
132
+ start: 5,
133
+ hint: "The feature\u2019s own depth (its top to its bottom) over the same clearance as L/D to top of part: what its walls ask of the cutter, however deep in the part it sits"
134
+ });
135
+ var FEATURE_METRICS = [
136
+ PRESENCE_METRIC,
137
+ LD_METRIC,
138
+ FEATURE_LD_METRIC,
139
+ define({
140
+ key: "depthBelowTop",
141
+ label: "depth below top",
142
+ group: SIZE,
143
+ quantity: "length",
144
+ op: "gte",
145
+ start: DEEP,
146
+ hint: "How far below the top of the part the feature bottoms out"
147
+ }),
148
+ define({
149
+ key: "featureDepth",
150
+ label: "feature depth",
151
+ group: SIZE,
152
+ quantity: "length",
153
+ op: "gte",
154
+ start: { mm: 25, inch: MM_PER_INCH },
155
+ hint: "The feature\u2019s own depth, top to bottom"
156
+ }),
157
+ define({
158
+ key: "faceCount",
159
+ label: "number of faces",
160
+ group: SIZE,
161
+ quantity: "count",
162
+ op: "gte",
163
+ start: 10,
164
+ hint: "How many faces of the model make it up"
165
+ }),
166
+ define({
167
+ key: "zMin",
168
+ label: "bottom (tool-axis Z)",
169
+ group: SIZE,
170
+ quantity: "length",
171
+ op: "lte",
172
+ start: 0,
173
+ signed: true,
174
+ path: "zMin",
175
+ hint: "The feature\u2019s bottom along its tool axis, in the Engine\u2019s frame"
176
+ }),
177
+ define({
178
+ key: "zMax",
179
+ label: "top (tool-axis Z)",
180
+ group: SIZE,
181
+ quantity: "length",
182
+ op: "gte",
183
+ start: 0,
184
+ signed: true,
185
+ path: "zMax",
186
+ hint: "The feature\u2019s top along its tool axis, in the Engine\u2019s frame"
187
+ }),
188
+ define({
189
+ key: "extendedZMin",
190
+ label: "pass bottom (tool-axis Z)",
191
+ group: SIZE,
192
+ quantity: "length",
193
+ op: "lte",
194
+ start: 0,
195
+ signed: true,
196
+ path: "extendedZMin",
197
+ hint: "Where a pass over it runs out: its bottom, or lower where a chamfer on a far edge deepens it"
198
+ }),
199
+ define({
200
+ key: "extendedZMax",
201
+ label: "pass top (tool-axis Z)",
202
+ group: SIZE,
203
+ quantity: "length",
204
+ op: "gte",
205
+ start: 0,
206
+ signed: true,
207
+ path: "extendedZMax",
208
+ hint: "Where a pass over it starts: its top, or higher where a chamfer or fillet sits on its mouth"
209
+ }),
210
+ define({
211
+ key: "reachHeight",
212
+ label: "material height beside it",
213
+ group: SIZE,
214
+ quantity: "length",
215
+ op: "gte",
216
+ start: DEEP,
217
+ hint: "The highest the material stands anywhere outboard of it (its reach curve), which a tool\u2019s holder must clear"
218
+ }),
219
+ define({
220
+ key: "maxTool",
221
+ label: "largest tool that fits",
222
+ group: TOOLS,
223
+ quantity: "length",
224
+ op: "lte",
225
+ start: TOOL,
226
+ path: cd("ignore", "min"),
227
+ hint: "The widest cutter that still reaches every point"
228
+ }),
229
+ define({
230
+ key: "cornerRadius",
231
+ label: "inside corner radius",
232
+ group: TOOLS,
233
+ quantity: "length",
234
+ op: "lte",
235
+ start: SMALL,
236
+ hint: "The radius of its tightest corner between walls, as drawn \u2014 zero when sharp. Not a floor fillet"
237
+ }),
238
+ define({
239
+ key: "maxToolSomewhere",
240
+ label: "largest tool that fits anywhere",
241
+ group: TOOLS,
242
+ quantity: "length",
243
+ op: "lte",
244
+ start: TOOL,
245
+ path: cd("ignore", "max"),
246
+ hint: "The widest cutter that fits somewhere in it"
247
+ }),
248
+ define({
249
+ key: "maxToolDeviate",
250
+ label: "largest tool within tolerance",
251
+ group: TOOLS,
252
+ quantity: "length",
253
+ op: "lte",
254
+ start: TOOL,
255
+ path: cd("deviate", "min"),
256
+ hint: "The widest cutter that reaches every point, allowing the reported tolerance band"
257
+ }),
258
+ define({
259
+ key: "maxToolAdaptive",
260
+ label: "largest adaptive tool",
261
+ group: TOOLS,
262
+ quantity: "length",
263
+ op: "lte",
264
+ start: TOOL,
265
+ path: cd("effectiveAdaptive", "min"),
266
+ hint: "The widest cutter that reaches every point once adaptive-cutting allowances are applied"
267
+ }),
268
+ define({
269
+ key: "terminalCornerRadius",
270
+ label: "finishing corner radius limit",
271
+ group: TOOLS,
272
+ quantity: "length",
273
+ op: "lte",
274
+ start: SMALL,
275
+ path: ["facts.cd.terminalCornerRadius", "facts.three.cd.terminalCornerRadius"],
276
+ hint: "The corner radius a finishing tool must not exceed"
277
+ }),
278
+ define({
279
+ key: "maxBottomDiameter",
280
+ label: "largest tool bottom",
281
+ group: TOOLS,
282
+ quantity: "length",
283
+ op: "lte",
284
+ start: TOOL,
285
+ path: ["facts.maxBottomDiameter", "facts.three.maxBottomDiameter"],
286
+ kinds: ["Pocket", "Boss", "Face", "Three", "Chamfer"],
287
+ hint: "The widest bottom a finishing tool may have"
288
+ }),
289
+ define({
290
+ key: "pinchPoints",
291
+ label: "tight spots",
292
+ group: TOOLS,
293
+ quantity: "count",
294
+ op: "gte",
295
+ start: 2,
296
+ hint: "How many places it narrows to its tightest clearance (the Engine reports up to ten)"
297
+ }),
298
+ define({
299
+ key: "pinchDiameter",
300
+ label: "minimum clearance",
301
+ group: TOOLS,
302
+ quantity: "length",
303
+ op: "lte",
304
+ start: TOOL,
305
+ hint: "The widest circle that fits where it is tightest: its smallest pinch disc, the Engine\u2019s tolerance taken out. Only for the kinds the Engine reports pinch points for; a sharp corner is its own rule"
306
+ }),
307
+ define({
308
+ key: "sharpCorner",
309
+ label: "a sharp inside corner",
310
+ group: TOOLS,
311
+ quantity: "flag",
312
+ op: "gte",
313
+ start: 0,
314
+ hint: "A vertical corner between its walls with no radius, which no end mill makes. Not a floor fillet"
315
+ }),
316
+ define({
317
+ key: "noToolFits",
318
+ label: "no room for any tool",
319
+ group: TOOLS,
320
+ quantity: "flag",
321
+ op: "gte",
322
+ start: 0,
323
+ hint: "No cutter fits anywhere in it"
324
+ }),
325
+ define({
326
+ key: "tilted",
327
+ label: "a tilted tool axis",
328
+ group: TOOLS,
329
+ quantity: "flag",
330
+ op: "gte",
331
+ start: 0,
332
+ hint: "Machined along a direction that is not one of the part\u2019s six square ones: a 5-axis setup"
333
+ }),
334
+ define({
335
+ key: "surfaceArea",
336
+ label: "surface area",
337
+ group: AREAS,
338
+ quantity: "area",
339
+ op: "gte",
340
+ start: AREA,
341
+ hint: "Its whole surface: the Engine\u2019s partition of it, or its faces\u2019 areas together"
342
+ }),
343
+ define({
344
+ key: "projectedFloorArea",
345
+ label: "floor area (projected)",
346
+ group: AREAS,
347
+ quantity: "area",
348
+ op: "gte",
349
+ start: AREA,
350
+ path: ["projectedFloorArea", "floorishArea"],
351
+ hint: "Area machined floor-wise, square to the tool axis"
352
+ }),
353
+ define({
354
+ key: "projectedWallArea",
355
+ label: "wall area (projected)",
356
+ group: AREAS,
357
+ quantity: "area",
358
+ op: "gte",
359
+ start: AREA,
360
+ path: ["projectedWallArea", "wallishArea"],
361
+ hint: "Area machined wall-wise, along the tool axis"
362
+ }),
363
+ define({
364
+ key: "floorArea",
365
+ label: "floor surface",
366
+ group: AREAS,
367
+ quantity: "area",
368
+ op: "gte",
369
+ start: AREA,
370
+ path: "areas.floorArea",
371
+ hint: "Surface square to the tool axis"
372
+ }),
373
+ define({
374
+ key: "wallArea",
375
+ label: "wall surface",
376
+ group: AREAS,
377
+ quantity: "area",
378
+ op: "gte",
379
+ start: AREA,
380
+ path: "areas.wallArea",
381
+ hint: "Surface running along the tool axis"
382
+ }),
383
+ define({
384
+ key: "filletArea",
385
+ label: "fillet surface",
386
+ group: AREAS,
387
+ quantity: "area",
388
+ op: "gte",
389
+ start: AREA,
390
+ path: "areas.filletArea",
391
+ hint: "The concave rounds between floors and walls"
392
+ }),
393
+ define({
394
+ key: "chamferArea",
395
+ label: "chamfer surface",
396
+ group: AREAS,
397
+ quantity: "area",
398
+ op: "gte",
399
+ start: AREA,
400
+ path: "areas.chamferArea",
401
+ hint: "The bevels"
402
+ }),
403
+ define({
404
+ key: "contourArea",
405
+ label: "3D surface",
406
+ group: AREAS,
407
+ quantity: "area",
408
+ op: "gte",
409
+ start: AREA,
410
+ path: "areas.contourArea",
411
+ hint: "Surface with no flat to stand on"
412
+ }),
413
+ define({
414
+ key: "hasFloor",
415
+ label: "a floor",
416
+ group: MAKE,
417
+ quantity: "flag",
418
+ op: "gte",
419
+ start: 0,
420
+ path: "hasFloor",
421
+ hint: "It has a floor machined square to the tool axis"
422
+ }),
423
+ define({
424
+ key: "hasWall",
425
+ label: "a wall",
426
+ group: MAKE,
427
+ quantity: "flag",
428
+ op: "gte",
429
+ start: 0,
430
+ path: "hasWall",
431
+ hint: "It has a wall machined along the tool axis"
432
+ }),
433
+ define({
434
+ key: "axialStock",
435
+ label: "axial stock to leave",
436
+ group: MAKE,
437
+ quantity: "length",
438
+ op: "gte",
439
+ start: { mm: 0.1, inch: 0.127 },
440
+ path: "axialStockToLeave",
441
+ hint: "Material left along the tool axis for a later operation"
442
+ }),
443
+ define({
444
+ key: "radialStock",
445
+ label: "radial stock to leave",
446
+ group: MAKE,
447
+ quantity: "length",
448
+ op: "gte",
449
+ start: { mm: 0.1, inch: 0.127 },
450
+ path: "radialStockToLeave",
451
+ hint: "Material left radially for a later operation"
452
+ }),
453
+ define({
454
+ key: "tolIgnore",
455
+ label: "ignored tolerance",
456
+ group: MAKE,
457
+ quantity: "length",
458
+ op: "lte",
459
+ start: { mm: 0.01, inch: 0.0127 },
460
+ path: "toleranceBand.atolIgnore",
461
+ hint: "Deviation at or below this is ignored"
462
+ }),
463
+ define({
464
+ key: "tolDeviate",
465
+ label: "reported tolerance",
466
+ group: MAKE,
467
+ quantity: "length",
468
+ op: "lte",
469
+ start: { mm: 0.02, inch: 0.0254 },
470
+ path: "toleranceBand.atolDeviate",
471
+ hint: "Deviation up to this is reported"
472
+ }),
473
+ define({
474
+ key: "tolMax",
475
+ label: "tolerance",
476
+ group: MAKE,
477
+ quantity: "length",
478
+ op: "lte",
479
+ start: { mm: 0.05, inch: 0.0508 },
480
+ path: "toleranceBand.atolMax",
481
+ hint: "The most it may deviate from the model"
482
+ }),
483
+ define({
484
+ key: "passLength",
485
+ label: "pass length",
486
+ group: OUTLINES,
487
+ quantity: "length",
488
+ op: "gte",
489
+ start: { mm: 500, inch: 508 },
490
+ path: ["facts.wallLength.length", "facts.length"],
491
+ kinds: ["Pocket", "Boss", "Wall", "Profile"],
492
+ hint: "How far a pass around its outline travels, with a tool of no width"
493
+ }),
494
+ define({
495
+ key: "diameter",
496
+ label: "diameter",
497
+ group: HOLE,
498
+ quantity: "length",
499
+ op: "lte",
500
+ start: { mm: 1.5, inch: 1.5875 },
501
+ path: "facts.diameter",
502
+ kinds: ["Hole"],
503
+ hint: "What the hole is drilled to \u2014 narrower than modelled for a threaded hole"
504
+ }),
505
+ define({
506
+ key: "coneAngle",
507
+ label: "tip angle",
508
+ group: HOLE,
509
+ quantity: "angle",
510
+ op: "lte",
511
+ start: 118,
512
+ path: "facts.fullConeDeg",
513
+ kinds: ["Hole"],
514
+ hint: "The full angle of the cone at its bottom; 180\xB0 for a flat bottom"
515
+ }),
516
+ define({
517
+ key: "counterbore",
518
+ label: "a counterbore",
519
+ group: HOLE,
520
+ quantity: "flag",
521
+ op: "gte",
522
+ start: 0,
523
+ path: "facts.isCounterbore",
524
+ kinds: ["Hole"],
525
+ hint: "A larger bore at its mouth"
526
+ }),
527
+ define({
528
+ key: "maxSpot",
529
+ label: "largest spot drill",
530
+ group: HOLE,
531
+ quantity: "length",
532
+ op: "lte",
533
+ start: TOOL,
534
+ path: "facts.maxSpotDiameter",
535
+ kinds: ["Hole"],
536
+ hint: "The widest spot drill that reaches it without collision"
537
+ }),
538
+ define({
539
+ key: "minDrill",
540
+ label: "smallest drill",
541
+ group: HOLE,
542
+ quantity: "length",
543
+ op: "gte",
544
+ start: TOOL,
545
+ path: "facts.minDrillDiameter",
546
+ kinds: ["Hole"],
547
+ hint: "The narrowest drill that leaves it within its undersize"
548
+ }),
549
+ define({
550
+ key: "maxDrill",
551
+ label: "largest drill",
552
+ group: HOLE,
553
+ quantity: "length",
554
+ op: "lte",
555
+ start: TOOL,
556
+ path: "facts.maxDrillDiameter",
557
+ kinds: ["Hole"],
558
+ hint: "The widest drill that leaves it within its oversize"
559
+ }),
560
+ define({
561
+ key: "maxEndmill",
562
+ label: "largest endmill",
563
+ group: HOLE,
564
+ quantity: "length",
565
+ op: "lte",
566
+ start: TOOL,
567
+ path: "facts.maxEndmillDiameter",
568
+ kinds: ["Hole"],
569
+ hint: "The widest endmill that can machine it"
570
+ }),
571
+ define({
572
+ key: "floorFillet",
573
+ label: "floor fillet",
574
+ group: OUTLINES,
575
+ quantity: "length",
576
+ op: "lte",
577
+ start: { mm: 0.5, inch: 0.396875 },
578
+ path: "facts.filletRadius",
579
+ kinds: ["Hole", "Pocket", "Boss", "Dovetail", "Tslot"],
580
+ hint: "The radius where the floor meets the walls; zero when sharp"
581
+ }),
582
+ define({
583
+ key: "filletHeight",
584
+ label: "floor fillet height",
585
+ group: OUTLINES,
586
+ quantity: "length",
587
+ op: "gte",
588
+ start: SMALL,
589
+ path: "facts.filletHeight",
590
+ kinds: ["Hole", "Pocket", "Boss"],
591
+ hint: "How high the floor blend runs; zero when sharp"
592
+ }),
593
+ define({
594
+ key: "threaded",
595
+ label: "a thread",
596
+ group: THREAD,
597
+ quantity: "flag",
598
+ op: "gte",
599
+ start: 0,
600
+ path: "facts.threading.spec.threadPitch",
601
+ kinds: ["Hole"],
602
+ hint: "The model threads it"
603
+ }),
604
+ define({
605
+ key: "threadPitch",
606
+ label: "thread pitch",
607
+ group: THREAD,
608
+ quantity: "length",
609
+ op: "lte",
610
+ start: { mm: 0.5, inch: 0.4536 },
611
+ path: "facts.threading.spec.threadPitch",
612
+ kinds: ["Hole"],
613
+ hint: "A modelled thread\u2019s pitch (0.454 mm is 56 TPI)"
614
+ }),
615
+ define({
616
+ key: "threadDiameter",
617
+ label: "thread size",
618
+ group: THREAD,
619
+ quantity: "length",
620
+ op: "lte",
621
+ start: { mm: 3, inch: 3.175 },
622
+ path: "facts.threading.spec.basicDiameter",
623
+ kinds: ["Hole"],
624
+ hint: "The thread\u2019s nominal major diameter"
625
+ }),
626
+ define({
627
+ key: "threadMinorMin",
628
+ label: "thread minor diameter (min)",
629
+ group: THREAD,
630
+ quantity: "length",
631
+ op: "lte",
632
+ start: TOOL,
633
+ path: "facts.threading.spec.minMinorDiameter",
634
+ kinds: ["Hole"],
635
+ hint: "The smallest minor diameter allowed"
636
+ }),
637
+ define({
638
+ key: "threadMinorMax",
639
+ label: "thread minor diameter (max)",
640
+ group: THREAD,
641
+ quantity: "length",
642
+ op: "lte",
643
+ start: TOOL,
644
+ path: "facts.threading.spec.maxMinorDiameter",
645
+ kinds: ["Hole"],
646
+ hint: "The largest minor diameter allowed"
647
+ }),
648
+ define({
649
+ key: "threadMajorMin",
650
+ label: "thread major diameter (min)",
651
+ group: THREAD,
652
+ quantity: "length",
653
+ op: "lte",
654
+ start: TOOL,
655
+ path: "facts.threading.spec.minMajorDiameter",
656
+ kinds: ["Hole"],
657
+ hint: "The smallest major diameter allowed"
658
+ }),
659
+ define({
660
+ key: "threadPercentage",
661
+ label: "thread percentage",
662
+ group: THREAD,
663
+ quantity: "percent",
664
+ op: "gte",
665
+ start: 75,
666
+ path: "facts.threading.spec.threadPercentage",
667
+ kinds: ["Hole"],
668
+ hint: "How much of the theoretical thread depth is to be formed"
669
+ }),
670
+ define({
671
+ key: "chamferAngle",
672
+ label: "chamfer angle",
673
+ group: CHAMFER,
674
+ quantity: "angle",
675
+ op: "gte",
676
+ start: 60,
677
+ path: "facts.bevel.angleDeg",
678
+ kinds: ["Chamfer"],
679
+ hint: "Between the bevel and the tool axis: the half angle of the tool that cuts it. A standard 90\xB0 chamfer mill is 45\xB0"
680
+ }),
681
+ define({
682
+ key: "chamferSlant",
683
+ label: "bevel length",
684
+ group: CHAMFER,
685
+ quantity: "length",
686
+ op: "gte",
687
+ start: { mm: 2, inch: 2.54 },
688
+ path: "facts.bevel.slant",
689
+ kinds: ["Chamfer"],
690
+ hint: "How far the bevel runs along its slope: the cutting edge a tool needs to span it in one pass"
691
+ }),
692
+ define({
693
+ key: "chamferRoom",
694
+ label: "room under the bevel (tool-axis Z)",
695
+ group: CHAMFER,
696
+ quantity: "length",
697
+ op: "gte",
698
+ start: 0,
699
+ signed: true,
700
+ path: "facts.bevel.lowerAdjacentZMin",
701
+ kinds: ["Chamfer"],
702
+ hint: "The highest bottom among the features it stands on: how far a tool can overshoot below it"
703
+ }),
704
+ define({
705
+ key: "openPocketEdge",
706
+ label: "an open pocket edge",
707
+ group: CHAMFER,
708
+ quantity: "flag",
709
+ op: "gte",
710
+ start: 0,
711
+ path: "facts.bevel.isOpenPocketBottom",
712
+ kinds: ["Chamfer"],
713
+ hint: "It breaks the edge along an open pocket\u2019s floor"
714
+ }),
715
+ define({
716
+ key: "countersink",
717
+ label: "a countersink",
718
+ group: CHAMFER,
719
+ quantity: "flag",
720
+ op: "gte",
721
+ start: 0,
722
+ path: "facts.bevel.countersink.outerRadius",
723
+ kinds: ["Chamfer"],
724
+ hint: "The bevel is a countersink of revolution"
725
+ }),
726
+ define({
727
+ key: "countersinkOuter",
728
+ label: "countersink radius",
729
+ group: CHAMFER,
730
+ quantity: "length",
731
+ op: "gte",
732
+ start: TOOL,
733
+ path: "facts.bevel.countersink.outerRadius",
734
+ kinds: ["Chamfer"],
735
+ hint: "The radius it opens to at its upper edge"
736
+ }),
737
+ define({
738
+ key: "countersinkInner",
739
+ label: "countersink pilot radius",
740
+ group: CHAMFER,
741
+ quantity: "length",
742
+ op: "lte",
743
+ start: SMALL,
744
+ path: "facts.bevel.countersink.innerRadius",
745
+ kinds: ["Chamfer"],
746
+ hint: "The radius of the pilot hole it starts from"
747
+ }),
748
+ define({
749
+ key: "topFace",
750
+ label: "the top face",
751
+ group: FACES,
752
+ quantity: "flag",
753
+ op: "gte",
754
+ start: 0,
755
+ path: "facts.isTopFace",
756
+ kinds: ["Face"],
757
+ hint: "The highest surface of the part along the tool axis"
758
+ }),
759
+ define({
760
+ key: "facing",
761
+ label: "facing",
762
+ group: FACES,
763
+ quantity: "flag",
764
+ op: "gte",
765
+ start: 0,
766
+ path: "facts.isFacing",
767
+ kinds: ["Face"],
768
+ hint: "A top face that is to be faced off"
769
+ }),
770
+ define({
771
+ key: "modifiedProfile",
772
+ label: "a bridged outline",
773
+ group: FACES,
774
+ quantity: "flag",
775
+ op: "gte",
776
+ start: 0,
777
+ path: "facts.isModified",
778
+ kinds: ["Profile"],
779
+ hint: "The outline is a bridged reading of the part\u2019s silhouette"
780
+ }),
781
+ define({
782
+ key: "surfaceRadius",
783
+ label: "surface blend radius",
784
+ group: SURFACE,
785
+ quantity: "length",
786
+ op: "lte",
787
+ start: SMALL,
788
+ signed: true,
789
+ path: ["facts.filletRadius", "facts.three.filletRadius"],
790
+ kinds: ["Three", "Chamfer"],
791
+ hint: "Signed by which way it turns: positive over an edge, negative into a corner"
792
+ }),
793
+ define({
794
+ key: "maxStepdown",
795
+ label: "largest stepdown",
796
+ group: SURFACE,
797
+ quantity: "length",
798
+ op: "lte",
799
+ start: SMALL,
800
+ path: ["facts.maxStepdown", "facts.three.maxStepdown"],
801
+ kinds: ["Three", "Chamfer"],
802
+ hint: "The deepest cut taken in one pass down it"
803
+ }),
804
+ define({
805
+ key: "cuspHeight",
806
+ label: "finish scallop",
807
+ group: SURFACE,
808
+ quantity: "length",
809
+ op: "lte",
810
+ start: { mm: 0.01, inch: 0.0127 },
811
+ path: ["facts.surfaceFinishCuspHeight", "facts.three.surfaceFinishCuspHeight"],
812
+ kinds: ["Three", "Chamfer"],
813
+ hint: "How much scallop the finishing pass may leave"
814
+ }),
815
+ define({
816
+ key: "surfaceSharpCorner",
817
+ label: "a sharp surface corner",
818
+ group: SURFACE,
819
+ quantity: "flag",
820
+ op: "gte",
821
+ start: 0,
822
+ path: ["facts.hasSharpCorner", "facts.three.hasSharpCorner"],
823
+ kinds: ["Three", "Chamfer"],
824
+ hint: "It includes a sharp corner a tool must respect"
825
+ }),
826
+ define({
827
+ key: "ballOnly",
828
+ label: "ball-only finishing",
829
+ group: SURFACE,
830
+ quantity: "flag",
831
+ op: "gte",
832
+ start: 0,
833
+ path: ["facts.useOnlyBallToolsForFinish", "facts.three.useOnlyBallToolsForFinish"],
834
+ kinds: ["Three", "Chamfer"],
835
+ hint: "Only ball tools suit the finishing pass"
836
+ }),
837
+ define({
838
+ key: "fitCornerRadius",
839
+ label: "surface corner radius",
840
+ group: SURFACE,
841
+ quantity: "length",
842
+ op: "lte",
843
+ start: SMALL,
844
+ path: ["facts.toolFit.cornerRadius", "facts.three.toolFit.cornerRadius"],
845
+ kinds: ["Three", "Chamfer"],
846
+ hint: "The corner radius its shape admits"
847
+ }),
848
+ define({
849
+ key: "fitToolDiameter",
850
+ label: "largest tool its shape admits",
851
+ group: SURFACE,
852
+ quantity: "length",
853
+ op: "lte",
854
+ start: TOOL,
855
+ path: ["facts.toolFit.toolDiameter", "facts.three.toolFit.toolDiameter"],
856
+ kinds: ["Three", "Chamfer"],
857
+ hint: "The widest full-diameter tool its shape admits"
858
+ }),
859
+ define({
860
+ key: "fitToolBottom",
861
+ label: "largest tool bottom its shape admits",
862
+ group: SURFACE,
863
+ quantity: "length",
864
+ op: "lte",
865
+ start: TOOL,
866
+ path: ["facts.toolFit.toolBottomDiameter", "facts.three.toolFit.toolBottomDiameter"],
867
+ kinds: ["Three", "Chamfer"],
868
+ hint: "The widest tool bottom its shape admits"
869
+ }),
870
+ define({
871
+ key: "undercutDepth",
872
+ label: "undercut depth",
873
+ group: UNDERCUT,
874
+ quantity: "length",
875
+ op: "gte",
876
+ start: { mm: 5, inch: 5.08 },
877
+ path: "facts.undercutDepth",
878
+ kinds: ["Tslot"],
879
+ hint: "How far the groove runs back from its opening"
880
+ }),
881
+ define({
882
+ key: "maxEntry",
883
+ label: "largest tool that gets in",
884
+ group: UNDERCUT,
885
+ quantity: "length",
886
+ op: "lte",
887
+ start: { mm: 6, inch: 6.35 },
888
+ path: "facts.maxEntryCd",
889
+ kinds: ["Tslot"],
890
+ hint: "The widest tool that can come down through the opening above it"
891
+ }),
892
+ define({
893
+ key: "closedSlot",
894
+ label: "a closed outline",
895
+ group: UNDERCUT,
896
+ quantity: "flag",
897
+ op: "gte",
898
+ start: 0,
899
+ path: "facts.isClosed",
900
+ kinds: ["Tslot"],
901
+ hint: "Its walls close on themselves in plan view"
902
+ }),
903
+ define({
904
+ key: "externalUndercut",
905
+ label: "an external undercut",
906
+ group: UNDERCUT,
907
+ quantity: "flag",
908
+ op: "gte",
909
+ start: 0,
910
+ path: "facts.isExternal",
911
+ kinds: ["Tslot", "Dovetail"],
912
+ hint: "It runs around material standing in it, or runs out somewhere"
913
+ }),
914
+ define({
915
+ key: "taper",
916
+ label: "dovetail taper",
917
+ group: UNDERCUT,
918
+ quantity: "angle",
919
+ op: "gte",
920
+ start: 45,
921
+ path: "facts.taperDeg",
922
+ kinds: ["Dovetail"],
923
+ hint: "Between the overhanging wall and the tool axis"
924
+ }),
925
+ define({
926
+ key: "floorWidth",
927
+ label: "dovetail floor width",
928
+ group: UNDERCUT,
929
+ quantity: "length",
930
+ op: "lte",
931
+ start: { mm: 6, inch: 6.35 },
932
+ path: "facts.floorWidth",
933
+ kinds: ["Dovetail"],
934
+ hint: "The widest clearance over the floor"
935
+ }),
936
+ define({
937
+ key: "topOpening",
938
+ label: "dovetail top opening",
939
+ group: UNDERCUT,
940
+ quantity: "length",
941
+ op: "lte",
942
+ start: { mm: 6, inch: 6.35 },
943
+ path: "facts.topOpeningWidth",
944
+ kinds: ["Dovetail"],
945
+ hint: "The clearance through the opening at its top"
946
+ }),
947
+ define({
948
+ key: "bottomOpening",
949
+ label: "dovetail bottom opening",
950
+ group: UNDERCUT,
951
+ quantity: "length",
952
+ op: "lte",
953
+ start: { mm: 6, inch: 6.35 },
954
+ path: "facts.bottomOpeningWidth",
955
+ kinds: ["Dovetail"],
956
+ hint: "The clearance just above the floor, under the overhangs"
957
+ }),
958
+ define({
959
+ key: "invalidDovetail",
960
+ label: "unmeasured geometry",
961
+ group: UNDERCUT,
962
+ quantity: "flag",
963
+ op: "gte",
964
+ start: 0,
965
+ path: "facts.isInvalidGeometry",
966
+ kinds: ["Dovetail"],
967
+ hint: "A width could not be measured, so no tool is offered on its figures"
968
+ })
969
+ ];
970
+ var HOLE_KINDS = ["Hole"];
971
+ var POCKET_KINDS = ["Pocket"];
972
+ var SURFACE_KINDS = ["Three"];
973
+ var UNDERCUT_KINDS = ["Tslot", "Dovetail"];
974
+ var GROUP_SUBJECTS = [
975
+ { value: "any", label: "Any feature", group: "Groups" },
976
+ { value: "milled", label: "Milled features", group: "Groups" },
977
+ { value: "drilled", label: "Drilled features", group: "Groups", kinds: HOLE_KINDS },
978
+ { value: "hole", label: "Holes", group: "Groups", kinds: HOLE_KINDS },
979
+ { value: "threadedHole", label: "Threaded holes", group: "Groups", kinds: HOLE_KINDS },
980
+ { value: "pocket", label: "Pockets", group: "Groups", kinds: POCKET_KINDS },
981
+ { value: "slot", label: "Slots", group: "Groups", kinds: UNDERCUT_KINDS },
982
+ { value: "undercut", label: "Undercuts", group: "Groups", kinds: UNDERCUT_KINDS },
983
+ { value: "boss", label: "Bosses", group: "Groups", kinds: ["Boss"] },
984
+ { value: "countersink", label: "Countersinks", group: "Groups", kinds: ["Hole", "Chamfer"] },
985
+ { value: "profile", label: "Profiles", group: "Groups", kinds: ["Profile"] },
986
+ { value: "contour", label: "3D surfaces", group: "Groups", kinds: SURFACE_KINDS },
987
+ { value: "wall", label: "Walls", group: "Groups", kinds: ["Wall"] },
988
+ { value: "face", label: "Faces", group: "Groups", kinds: ["Face", "Three"] },
989
+ { value: "fillet", label: "Fillets", group: "Groups", kinds: SURFACE_KINDS },
990
+ { value: "chamfer", label: "Chamfers", group: "Groups", kinds: ["Chamfer"] }
991
+ ];
992
+ var TYPES = [
993
+ ["through_hole", "Through holes", HOLE_KINDS],
994
+ ["blind_hole", "Blind holes", HOLE_KINDS],
995
+ ["filleted_blind_hole", "Filleted blind holes", HOLE_KINDS],
996
+ ["tapered_through_hole", "Tapered through holes", HOLE_KINDS],
997
+ ["threaded_through_hole", "Threaded through holes", HOLE_KINDS],
998
+ ["threaded_blind_hole", "Threaded blind holes", HOLE_KINDS],
999
+ ["synthetic_hole", "Synthetic holes", HOLE_KINDS],
1000
+ ["thread", "Threads", HOLE_KINDS],
1001
+ ["sink", "Countersinks", ["Hole", "Chamfer"]],
1002
+ ["back_sink", "Back countersinks", ["Hole", "Chamfer"]],
1003
+ ["pocket", "Closed pockets", POCKET_KINDS],
1004
+ ["open_pocket", "Open pockets", POCKET_KINDS],
1005
+ ["through_pocket", "Through pockets", POCKET_KINDS],
1006
+ ["filleted_pocket", "Filleted pockets", POCKET_KINDS],
1007
+ ["filleted_open_pocket", "Filleted open pockets", POCKET_KINDS],
1008
+ ["boss", "Bosses", ["Boss"]],
1009
+ ["filleted_boss", "Filleted bosses", ["Boss"]],
1010
+ ["wall", "Wall features", ["Wall"]],
1011
+ ["face", "Face features", ["Face"]],
1012
+ ["slanted_face", "Slanted faces", ["Face", "Three"]],
1013
+ ["profile", "Profiles", ["Profile"]],
1014
+ ["chamfer", "Chamfer features", ["Chamfer"]],
1015
+ ["back_chamfer", "Back chamfers", ["Chamfer"]],
1016
+ ["fillet", "Fillet features", SURFACE_KINDS],
1017
+ ["inner_fillet", "Inner fillets", SURFACE_KINDS],
1018
+ ["outer_fillet", "Outer fillets", SURFACE_KINDS],
1019
+ ["contour_surface", "Contour surfaces", SURFACE_KINDS],
1020
+ ["u_slot", "U-slots", UNDERCUT_KINDS],
1021
+ ["undercut_wall", "Undercut walls", UNDERCUT_KINDS],
1022
+ ["undercut_tslot", "Undercut T-slots", ["Tslot"]],
1023
+ ["undercut_filleted_tslot", "Filleted undercut T-slots", ["Tslot"]],
1024
+ ["undercut_dovetail", "Undercut dovetails", ["Dovetail"]],
1025
+ ["undercut_filleted_dovetail", "Filleted undercut dovetails", ["Dovetail"]]
1026
+ ];
1027
+ var TYPE_SUBJECT = "type:";
1028
+ var FEATURE_SUBJECTS = [
1029
+ ...GROUP_SUBJECTS,
1030
+ ...TYPES.map(([type, label, kinds]) => ({
1031
+ value: `${TYPE_SUBJECT}${type}`,
1032
+ label,
1033
+ group: "Feature types",
1034
+ kinds
1035
+ }))
1036
+ ];
1037
+ var QUICK_SUBJECTS = [
1038
+ "any",
1039
+ "milled",
1040
+ "hole",
1041
+ "threadedHole",
1042
+ "countersink",
1043
+ "pocket",
1044
+ "boss",
1045
+ "wall",
1046
+ "face",
1047
+ "profile",
1048
+ "undercut",
1049
+ "fillet",
1050
+ "chamfer",
1051
+ "contour"
1052
+ ];
1053
+ var QUICK_METRICS = [
1054
+ "present",
1055
+ "ld",
1056
+ "featureLd",
1057
+ "depthBelowTop",
1058
+ "featureDepth",
1059
+ "diameter",
1060
+ "cornerRadius",
1061
+ "maxTool",
1062
+ "pinchDiameter",
1063
+ "sharpCorner",
1064
+ "surfaceArea",
1065
+ "floorFillet",
1066
+ "threadPitch",
1067
+ "chamferAngle",
1068
+ "undercutDepth",
1069
+ "tilted"
1070
+ ];
1071
+ var MILLING_RULES = {
1072
+ process: "milling",
1073
+ metrics: FEATURE_METRICS,
1074
+ subjects: FEATURE_SUBJECTS,
1075
+ quickSubjects: QUICK_SUBJECTS,
1076
+ quickMetrics: QUICK_METRICS,
1077
+ fallbackMetric: LD_METRIC,
1078
+ featureLabel: (feature) => ({
1079
+ name: featureTypeLabel(feature.featureType),
1080
+ detail: `from ${directionLabel(feature.machiningDirection)}`
1081
+ }),
1082
+ considerationsTitle: "Milling considerations",
1083
+ fileName: "dfm-rules.json"
1084
+ };
1085
+ var subjectInfo = (value, vocabulary = MILLING_RULES) => vocabulary.subjects.find((each) => each.value === value);
1086
+ var metricInfo = (key, vocabulary = MILLING_RULES) => vocabulary.metrics.find((each) => each.key === key);
1087
+ var metricsForSubject = (subject, vocabulary = MILLING_RULES) => {
1088
+ const kinds = subjectInfo(subject, vocabulary)?.kinds;
1089
+ if (!kinds) return vocabulary.metrics;
1090
+ return vocabulary.metrics.filter(
1091
+ (each) => !each.kinds || each.kinds.some((kind) => kinds.includes(kind))
1092
+ );
1093
+ };
1094
+
1095
+ // src/model/dfm-rules.ts
1096
+ import { MM_PER_INCH as MM_PER_INCH3 } from "@toolpath/tool-support";
1097
+
1098
+ // src/model/units.ts
1099
+ import { MM_PER_INCH as MM_PER_INCH2 } from "@toolpath/tool-support";
1100
+ var LENGTH_PLACES = { mm: 3, inch: 3 };
1101
+ var FIELD_LENGTH_PLACES = { mm: 3, inch: 4 };
1102
+ var UNIT_LABELS = { mm: "mm", inch: "in" };
1103
+ var inUnits = (mm2, units) => units === "inch" ? mm2 / MM_PER_INCH2 : mm2;
1104
+ var lengthValue = (mm2, units) => inUnits(mm2, units).toFixed(LENGTH_PLACES[units]);
1105
+ var unitLabel = (units) => UNIT_LABELS[units];
1106
+
1107
+ // src/model/dfm-rules.ts
1108
+ var EQUAL_TOLERANCE = 5e-3;
1109
+ var OPS = ["gte", "gt", "lte", "lt", "eq", "ne", "between"];
1110
+ var OP_SYMBOLS = {
1111
+ gte: "\u2265",
1112
+ gt: ">",
1113
+ lte: "\u2264",
1114
+ lt: "<",
1115
+ eq: "=",
1116
+ ne: "\u2260",
1117
+ between: "range"
1118
+ };
1119
+ var RANGE_LOWS = ["gte", "gt"];
1120
+ var RANGE_HIGHS = ["lt", "lte"];
1121
+ var opSymbol = (op) => OP_SYMBOLS[op];
1122
+ var RULE_COLORS = [
1123
+ { name: "Red", value: "#e5484d" },
1124
+ { name: "Orange", value: "#f76b15" },
1125
+ { name: "Yellow", value: "#ffd60a" },
1126
+ { name: "Purple", value: "#8e4ec6" },
1127
+ { name: "Green", value: "#30a46c" },
1128
+ { name: "Cyan", value: "#00a2c7" }
1129
+ ];
1130
+ var compareColors = (a, b) => {
1131
+ const rank = (color) => {
1132
+ const at2 = RULE_COLORS.findIndex((each) => each.value === color);
1133
+ return at2 === -1 ? RULE_COLORS.length : at2;
1134
+ };
1135
+ return rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0);
1136
+ };
1137
+ var CRITICAL_COLOR = "#e5484d";
1138
+ var WARNING_COLOR = "#ffd60a";
1139
+ var RETIRED_COLORS = {
1140
+ // Taken out of this palette.
1141
+ "#e93d82": CRITICAL_COLOR,
1142
+ "#99d52a": "#30a46c",
1143
+ // The first palette.
1144
+ "#dc2626": CRITICAL_COLOR,
1145
+ "#f97316": "#f76b15",
1146
+ "#eab308": WARNING_COLOR,
1147
+ "#16a34a": "#30a46c",
1148
+ "#0d9488": "#00a2c7",
1149
+ "#2563eb": "#00a2c7",
1150
+ "#9333ea": "#8e4ec6",
1151
+ "#db2777": CRITICAL_COLOR,
1152
+ // The Toolpath tokens.
1153
+ "#ba5b4b": CRITICAL_COLOR,
1154
+ "#843e32": CRITICAL_COLOR,
1155
+ "#e07a48": "#f76b15",
1156
+ "#d6632a": "#f76b15",
1157
+ "#cd8e04": WARNING_COLOR,
1158
+ "#ffe066": WARNING_COLOR,
1159
+ "#68b688": "#30a46c",
1160
+ "#6bb0b3": "#00a2c7"
1161
+ };
1162
+ var subjectLabel = (value, vocabulary = MILLING_RULES) => subjectInfo(value, vocabulary)?.label ?? value;
1163
+ var trim2 = (value, places) => String(Number(value.toFixed(places)));
1164
+ var SQUARE_MM_PER_SQUARE_INCH = MM_PER_INCH3 ** 2;
1165
+ var quantityUnit = (quantity, units) => {
1166
+ switch (quantity) {
1167
+ case "length":
1168
+ return units === "inch" ? "in" : "mm";
1169
+ case "area":
1170
+ return units === "inch" ? "in\xB2" : "mm\xB2";
1171
+ case "angle":
1172
+ return "\xB0";
1173
+ case "percent":
1174
+ return "%";
1175
+ default:
1176
+ return "";
1177
+ }
1178
+ };
1179
+ var withUnit = (value, unit2) => unit2 === "" ? value : unit2 === "\xB0" || unit2 === "%" ? `${value}${unit2}` : `${value} ${unit2}`;
1180
+ var toUnits = (quantity, value, units) => units !== "inch" ? value : quantity === "length" ? value / MM_PER_INCH3 : quantity === "area" ? value / SQUARE_MM_PER_SQUARE_INCH : value;
1181
+ var toField = (quantity, value, units) => {
1182
+ const shown = toUnits(quantity, value, units);
1183
+ return quantity === "length" ? trim2(shown, FIELD_LENGTH_PLACES[units]) : quantity === "count" ? trim2(shown, 0) : trim2(shown, 3);
1184
+ };
1185
+ var fromField = (metric, text, units) => {
1186
+ const parsed = Number(text.trim());
1187
+ if (text.trim() === "" || !Number.isFinite(parsed) || parsed < 0 && !metric.signed) return null;
1188
+ if (metric.quantity === "count" && !Number.isInteger(parsed)) return null;
1189
+ if (units !== "inch") return parsed;
1190
+ return metric.quantity === "length" ? parsed * MM_PER_INCH3 : metric.quantity === "area" ? parsed * SQUARE_MM_PER_SQUARE_INCH : parsed;
1191
+ };
1192
+ var formatMeasured = (quantity, value, units) => {
1193
+ const shown = toUnits(quantity, value, units);
1194
+ switch (quantity) {
1195
+ case "length":
1196
+ return units === "inch" ? `${trim2(shown, LENGTH_PLACES.inch)}"` : `${trim2(shown, LENGTH_PLACES.mm)} mm`;
1197
+ case "area":
1198
+ return `${Number(shown.toFixed(3)).toLocaleString(void 0, { maximumFractionDigits: 3 })} ${quantityUnit("area", units)}`;
1199
+ case "angle":
1200
+ return `${trim2(shown, 3)}\xB0`;
1201
+ case "percent":
1202
+ return `${trim2(shown, 3)}%`;
1203
+ case "count":
1204
+ return trim2(shown, 0);
1205
+ case "flag":
1206
+ case "presence":
1207
+ return "";
1208
+ default:
1209
+ return trim2(shown, 3);
1210
+ }
1211
+ };
1212
+ var describeRule = (rule, units, vocabulary = MILLING_RULES) => {
1213
+ const info = metricInfo(rule.metric, vocabulary);
1214
+ const subject = subjectLabel(rule.subject, vocabulary);
1215
+ if (!info) return subject;
1216
+ if (info.quantity === "presence") return `${subject}, ${info.label}`;
1217
+ if (info.quantity === "flag") return `${subject} with ${info.label}`;
1218
+ const unit2 = quantityUnit(info.quantity, units);
1219
+ const shown = (value) => withUnit(toField(info.quantity, value, units), unit2);
1220
+ return rule.op === "between" ? `${subject} with ${shown(rule.value)} ${lowOf(rule) === "gt" ? "<" : "\u2264"} ${info.label} ${opSymbol(highOf(rule))} ${shown(upperOf(rule))}` : `${subject} with ${info.label} ${opSymbol(rule.op)} ${shown(rule.value)}`;
1221
+ };
1222
+ var newRuleId = () => Math.random().toString(36).slice(2, 10);
1223
+ var makeRule = (subject, metricKey, units, color = WARNING_COLOR, vocabulary = MILLING_RULES) => {
1224
+ const info = metricInfo(metricKey, vocabulary) ?? vocabulary.fallbackMetric;
1225
+ return {
1226
+ id: newRuleId(),
1227
+ subject,
1228
+ metric: info.key,
1229
+ op: info.op,
1230
+ value: info.start[units],
1231
+ color
1232
+ };
1233
+ };
1234
+ var upperOf = (rule) => rule.max ?? rule.value + Math.max(Math.abs(rule.value), 1);
1235
+ var lowOf = (rule) => rule.lowOp ?? "gte";
1236
+ var highOf = (rule) => rule.highOp ?? "lt";
1237
+ var withoutRange = ({ max: _max, lowOp: _low, highOp: _high, ...rest }) => rest;
1238
+ var withMetric = (rule, metricKey, units, vocabulary = MILLING_RULES) => {
1239
+ const info = metricInfo(metricKey, vocabulary);
1240
+ if (!info) return rule;
1241
+ return { ...withoutRange(rule), metric: info.key, op: info.op, value: info.start[units] };
1242
+ };
1243
+ var withOp = (rule, op) => op === "between" ? { ...rule, op, max: upperOf(rule), lowOp: lowOf(rule), highOp: highOf(rule) } : { ...withoutRange(rule), op };
1244
+ var withSubject = (rule, subject, units, vocabulary = MILLING_RULES) => {
1245
+ const metrics = metricsForSubject(subject, vocabulary);
1246
+ const moved = { ...rule, subject };
1247
+ return metrics.some((each) => each.key === rule.metric) ? moved : withMetric(moved, (metrics[0] ?? vocabulary.fallbackMetric).key, units, vocabulary);
1248
+ };
1249
+ var breaks = (rule, quantity, value) => {
1250
+ if (quantity === "presence") return true;
1251
+ if (quantity === "flag") return value > 0;
1252
+ if (rule.op === "gte") return value >= rule.value;
1253
+ if (rule.op === "gt") return value > rule.value;
1254
+ if (rule.op === "lte") return value <= rule.value;
1255
+ if (rule.op === "lt") return value < rule.value;
1256
+ if (rule.op === "between") {
1257
+ const high = upperOf(rule);
1258
+ const aboveLow = lowOf(rule) === "gt" ? value > rule.value : value >= rule.value;
1259
+ const belowHigh = highOf(rule) === "lte" ? value <= high : value < high;
1260
+ return aboveLow && belowHigh;
1261
+ }
1262
+ const equal = Math.abs(value - rule.value) <= Math.max(Math.abs(rule.value) * EQUAL_TOLERANCE, 1e-6);
1263
+ return rule.op === "ne" ? !equal : equal;
1264
+ };
1265
+ var worseUpward = (rule, metric) => rule.op === "gte" || rule.op === "gt" ? true : rule.op === "lte" || rule.op === "lt" ? false : metric.op !== "lte" && metric.op !== "lt";
1266
+ var COLOR = /^#[0-9a-f]{6}$/i;
1267
+ var isValue = (value, metric) => typeof value === "number" && Number.isFinite(value) && (value >= 0 || metric?.signed === true) && (metric?.quantity !== "count" || Number.isInteger(value));
1268
+ var parseRule = (raw, vocabulary) => {
1269
+ if (!raw || typeof raw !== "object") return null;
1270
+ const {
1271
+ id,
1272
+ subject,
1273
+ metric: key,
1274
+ op,
1275
+ value,
1276
+ max,
1277
+ lowOp,
1278
+ highOp,
1279
+ color
1280
+ } = raw;
1281
+ const info = typeof key === "string" ? metricInfo(key, vocabulary) : void 0;
1282
+ const valid = typeof id === "string" && typeof subject === "string" && subjectInfo(subject, vocabulary) !== void 0 && typeof key === "string" && info !== void 0 && OPS.includes(op) && isValue(value, info) && (op !== "between" || isValue(max, info)) && typeof color === "string" && COLOR.test(color);
1283
+ if (!valid) return null;
1284
+ const hex = color.toLowerCase();
1285
+ return {
1286
+ id,
1287
+ subject,
1288
+ metric: key,
1289
+ op,
1290
+ value,
1291
+ ...op === "between" ? {
1292
+ max,
1293
+ ...RANGE_LOWS.includes(lowOp) ? { lowOp } : {},
1294
+ ...RANGE_HIGHS.includes(highOp) ? { highOp } : {}
1295
+ } : {},
1296
+ color: RETIRED_COLORS[hex] ?? hex
1297
+ };
1298
+ };
1299
+ var parseRules = (raw, vocabulary = MILLING_RULES) => Array.isArray(raw) ? raw.flatMap((each) => parseRule(each, vocabulary) ?? []) : null;
1300
+
1301
+ // src/model/dfm-explain.ts
1302
+ var figureless = (hit) => hit.metric.quantity === "flag" || hit.metric.quantity === "presence";
1303
+ var hitFigure = (hit, units) => figureless(hit) ? "" : formatMeasured(hit.metric.quantity, hit.value, units);
1304
+
1305
+ // src/model/broken-rules.ts
1306
+ var byColor = (hits) => [...hits].sort((a, b) => compareColors(a.rule.color, b.rule.color));
1307
+ var worstHits = (hits) => {
1308
+ const seen = /* @__PURE__ */ new Set();
1309
+ return byColor(hits).filter((hit) => {
1310
+ if (seen.has(hit.metric.key)) return false;
1311
+ seen.add(hit.metric.key);
1312
+ return true;
1313
+ });
1314
+ };
1315
+ var brokenRules = (check, tag, units, vocabulary = MILLING_RULES) => worstHits(check.hitsByTag.get(tag.toLowerCase()) ?? []).map((hit) => {
1316
+ const figure = hitFigure(hit, units);
1317
+ return {
1318
+ key: hit.rule.id,
1319
+ color: hit.rule.color,
1320
+ text: describeRule(hit.rule, units, vocabulary),
1321
+ ...figure ? { figure } : {}
1322
+ };
1323
+ });
1324
+ var featureColor = (check, tag, region = null) => {
1325
+ const key = tag.toLowerCase();
1326
+ const face = region === null ? void 0 : check.paintedFaces.get(region);
1327
+ if (face && face.tag.toLowerCase() === key) return face.color;
1328
+ const whole = check.paintedWhole.get(key);
1329
+ if (whole) return whole.color;
1330
+ if (!check.required.has(key)) return void 0;
1331
+ return byColor(check.hitsByTag.get(key) ?? [])[0]?.rule.color;
1332
+ };
1333
+
1334
+ // src/model/dfm-flags.ts
1335
+ var howCut = (sheet) => {
1336
+ const drilled = isDrilled(sheet);
1337
+ return { drilled, milled: !drilled };
1338
+ };
1339
+ var featureIs = (subject, featureType, sheet) => {
1340
+ const type = featureType.toLowerCase();
1341
+ const kind = sheet?.kind?.toLowerCase() ?? "";
1342
+ if (subject.startsWith(TYPE_SUBJECT)) return type === subject.slice(TYPE_SUBJECT.length);
1343
+ switch (subject) {
1344
+ case "any":
1345
+ return true;
1346
+ case "milled":
1347
+ return howCut(sheet).milled;
1348
+ case "drilled":
1349
+ return howCut(sheet).drilled;
1350
+ case "hole":
1351
+ return type.includes("hole") || kind === "hole";
1352
+ case "threadedHole":
1353
+ return sheet?.threading !== void 0;
1354
+ case "pocket":
1355
+ return type.includes("pocket") || kind === "pocket";
1356
+ case "slot":
1357
+ return type.includes("slot") || kind === "tslot";
1358
+ case "undercut":
1359
+ return kind === "tslot" || type.includes("undercut") || type.includes("tslot");
1360
+ case "contour":
1361
+ return type.includes("contour") || kind.includes("contour");
1362
+ case "wall":
1363
+ return type.includes("wall") || kind === "wall";
1364
+ case "face":
1365
+ return type === "face" || kind === "face";
1366
+ case "fillet":
1367
+ return type.split("_").includes("fillet") || kind.includes("fillet");
1368
+ case "chamfer":
1369
+ return type.includes("chamfer") || kind === "chamfer";
1370
+ case "boss":
1371
+ return type.includes("boss") || kind === "boss";
1372
+ case "countersink":
1373
+ return type === "sink";
1374
+ case "profile":
1375
+ return type === "profile" || kind === "profile";
1376
+ default:
1377
+ return false;
1378
+ }
1379
+ };
1380
+ var at = (record2, path) => path.split(".").reduce(
1381
+ (value, key) => typeof value === "object" && value !== null ? value[key] : void 0,
1382
+ record2
1383
+ );
1384
+ var readPath = (datasheet, metric) => {
1385
+ for (const path of metric.path ?? []) {
1386
+ const value = at(datasheet, path);
1387
+ if (value === void 0 || value === null) continue;
1388
+ if (typeof value === "boolean") return value ? 1 : 0;
1389
+ if (metric.quantity === "flag") return 1;
1390
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1391
+ return null;
1392
+ }
1393
+ return metric.quantity === "flag" && metric.path ? 0 : null;
1394
+ };
1395
+ var TILT = 1e-6;
1396
+ var isTilted = ({ x, y, z }) => [x, y, z].filter((each) => Math.abs(each) > TILT).length > 1;
1397
+ var featureMeasure = (metric, features, feature, { sheets, datasheets, regionAreas }) => {
1398
+ const tag = feature.tag.toLowerCase();
1399
+ const sheet = sheets[tag];
1400
+ const datasheet = datasheets[tag];
1401
+ const positive = (value) => value !== void 0 && value > 0 ? { value } : null;
1402
+ switch (metric.key) {
1403
+ case "present":
1404
+ return { value: 1 };
1405
+ case "tilted":
1406
+ return { value: isTilted(feature.machiningDirection) ? 1 : 0 };
1407
+ case "faceCount":
1408
+ return { value: new Set(feature.regionIdxs).size };
1409
+ case "ld": {
1410
+ const ld = featureLd(features, feature, sheets);
1411
+ return ld ? { value: ld.ratio, drilling: ld.drilling } : null;
1412
+ }
1413
+ case "featureLd": {
1414
+ const ld = featureLd(features, feature, sheets);
1415
+ return ld?.featureRatio !== void 0 ? { value: ld.featureRatio, drilling: ld.drilling } : null;
1416
+ }
1417
+ case "depthBelowTop": {
1418
+ const top = partTop(features, sheets, feature.machiningDirection);
1419
+ return top !== null && sheet?.zMin !== void 0 ? { value: top - sheet.zMin } : null;
1420
+ }
1421
+ case "featureDepth":
1422
+ return sheet?.zMax !== void 0 && sheet.zMin !== void 0 ? { value: sheet.zMax - sheet.zMin } : null;
1423
+ // Zero is no measure at all for these: no bore, no tool, a sharp floor rather than a tiny fillet.
1424
+ case "diameter":
1425
+ return positive(sheet?.diameter);
1426
+ // Zero is a measure here: a sharp corner, which a "radius ≤" rule is most of all about.
1427
+ case "cornerRadius":
1428
+ return sheet?.cornerRadius === void 0 ? null : { value: sheet.cornerRadius };
1429
+ case "maxTool":
1430
+ return positive(sheet?.maxTool);
1431
+ case "floorFillet":
1432
+ return positive(sheet?.filletRadius);
1433
+ case "sharpCorner":
1434
+ return sheet ? { value: sheet.sharpCorners ? 1 : 0 } : null;
1435
+ case "noToolFits":
1436
+ return sheet ? { value: sheet.noToolFits ? 1 : 0 } : null;
1437
+ case "pinchPoints": {
1438
+ const points = at(datasheet, "pinchPoints");
1439
+ return Array.isArray(points) ? { value: points.length } : null;
1440
+ }
1441
+ // A disc under a hundredth is a sharp corner, which `sharpCorner` is for: see `pinchDiameter`.
1442
+ case "pinchDiameter":
1443
+ return positive(sheet?.pinchDiameter);
1444
+ case "reachHeight": {
1445
+ const heights = at(datasheet, "reachCurve.verticalOffset");
1446
+ const numbers2 = Array.isArray(heights) ? heights.filter(
1447
+ (each) => typeof each === "number" && Number.isFinite(each)
1448
+ ) : [];
1449
+ return numbers2.length > 0 ? { value: Math.max(...numbers2) } : null;
1450
+ }
1451
+ case "surfaceArea": {
1452
+ const areas = at(datasheet, "areas");
1453
+ if (typeof areas === "object" && areas !== null) {
1454
+ const parts = Object.values(areas).filter(
1455
+ (each) => typeof each === "number" && Number.isFinite(each)
1456
+ );
1457
+ if (parts.length > 0) return { value: parts.reduce((sum, each) => sum + each, 0) };
1458
+ }
1459
+ const faces = [...new Set(feature.regionIdxs)].flatMap(
1460
+ (region) => regionAreas.get(region) ?? []
1461
+ );
1462
+ return faces.length > 0 ? { value: faces.reduce((sum, each) => sum + each, 0) } : null;
1463
+ }
1464
+ default: {
1465
+ if (!datasheet) return null;
1466
+ const value = readPath(datasheet, metric);
1467
+ return value === null ? null : { value };
1468
+ }
1469
+ }
1470
+ };
1471
+ var featureHits = (features, feature, sources, rules) => {
1472
+ const sheet = sources.sheets[feature.tag.toLowerCase()];
1473
+ return rules.flatMap((rule) => {
1474
+ const info = metricInfo(rule.metric);
1475
+ if (!info || !featureIs(rule.subject, feature.featureType, sheet)) return [];
1476
+ const measured = featureMeasure(info, features, feature, sources);
1477
+ if (!measured || !breaks(rule, info.quantity, measured.value)) return [];
1478
+ return [{ rule, metric: info, ...measured }];
1479
+ });
1480
+ };
1481
+ var requiredTags = (features) => {
1482
+ const owners = /* @__PURE__ */ new Map();
1483
+ for (const feature of features) {
1484
+ for (const region of new Set(feature.regionIdxs))
1485
+ owners.set(region, (owners.get(region) ?? 0) + 1);
1486
+ }
1487
+ return new Set(
1488
+ features.filter((feature) => feature.regionIdxs.some((region) => owners.get(region) === 1)).map((feature) => feature.tag.toLowerCase())
1489
+ );
1490
+ };
1491
+ var worstColor = (hits) => hits?.map((hit) => hit.rule.color).sort(compareColors)[0];
1492
+ var ruleResults = (rules, features, hitsByTag, required) => rules.map((rule) => {
1493
+ const hits = features.flatMap((feature) => {
1494
+ const tag = feature.tag.toLowerCase();
1495
+ const hit = hitsByTag.get(tag)?.find((each) => each.rule.id === rule.id);
1496
+ return hit ? [{ tag: feature.tag, value: hit.value, metric: hit.metric, required: required.has(tag) }] : [];
1497
+ });
1498
+ const [first] = hits;
1499
+ const up = first ? worseUpward(rule, first.metric) : true;
1500
+ hits.sort((a, b) => up ? b.value - a.value : a.value - b.value);
1501
+ return {
1502
+ rule,
1503
+ tags: hits.filter((hit) => hit.required).map((hit) => hit.tag),
1504
+ otherTags: hits.filter((hit) => !hit.required).map((hit) => hit.tag)
1505
+ };
1506
+ });
1507
+ var QUALIFIERS = ["undercut", "filleted", "tslot", "dovetail"];
1508
+ var EDGE_TYPES = ["fillet", "chamfer", "sink"];
1509
+ var featureComplexity = (featureType) => {
1510
+ const words = featureType.toLowerCase().split("_");
1511
+ const edge = EDGE_TYPES.some((type) => words.includes(type)) ? 1 : 0;
1512
+ return edge + QUALIFIERS.filter((word) => words.includes(word)).length;
1513
+ };
1514
+ var CONTOUR = "contour_surface";
1515
+ var unavoidableFaces = (features, hitsByTag, regionAreas) => {
1516
+ const areaOf = (feature) => [...new Set(feature.regionIdxs)].reduce(
1517
+ (sum, region) => sum + (regionAreas.get(region) ?? 0),
1518
+ 0
1519
+ );
1520
+ const owners = /* @__PURE__ */ new Map();
1521
+ for (const feature of features) {
1522
+ for (const region of new Set(feature.regionIdxs)) {
1523
+ owners.set(region, [...owners.get(region) ?? [], feature]);
1524
+ }
1525
+ }
1526
+ const faces = /* @__PURE__ */ new Map();
1527
+ const whole = /* @__PURE__ */ new Map();
1528
+ for (const [region, candidates] of owners) {
1529
+ const colored = candidates.map((feature) => ({
1530
+ feature,
1531
+ color: worstColor(hitsByTag.get(feature.tag.toLowerCase())),
1532
+ area: areaOf(feature)
1533
+ }));
1534
+ const isContour = ({ feature }) => feature.featureType === CONTOUR;
1535
+ const machined = colored.filter((each) => !isContour(each));
1536
+ const pool = machined.length > 0 ? machined : colored;
1537
+ if (pool.some(({ color }) => color === void 0)) continue;
1538
+ const contour = colored.filter(isContour).reduce((kept, each) => kept === void 0 || each.area > kept.area ? each : kept, void 0);
1539
+ if (contour?.color && colored.every(({ color }) => color !== void 0)) {
1540
+ whole.set(contour.feature.tag.toLowerCase(), {
1541
+ tag: contour.feature.tag,
1542
+ color: contour.color
1543
+ });
1544
+ continue;
1545
+ }
1546
+ const best = pool.reduce((kept, each) => {
1547
+ const plainer = featureComplexity(kept.feature.featureType) - featureComplexity(each.feature.featureType);
1548
+ if (plainer !== 0) return plainer > 0 ? each : kept;
1549
+ const milder = compareColors(each.color ?? "", kept.color ?? "");
1550
+ return milder > 0 || milder === 0 && each.area > kept.area ? each : kept;
1551
+ });
1552
+ if (best.color) faces.set(region, { tag: best.feature.tag, color: best.color });
1553
+ }
1554
+ return { paintedFaces: faces, paintedWhole: whole };
1555
+ };
1556
+ var withSurroundedFaces = (check, features, adjacency) => {
1557
+ const paintedFor = new Map(check.paintedFaces);
1558
+ const regionsOf2 = /* @__PURE__ */ new Map();
1559
+ for (const feature of features) {
1560
+ const key = feature.tag.toLowerCase();
1561
+ regionsOf2.set(key, new Set(feature.regionIdxs));
1562
+ const whole = check.paintedWhole.get(key);
1563
+ if (whole) {
1564
+ for (const region of feature.regionIdxs)
1565
+ if (!paintedFor.has(region)) paintedFor.set(region, whole);
1566
+ }
1567
+ }
1568
+ const surrounded = /* @__PURE__ */ new Map();
1569
+ for (const [region, neighbours] of adjacency) {
1570
+ if (paintedFor.has(region)) continue;
1571
+ const counts = /* @__PURE__ */ new Map();
1572
+ for (const neighbour of neighbours) {
1573
+ const paint2 = neighbour === region ? void 0 : paintedFor.get(neighbour);
1574
+ if (!paint2) continue;
1575
+ const key = paint2.tag.toLowerCase();
1576
+ counts.set(key, { paint: paint2, count: (counts.get(key)?.count ?? 0) + 1 });
1577
+ }
1578
+ const most = Math.max(0, ...[...counts.values()].map(({ count }) => count));
1579
+ const [paint] = [...counts].filter(([key, { count }]) => count === most && regionsOf2.get(key)?.has(region)).map(([, { paint: paint2 }]) => paint2).sort((a, b) => compareColors(a.color, b.color));
1580
+ if (paint) surrounded.set(region, paint);
1581
+ }
1582
+ if (surrounded.size === 0) return check;
1583
+ return { ...check, paintedFaces: new Map([...check.paintedFaces, ...surrounded]) };
1584
+ };
1585
+ var checkPart = (features, sources, rules) => {
1586
+ const required = requiredTags(features);
1587
+ const hitsByTag = new Map(
1588
+ features.map((feature) => [
1589
+ feature.tag.toLowerCase(),
1590
+ featureHits(features, feature, sources, rules)
1591
+ ])
1592
+ );
1593
+ return {
1594
+ hitsByTag,
1595
+ required,
1596
+ byRule: ruleResults(rules, features, hitsByTag, required),
1597
+ ...unavoidableFaces(features, hitsByTag, sources.regionAreas)
1598
+ };
1599
+ };
1600
+
1601
+ // src/model/pinch.ts
1602
+ var asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1603
+ var recordOf = (value) => typeof value === "object" && value !== null ? value : {};
1604
+ var clearanceFit = (datasheet) => {
1605
+ const sheet = recordOf(datasheet);
1606
+ const facts = recordOf(sheet.facts);
1607
+ const chamfer = facts.kind === "Chamfer";
1608
+ const band = recordOf(chamfer ? recordOf(facts.three).cd : facts.cd);
1609
+ const tolerance = recordOf(sheet.toleranceBand);
1610
+ const strict = asNumber(recordOf(band.ignore).min);
1611
+ if (strict === void 0) return void 0;
1612
+ const bandPath = chamfer ? "facts.three.cd" : "facts.cd";
1613
+ const loose = asNumber(recordOf(band.deviate).min);
1614
+ const atStrict = asNumber(tolerance.atolIgnore);
1615
+ const atLoose = asNumber(tolerance.atolDeviate);
1616
+ const source = { bandPath, strict, loose, atStrict, atLoose };
1617
+ if (loose === void 0 || atStrict === void 0 || atLoose === void 0 || atLoose <= atStrict)
1618
+ return { ...source, slack: 0, resolved: false };
1619
+ const growth = (loose - strict) / (atLoose - atStrict);
1620
+ const slack = growth > 0 ? Math.min(growth * atStrict, strict) : 0;
1621
+ return { ...source, slack, resolved: true };
1622
+ };
1623
+ var readPinch = (datasheet) => {
1624
+ const sheet = recordOf(datasheet);
1625
+ const zMin = asNumber(sheet.zMin);
1626
+ const zMax = asNumber(sheet.zMax);
1627
+ if (zMin === void 0 || zMax === void 0 || !Array.isArray(sheet.pinchPoints)) return null;
1628
+ const fit = clearanceFit(datasheet);
1629
+ const slack = fit?.slack ?? 0;
1630
+ const discs = sheet.pinchPoints.flatMap((point) => {
1631
+ const centre = recordOf(recordOf(point).center);
1632
+ const x = asNumber(centre.x);
1633
+ const y = asNumber(centre.y);
1634
+ const diameter = asNumber(recordOf(point).diameter);
1635
+ return x === void 0 || y === void 0 || diameter === void 0 || diameter < 0 ? [] : [{ x, y, diameter: Math.max(diameter - slack, 0) }];
1636
+ });
1637
+ if (discs.length === 0) return null;
1638
+ return { discs, zMin, zMax, ...fit && !fit.resolved ? { unresolved: true } : {} };
1639
+ };
1640
+
1641
+ // src/model/feature-sheet.ts
1642
+ var SHARP_MM = 0.1;
1643
+ var SHARP_RADIUS_MM = 0.01;
1644
+ var PINCH_TOOL_MM = 0.01;
1645
+ var asNumber2 = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
1646
+ var recordOf2 = (value) => typeof value === "object" && value !== null ? value : {};
1647
+ var definedOnly = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== void 0));
1648
+ var undercutOf = (facts) => {
1649
+ const unmeasured = facts.isInvalidGeometry === true || recordOf2(facts.cd).measurementFailed === true ? true : void 0;
1650
+ if (facts.kind === "Tslot")
1651
+ return definedOnly({
1652
+ undercutDepth: asNumber2(facts.undercutDepth),
1653
+ maxEntry: asNumber2(facts.maxEntryCd),
1654
+ isClosed: facts.isClosed === true ? true : void 0,
1655
+ unmeasured
1656
+ });
1657
+ if (facts.kind === "Dovetail")
1658
+ return definedOnly({
1659
+ taperDeg: asNumber2(facts.taperDeg),
1660
+ floorWidth: asNumber2(facts.floorWidth),
1661
+ topOpeningWidth: asNumber2(facts.topOpeningWidth),
1662
+ isExternal: facts.isExternal === true ? true : void 0,
1663
+ unmeasured
1664
+ });
1665
+ return void 0;
1666
+ };
1667
+ var threadingOf = (facts) => {
1668
+ const spec = recordOf2(recordOf2(facts.threading).spec);
1669
+ const basicDiameter = asNumber2(spec.basicDiameter);
1670
+ const threadPitch = asNumber2(spec.threadPitch);
1671
+ if (basicDiameter === void 0 || threadPitch === void 0 || threadPitch <= 0) return void 0;
1672
+ return { basicDiameter, threadPitch };
1673
+ };
1674
+ var noToolFits = (band) => {
1675
+ const { max } = band;
1676
+ if (typeof max === "string") return /^-inf/i.test(max.trim());
1677
+ return typeof max === "number" && (max === Number.NEGATIVE_INFINITY || Number.isFinite(max) && max < SHARP_MM);
1678
+ };
1679
+ var insideCornerRadius = (fit) => Math.max((fit.strict - fit.slack) / 2, 0);
1680
+ var pinchCentres = (points) => Array.isArray(points) ? points.flatMap((point) => {
1681
+ const centre = recordOf2(recordOf2(point).center);
1682
+ const x = asNumber2(centre.x);
1683
+ const y = asNumber2(centre.y);
1684
+ return x === void 0 || y === void 0 ? [] : [{ x, y }];
1685
+ }) : [];
1686
+ var ZERO_MM = 5e-5;
1687
+ var isCutterlessTslot = (datasheet) => {
1688
+ const facts = recordOf2(recordOf2(datasheet).facts);
1689
+ if (facts.kind !== "Tslot") return false;
1690
+ const leaves = (value) => typeof value === "object" && value !== null ? Object.values(value).flatMap(leaves) : [value];
1691
+ const band = leaves(facts.cd);
1692
+ return band.length > 0 && band.every((each) => {
1693
+ const value = typeof each === "string" && each.trim() !== "" ? Number(each) : each;
1694
+ return typeof value === "number" && Math.abs(value) < ZERO_MM;
1695
+ });
1696
+ };
1697
+ var isSharpCorneredFillet = (featureType, datasheet) => {
1698
+ if (typeof featureType !== "string" || !featureType.toLowerCase().split("_").includes("fillet"))
1699
+ return false;
1700
+ const facts = recordOf2(recordOf2(datasheet).facts);
1701
+ return facts.hasSharpCorner === true || recordOf2(facts.three).hasSharpCorner === true;
1702
+ };
1703
+ var isNoReading = (featureType, datasheet) => isCutterlessTslot(datasheet) || isSharpCorneredFillet(featureType, datasheet);
1704
+ var featureSheet = (datasheet) => {
1705
+ const sheet = recordOf2(datasheet);
1706
+ const facts = recordOf2(sheet.facts);
1707
+ const kind = typeof facts.kind === "string" ? facts.kind : void 0;
1708
+ const band = recordOf2(kind === "Chamfer" ? recordOf2(facts.three).cd : facts.cd);
1709
+ const ignoreBand = recordOf2(band.ignore);
1710
+ const fit = clearanceFit(datasheet);
1711
+ const fields = {
1712
+ kind,
1713
+ zMin: asNumber2(sheet.zMin),
1714
+ zMax: asNumber2(sheet.zMax),
1715
+ extendedZMax: asNumber2(sheet.extendedZMax),
1716
+ maxTool: asNumber2(ignoreBand.min),
1717
+ diameter: kind === "Hole" ? asNumber2(facts.diameter) : void 0,
1718
+ tipAngle: kind === "Hole" ? asNumber2(facts.fullConeDeg) : void 0,
1719
+ filletRadius: asNumber2(facts.filletRadius),
1720
+ // A bevel's angle wherever the facts carry one: a chamfer's, and any other kind that bevels.
1721
+ chamferAngle: asNumber2(recordOf2(facts.bevel).angleDeg)
1722
+ };
1723
+ const toolFitPath = facts.toolFit !== void 0 ? "facts.toolFit" : "facts.three.toolFit";
1724
+ const toolFit = recordOf2(facts.toolFit ?? recordOf2(facts.three).toolFit);
1725
+ fields.fitToolDiameter = asNumber2(toolFit.toolDiameter);
1726
+ fields.fitCornerRadius = asNumber2(toolFit.cornerRadius);
1727
+ const ballOnly = (facts.useOnlyBallToolsForFinish ?? recordOf2(facts.three).useOnlyBallToolsForFinish) === true;
1728
+ const measured = Object.fromEntries(
1729
+ Object.entries(fields).filter(([, value]) => value !== void 0)
1730
+ );
1731
+ const cramped = Object.keys(ignoreBand).length > 0 && noToolFits(ignoreBand);
1732
+ const walled = kind !== "Hole" && sheet.hasWall === true;
1733
+ const clearance = walled ? fit : void 0;
1734
+ const cornerRadius = clearance ? insideCornerRadius(clearance) : void 0;
1735
+ const sharp = cornerRadius !== void 0 && cornerRadius < SHARP_RADIUS_MM && !cramped;
1736
+ const threading = kind === "Hole" ? threadingOf(facts) : void 0;
1737
+ const undercut = undercutOf(facts);
1738
+ const pinched = (readPinch(datasheet)?.discs ?? []).filter(
1739
+ (disc) => disc.diameter >= PINCH_TOOL_MM
1740
+ );
1741
+ return {
1742
+ ...measured,
1743
+ ...pinched.length > 0 ? {
1744
+ pinchDiameter: Math.min(...pinched.map((disc) => disc.diameter)),
1745
+ pinchPlaces: pinched.length
1746
+ } : {},
1747
+ ...cornerRadius !== void 0 ? { cornerRadius } : {},
1748
+ ...clearance && !clearance.resolved ? { cornerRadiusUnresolved: true } : {},
1749
+ ...clearance ? { clearance } : {},
1750
+ ...Object.keys(toolFit).length > 0 ? { toolFitPath } : {},
1751
+ ...sharp ? { sharpCorners: pinchCentres(sheet.pinchPoints) } : {},
1752
+ ...cramped ? { noToolFits: true } : {},
1753
+ ...ballOnly ? { ballOnly: true } : {},
1754
+ ...threading ? { threading } : {},
1755
+ ...undercut ? { undercut } : {}
1756
+ };
1757
+ };
1758
+
1759
+ // src/model/feature-details.ts
1760
+ import { MM_PER_INCH as MM_PER_INCH4 } from "@toolpath/tool-support";
1761
+ var mm = (value) => `${trim3(value, 4)} mm`;
1762
+ var tol = (value) => `${trim3(value, 6)} mm`;
1763
+ var clearanceDerivation = (fit, clearance) => {
1764
+ const { bandPath, strict, loose, atStrict, atLoose } = fit;
1765
+ const lines = [`\u2300\u2081 = ${bandPath}.ignore.min = ${mm(strict)}`];
1766
+ if (loose === void 0 || atStrict === void 0 || atLoose === void 0 || atLoose <= atStrict) {
1767
+ lines.push(
1768
+ `${bandPath}.deviate.min or a tolerance is missing, so the tolerance cannot be taken out:`,
1769
+ `clearance \u2264 \u2300\u2081 = ${mm(clearance)}`
1770
+ );
1771
+ return lines;
1772
+ }
1773
+ const growth = (loose - strict) / (atLoose - atStrict);
1774
+ lines.push(
1775
+ ` at t\u2081 = toleranceBand.atolIgnore = ${tol(atStrict)}`,
1776
+ `\u2300\u2082 = ${bandPath}.deviate.min = ${mm(loose)}`,
1777
+ ` at t\u2082 = toleranceBand.atolDeviate = ${tol(atLoose)}`
1778
+ );
1779
+ if (growth <= 0) {
1780
+ lines.push("The tool does not grow with tolerance (a gap, not a corner): clearance = \u2300\u2081");
1781
+ } else {
1782
+ lines.push(
1783
+ `k = (\u2300\u2082 \u2212 \u2300\u2081) / (t\u2082 \u2212 t\u2081) = ${trim3(growth, 3)}`,
1784
+ `slack = k \xD7 t\u2081 = ${mm(growth * atStrict)}, what the tolerance adds to every tool`,
1785
+ `clearance = \u2300\u2081 \u2212 slack`
1786
+ );
1787
+ }
1788
+ lines.push(`clearance = ${mm(clearance)}`);
1789
+ return lines;
1790
+ };
1791
+ var trim3 = (value, places) => String(Number(value.toFixed(places)));
1792
+ var formatLength = (mm2, units, inchMark = false) => units === "inch" ? `${trim3(mm2 / MM_PER_INCH4, LENGTH_PLACES.inch)}${inchMark ? '"' : " in"}` : `${trim3(mm2, LENGTH_PLACES.mm)} mm`;
1793
+ var SURFACE_TYPES = /* @__PURE__ */ new Set([
1794
+ "contour_surface",
1795
+ "inner_fillet",
1796
+ "outer_fillet",
1797
+ "fillet"
1798
+ ]);
1799
+ var isSurfaceType = (featureType) => SURFACE_TYPES.has(featureType);
1800
+ var maxBallDiameter = (sheet) => {
1801
+ const { fitToolDiameter: diameter, fitCornerRadius: corner } = sheet;
1802
+ if (corner === void 0 || corner <= 0) return void 0;
1803
+ return diameter !== void 0 && diameter > 0 ? Math.min(diameter, 2 * corner) : 2 * corner;
1804
+ };
1805
+ var ROW_ORDER = [
1806
+ "featureDepth",
1807
+ "undercutWidth",
1808
+ "floorWidth",
1809
+ "openingWidth",
1810
+ "undercutDepth",
1811
+ "taper",
1812
+ "diameter",
1813
+ "cornerRadius",
1814
+ "filletRadius",
1815
+ "floorFillet",
1816
+ "tipAngle",
1817
+ "thread",
1818
+ "bevelAngle",
1819
+ "finishTool",
1820
+ "undercutCutter",
1821
+ "maxShaft",
1822
+ "pinch",
1823
+ "fitCornerRadius",
1824
+ "maxBull",
1825
+ "maxBall",
1826
+ "depthBelowTop",
1827
+ "ld",
1828
+ "featureLd",
1829
+ "topLd"
1830
+ ];
1831
+ var featureMeasurements = ({
1832
+ features,
1833
+ feature,
1834
+ sheets,
1835
+ units,
1836
+ inchMark = false
1837
+ }) => {
1838
+ const other = units === "inch" ? "mm" : "inch";
1839
+ const length = (mm2) => ({
1840
+ value: formatLength(mm2, units, inchMark),
1841
+ alt: formatLength(mm2, other, inchMark)
1842
+ });
1843
+ const sheet = sheets[feature.tag.toLowerCase()];
1844
+ const rows = [];
1845
+ const undercut = sheet?.undercut;
1846
+ const top = sheet ? partTop(features, sheets, feature.machiningDirection) : null;
1847
+ const bottom = sheet?.zMin;
1848
+ const topFrom = top === null ? void 0 : features.find((each) => sheets[each.tag.toLowerCase()]?.extendedZMax === top);
1849
+ const topLine = top === null ? "" : `Top of part = the highest extendedZMax of the features machined from this direction = ${mm(top)}${topFrom ? ` (${featureTypeLabel(topFrom.featureType)} ${topFrom.tag})` : ""}`;
1850
+ if (top !== null && bottom !== void 0) {
1851
+ rows.push({
1852
+ key: "depthBelowTop",
1853
+ label: "Depth below top of part",
1854
+ milling: true,
1855
+ ...length(top - bottom),
1856
+ derivation: [topLine, `Top \u2212 zMin = ${mm(top)} \u2212 ${mm(bottom)} = ${mm(top - bottom)}`]
1857
+ });
1858
+ }
1859
+ if (sheet?.zMax !== void 0 && bottom !== void 0 && !(undercut && sheet?.kind === "Tslot")) {
1860
+ rows.push({
1861
+ key: "featureDepth",
1862
+ label: "Feature depth",
1863
+ ...length(sheet.zMax - bottom),
1864
+ derivation: [`zMax \u2212 zMin = ${mm(sheet.zMax)} \u2212 ${mm(bottom)} = ${mm(sheet.zMax - bottom)}`]
1865
+ });
1866
+ }
1867
+ if (sheet?.diameter !== void 0) {
1868
+ rows.push({
1869
+ key: "diameter",
1870
+ label: "Diameter",
1871
+ ...length(sheet.diameter),
1872
+ derivation: [`facts.diameter = ${mm(sheet.diameter)}`]
1873
+ });
1874
+ }
1875
+ if (sheet?.cornerRadius !== void 0 && !undercut) {
1876
+ const radius = length(sheet.cornerRadius);
1877
+ rows.push({
1878
+ key: "cornerRadius",
1879
+ label: sheet.sharpCorners ? "Minimum radius (sharp)" : "Minimum radius",
1880
+ // Measured at one tolerance only, it is the most the modelled radius can be, not the radius itself.
1881
+ ...sheet.cornerRadiusUnresolved ? { value: `\u2264 ${radius.value}`, alt: `\u2264 ${radius.alt}` } : radius,
1882
+ derivation: [
1883
+ "The tightest corner between walls: half the minimum clearance, the widest tool that reaches every point with the tolerance it was measured to taken out.",
1884
+ ...sheet.clearance ? clearanceDerivation(sheet.clearance, 2 * sheet.cornerRadius) : [],
1885
+ `R = clearance / 2 = ${mm(sheet.cornerRadius)}${sheet.sharpCorners ? " (under 0.01 mm: sharp)" : ""}`
1886
+ ]
1887
+ });
1888
+ }
1889
+ if (!sheet?.sharpCorners && !undercut) {
1890
+ const clearance = sheet?.pinchDiameter ?? (sheet?.cornerRadius !== void 0 ? 2 * sheet.cornerRadius : void 0);
1891
+ if (clearance !== void 0) {
1892
+ const width = length(clearance);
1893
+ const places = sheet?.pinchPlaces;
1894
+ const floor = !SURFACE_TYPES.has(feature.featureType) && sheet?.filletRadius !== void 0 && sheet.filletRadius > 0 ? length(sheet.filletRadius) : void 0;
1895
+ const atMost = sheet?.cornerRadiusUnresolved ? "\u2264 " : "";
1896
+ rows.push({
1897
+ key: "pinch",
1898
+ label: "Max tool diameter",
1899
+ milling: true,
1900
+ // Measured at one tolerance only, it is the most the clearance can be, not the clearance itself.
1901
+ value: `${atMost}\u2300 ${width.value}${floor ? ` \xB7 R ${floor.value}` : ""}`,
1902
+ alt: `${atMost}\u2300 ${width.alt}${floor ? ` \xB7 R ${floor.alt}` : ""}`,
1903
+ derivation: [
1904
+ "The widest circle that fits where the feature is tightest: twice an inside corner\u2019s radius, or the width of a gap between walls.",
1905
+ ...places !== void 0 ? [
1906
+ `pinchPoints: where the feature narrows to its tightest clearance, a disc each; ${places} ${places === 1 ? "place" : "places"} that tight, the tightest drawn.`,
1907
+ `min(pinchPoints[].diameter) is the cutter band\u2019s min, measured to a tolerance (t) the widest tool grows with: run back to none from two tolerances.`
1908
+ ] : [
1909
+ "No pinch points reported: the cutter band\u2019s min, measured to a tolerance (t) the widest tool grows with, run back to none from two tolerances."
1910
+ ],
1911
+ ...sheet?.clearance ? clearanceDerivation(sheet.clearance, clearance) : [],
1912
+ ...floor && sheet?.filletRadius !== void 0 ? [
1913
+ `A bull nose, its corner radius the floor fillet\u2019s: facts.filletRadius = ${mm(sheet.filletRadius)}`
1914
+ ] : []
1915
+ ]
1916
+ });
1917
+ }
1918
+ }
1919
+ if (sheet && undercut) rows.push(...undercutRows(sheet, undercut, top, length));
1920
+ const surface = SURFACE_TYPES.has(feature.featureType);
1921
+ if (surface && sheet) {
1922
+ const diameter = sheet.fitToolDiameter;
1923
+ const corner = sheet.fitCornerRadius;
1924
+ const fit = sheet.toolFitPath ?? "facts.toolFit";
1925
+ const ball = maxBallDiameter(sheet);
1926
+ rows.push({
1927
+ key: "finishTool",
1928
+ label: "Finishing tool",
1929
+ milling: true,
1930
+ value: sheet.ballOnly ? "Ball end mill" : "Ball or bull nose",
1931
+ derivation: [
1932
+ `facts.useOnlyBallToolsForFinish (or facts.three.\u2026) is ${sheet.ballOnly ? "true" : "not true"}`
1933
+ ]
1934
+ });
1935
+ if (corner !== void 0 && corner > 0) {
1936
+ rows.push({
1937
+ key: "fitCornerRadius",
1938
+ label: "Max corner radius",
1939
+ milling: true,
1940
+ ...length(corner),
1941
+ derivation: [
1942
+ "The tightest the surface turns inward: a tool with a larger corner radius cannot follow it.",
1943
+ `${fit}.cornerRadius = ${mm(corner)}`
1944
+ ]
1945
+ });
1946
+ }
1947
+ const sized = diameter !== void 0 && diameter > 0;
1948
+ if (!sheet.ballOnly && corner !== void 0 && corner > 0 && (!sized || ball === void 0 || ball < diameter)) {
1949
+ const size = sized ? length(diameter) : void 0;
1950
+ const radius = length(corner);
1951
+ rows.push({
1952
+ key: "maxBull",
1953
+ label: "Max bull nose",
1954
+ milling: true,
1955
+ // Drawable only at a diameter the fit gives: "any" is no size to draw.
1956
+ ...sized ? { tool: "bull" } : {},
1957
+ value: `${size ? `\u2300 ${size.value}` : "any \u2300"} \xB7 R ${radius.value}`,
1958
+ alt: `${size ? `\u2300 ${size.alt}` : "any \u2300"} \xB7 R ${radius.alt}`,
1959
+ derivation: [
1960
+ sized ? "The largest full-diameter tool the surface\u2019s shape admits, with the largest corner radius it admits." : "The surface limits only the tool\u2019s corner radius, not its diameter: a bull nose of any size with a corner no larger than this.",
1961
+ ...sized ? [`\u2300 = ${fit}.toolDiameter = ${mm(diameter)}`] : [`${fit}.toolDiameter is not reported`],
1962
+ `R = ${fit}.cornerRadius = ${mm(corner)}`
1963
+ ]
1964
+ });
1965
+ }
1966
+ if (ball !== void 0 && corner !== void 0) {
1967
+ const size = length(ball);
1968
+ rows.push({
1969
+ key: "maxBall",
1970
+ label: "Max ball nose",
1971
+ milling: true,
1972
+ tool: "ball",
1973
+ value: `\u2300 ${size.value}`,
1974
+ alt: `\u2300 ${size.alt}`,
1975
+ derivation: [
1976
+ "A ball\u2019s corner radius is half its diameter, so it must fit under the corner limit, and the diameter limit where there is one:",
1977
+ ...diameter !== void 0 && diameter > 0 ? [
1978
+ `min(${fit}.toolDiameter, 2 \xD7 ${fit}.cornerRadius)`,
1979
+ `= min(${mm(diameter)}, ${mm(2 * corner)}) = ${mm(ball)}`
1980
+ ] : [`2 \xD7 ${fit}.cornerRadius = ${mm(ball)} (${fit}.toolDiameter is not reported)`]
1981
+ ]
1982
+ });
1983
+ }
1984
+ }
1985
+ const ld = undercut ? null : featureLd(features, feature, sheets);
1986
+ if (ld && top !== null && bottom !== void 0) {
1987
+ const across = ld.basis === "bore" ? `D = facts.diameter = ${mm(ld.across)}` : ld.basis === "pinch" ? `D = widest tool (the tightest pinch disc, tolerance taken out) = ${mm(ld.across)}${ld.atLeast ? " (at most, so L/D is at least)" : ""}` : `D = widest tool = 2 \xD7 inside corner radius = ${mm(ld.across)}${ld.atLeast ? " (at most, so L/D is at least)" : ""} (no pinch points reported)`;
1988
+ const ratioOf = (depth) => {
1989
+ const ratio2 = (depth / ld.across).toFixed(3);
1990
+ return ld.atLeast ? `\u2265 ${ratio2}` : ratio2;
1991
+ };
1992
+ if (ld.drilling) {
1993
+ rows.push({
1994
+ key: "ld",
1995
+ label: "Drilling L/D",
1996
+ milling: true,
1997
+ value: ratioOf(top - bottom),
1998
+ derivation: [
1999
+ `L = depth below top of part = ${mm(top - bottom)}`,
2000
+ across,
2001
+ `L / D = ${mm(top - bottom)} / ${mm(ld.across)} = ${ld.ratio.toFixed(3)}`
2002
+ ]
2003
+ });
2004
+ } else {
2005
+ if (sheet?.zMax !== void 0) {
2006
+ rows.push({
2007
+ key: "featureLd",
2008
+ label: "Feature L/D",
2009
+ milling: true,
2010
+ value: ratioOf(sheet.zMax - bottom),
2011
+ derivation: [
2012
+ `L = feature depth = zMax \u2212 zMin = ${mm(sheet.zMax - bottom)}`,
2013
+ across,
2014
+ `L / D = ${mm(sheet.zMax - bottom)} / ${mm(ld.across)} = ${((sheet.zMax - bottom) / ld.across).toFixed(3)}`
2015
+ ]
2016
+ });
2017
+ }
2018
+ rows.push({
2019
+ key: "topLd",
2020
+ label: "L/D to top of part",
2021
+ milling: true,
2022
+ value: ratioOf(top - bottom),
2023
+ derivation: [
2024
+ `L = depth below top of part = ${mm(top - bottom)}`,
2025
+ across,
2026
+ `L / D = ${mm(top - bottom)} / ${mm(ld.across)} = ${ld.ratio.toFixed(3)}`
2027
+ ]
2028
+ });
2029
+ }
2030
+ }
2031
+ if (sheet?.tipAngle !== void 0) {
2032
+ const flat = Math.abs(sheet.tipAngle - 180) < 0.05;
2033
+ rows.push({
2034
+ key: "tipAngle",
2035
+ label: "Tip angle",
2036
+ value: `${trim3(sheet.tipAngle, 3)}\xB0${flat ? " (flat)" : ""}`,
2037
+ derivation: [
2038
+ `facts.fullConeDeg = ${trim3(sheet.tipAngle, 3)}\xB0${flat ? " (180\xB0: flat bottom)" : ""}`
2039
+ ]
2040
+ });
2041
+ }
2042
+ if (sheet?.threading) {
2043
+ rows.push({
2044
+ key: "thread",
2045
+ label: "Thread",
2046
+ value: `${formatLength(sheet.threading.basicDiameter, units, inchMark)} \xD7 ${formatLength(sheet.threading.threadPitch, units, inchMark)} pitch`,
2047
+ derivation: [
2048
+ `facts.threading.spec.basicDiameter = ${mm(sheet.threading.basicDiameter)}`,
2049
+ `facts.threading.spec.threadPitch = ${mm(sheet.threading.threadPitch)}`
2050
+ ]
2051
+ });
2052
+ }
2053
+ if (surface) {
2054
+ if (sheet?.filletRadius !== void 0 && sheet.filletRadius !== 0) {
2055
+ rows.push({
2056
+ key: "filletRadius",
2057
+ label: "Fillet radius",
2058
+ ...length(Math.abs(sheet.filletRadius)),
2059
+ derivation: [
2060
+ `|facts.filletRadius| = |${mm(sheet.filletRadius)}| (signed: + over an edge, \u2212 into a corner)`
2061
+ ]
2062
+ });
2063
+ }
2064
+ } else if (sheet?.filletRadius !== void 0 && sheet.filletRadius > 0) {
2065
+ rows.push({
2066
+ key: "floorFillet",
2067
+ label: "Floor fillet radius",
2068
+ ...length(sheet.filletRadius),
2069
+ derivation: [`facts.filletRadius = ${mm(sheet.filletRadius)}`]
2070
+ });
2071
+ }
2072
+ if (sheet?.chamferAngle !== void 0) {
2073
+ rows.push({
2074
+ key: "bevelAngle",
2075
+ label: "Chamfer angle",
2076
+ value: `${sheet.chamferAngle.toFixed(3)}\xB0`,
2077
+ derivation: [`facts.bevel.angleDeg = ${trim3(sheet.chamferAngle, 3)}\xB0`]
2078
+ });
2079
+ }
2080
+ return rows.sort((a, b) => ROW_ORDER.indexOf(a.key) - ROW_ORDER.indexOf(b.key));
2081
+ };
2082
+ var undercutRows = (sheet, undercut, top, length) => {
2083
+ const rows = [];
2084
+ const tslot = sheet.kind === "Tslot";
2085
+ const { zMin, zMax } = sheet;
2086
+ const { floorWidth: floor, topOpeningWidth: opening } = undercut;
2087
+ const angle = undercut.taperDeg !== void 0 ? `${trim3(undercut.taperDeg, 3)}\xB0` : void 0;
2088
+ if (tslot && zMin !== void 0 && zMax !== void 0) {
2089
+ rows.push({
2090
+ key: "undercutWidth",
2091
+ label: "Undercut width",
2092
+ ...length(zMax - zMin),
2093
+ derivation: [
2094
+ "The groove\u2019s height along the tool: the widest cutter it takes in one pass.",
2095
+ `zMax \u2212 zMin = ${mm(zMax)} \u2212 ${mm(zMin)} = ${mm(zMax - zMin)}`
2096
+ ]
2097
+ });
2098
+ }
2099
+ if (floor !== void 0) {
2100
+ rows.push({
2101
+ key: "floorWidth",
2102
+ label: "Floor width",
2103
+ ...length(floor),
2104
+ derivation: ["The widest clearance over the floor", `facts.floorWidth = ${mm(floor)}`]
2105
+ });
2106
+ }
2107
+ if (opening !== void 0) {
2108
+ rows.push({
2109
+ key: "openingWidth",
2110
+ label: "Opening width",
2111
+ ...length(opening),
2112
+ derivation: [
2113
+ "The clearance through the opening at the top of the groove",
2114
+ `facts.topOpeningWidth = ${mm(opening)}`
2115
+ ]
2116
+ });
2117
+ }
2118
+ let depth = undercut.undercutDepth;
2119
+ let depthLine = depth !== void 0 ? `facts.undercutDepth = ${mm(depth)}` : "";
2120
+ if (depth === void 0 && floor !== void 0 && opening !== void 0) {
2121
+ if (floor > opening && !undercut.isExternal) {
2122
+ depth = (floor - opening) / 2;
2123
+ depthLine = `(facts.floorWidth \u2212 facts.topOpeningWidth) / 2 = (${mm(floor)} \u2212 ${mm(opening)}) / 2 = ${mm(depth)}`;
2124
+ }
2125
+ }
2126
+ if (depth !== void 0) {
2127
+ rows.push({
2128
+ key: "undercutDepth",
2129
+ label: "Undercut depth",
2130
+ ...length(depth),
2131
+ derivation: [
2132
+ "How far the groove runs back from its opening: what the cutter\u2019s head reaches past its shaft.",
2133
+ depthLine
2134
+ ]
2135
+ });
2136
+ }
2137
+ if (angle) {
2138
+ rows.push({
2139
+ key: "taper",
2140
+ label: "Dovetail angle",
2141
+ value: `${angle} from the tool axis`,
2142
+ derivation: [`facts.taperDeg = ${angle}, between the overhanging wall and the tool axis`]
2143
+ });
2144
+ }
2145
+ const cutter = (value, derivation, alt) => ({
2146
+ key: "undercutCutter",
2147
+ label: "Max cutting diameter",
2148
+ milling: true,
2149
+ value,
2150
+ ...alt !== void 0 ? { alt } : {},
2151
+ derivation
2152
+ });
2153
+ if (undercut.unmeasured) {
2154
+ return [
2155
+ ...rows,
2156
+ cutter("Not measured", [
2157
+ "The Engine could not measure the groove (facts.isInvalidGeometry or facts.cd.measurementFailed), so its figures offer no cutter."
2158
+ ])
2159
+ ];
2160
+ }
2161
+ if (sheet.noToolFits) {
2162
+ return [
2163
+ ...rows,
2164
+ cutter("None fits", [
2165
+ "facts.cd.ignore.max is next to nothing: no cutter fits anywhere in the groove."
2166
+ ])
2167
+ ];
2168
+ }
2169
+ const band = sheet.maxTool !== void 0 && sheet.maxTool > 0 ? sheet.maxTool : void 0;
2170
+ const dropIn = undercut.isClosed ? undercut.maxEntry : void 0;
2171
+ const head = band === void 0 ? void 0 : Math.min(band, dropIn ?? Infinity);
2172
+ const opensTo = undercut.maxEntry ?? opening;
2173
+ const behind = head !== void 0 && depth !== void 0 ? head - 2 * depth : void 0;
2174
+ const limits = [opensTo, behind].filter((each) => each !== void 0);
2175
+ const shaft = limits.length > 0 ? Math.max(Math.min(...limits), 0) : void 0;
2176
+ const shaftLines = [
2177
+ "The most the cutter\u2019s shaft can be: what comes down through the opening above the groove, and leaves the head reaching the back of it.",
2178
+ ...opensTo !== void 0 ? [`Opening: ${tslot ? "facts.maxEntryCd" : "facts.topOpeningWidth"} = ${mm(opensTo)}`] : [],
2179
+ ...behind !== void 0 && head !== void 0 && depth !== void 0 ? [
2180
+ `Reach: max cutting diameter \u2212 2 \xD7 undercut depth = ${mm(head)} \u2212 2 \xD7 ${mm(depth)} = ${mm(behind)}`
2181
+ ] : [],
2182
+ ...shaft !== void 0 ? [`Max shaft = ${mm(shaft)}`] : []
2183
+ ];
2184
+ if (head !== void 0 && band !== void 0) {
2185
+ const headLines = [
2186
+ "The biggest head that reaches every point of the groove.",
2187
+ `facts.cd.ignore.min = ${mm(band)}`,
2188
+ ...dropIn !== void 0 ? [
2189
+ `A closed slot: the head comes down through the opening above it, facts.maxEntryCd = ${mm(dropIn)}`
2190
+ ] : [],
2191
+ `\u2300 = ${mm(head)}`
2192
+ ];
2193
+ if (head < SHARP_MM || shaft !== void 0 && shaft < SHARP_MM) {
2194
+ return [
2195
+ ...rows,
2196
+ cutter("None fits", [
2197
+ ...headLines,
2198
+ ...shaft !== void 0 ? shaftLines : [],
2199
+ "No head that fits the groove leaves room for a shaft to carry it."
2200
+ ])
2201
+ ];
2202
+ }
2203
+ const size = length(head);
2204
+ const filletRadius = sheet.filletRadius !== void 0 && sheet.filletRadius > 0 ? sheet.filletRadius : void 0;
2205
+ const fillet = filletRadius !== void 0 ? length(filletRadius) : void 0;
2206
+ const describe = (unit2) => [`\u2300 ${size[unit2]}`, ...angle ? [angle] : [], ...fillet ? [`R ${fillet[unit2]}`] : []].join(
2207
+ " \xB7 "
2208
+ );
2209
+ rows.push(
2210
+ cutter(
2211
+ describe("value"),
2212
+ [
2213
+ ...headLines,
2214
+ ...angle ? [`Angle = facts.taperDeg = ${angle}, from the tool axis`] : [],
2215
+ ...filletRadius !== void 0 ? [`R = facts.filletRadius = ${mm(filletRadius)}, the blend the head\u2019s corners cut`] : []
2216
+ ],
2217
+ describe("alt")
2218
+ )
2219
+ );
2220
+ if (top !== null && zMin !== void 0) {
2221
+ const reach = top - zMin;
2222
+ const ratio2 = (reach / head).toFixed(3);
2223
+ rows.push({
2224
+ key: "topLd",
2225
+ label: "L/D to top of part",
2226
+ milling: true,
2227
+ value: ratio2,
2228
+ derivation: [
2229
+ `L = depth below top of part = ${mm(reach)}`,
2230
+ `D = max cutting diameter = ${mm(head)}`,
2231
+ `L / D = ${mm(reach)} / ${mm(head)} = ${ratio2}`
2232
+ ]
2233
+ });
2234
+ }
2235
+ }
2236
+ if (shaft !== void 0 || tslot) {
2237
+ const size = shaft !== void 0 ? length(shaft) : void 0;
2238
+ rows.push({
2239
+ key: "maxShaft",
2240
+ label: "Max shaft diameter",
2241
+ milling: true,
2242
+ ...size ? { value: `\u2300 ${size.value}`, alt: `\u2300 ${size.alt}` } : { value: "No limit" },
2243
+ derivation: size ? shaftLines : [
2244
+ "facts.maxEntryCd is not reported and the head or undercut depth is not known: nothing limits the shaft."
2245
+ ]
2246
+ });
2247
+ }
2248
+ return rows;
2249
+ };
2250
+
2251
+ // src/model/feature-record.ts
2252
+ var MAX_DATASHEET_FIELDS = 500;
2253
+ var formatLeaf = (value) => typeof value === "number" ? String(Number(value.toFixed(4))) : typeof value === "string" ? value : value === null ? "null" : String(value);
2254
+ var datasheetFields = (datasheet) => {
2255
+ const fields = [];
2256
+ let more = false;
2257
+ const walk = (value, path) => {
2258
+ if (fields.length >= MAX_DATASHEET_FIELDS) {
2259
+ more = true;
2260
+ return;
2261
+ }
2262
+ if (Array.isArray(value)) {
2263
+ if (value.every((each) => typeof each !== "object" || each === null)) {
2264
+ fields.push({ path, value: value.length === 0 ? "[]" : value.map(formatLeaf).join(", ") });
2265
+ } else {
2266
+ value.forEach((each, index) => walk(each, `${path}[${index}]`));
2267
+ }
2268
+ } else if (typeof value === "object" && value !== null) {
2269
+ const entries = Object.entries(value);
2270
+ if (entries.length === 0) fields.push({ path, value: "{}" });
2271
+ for (const [key, each] of entries) walk(each, path ? `${path}.${key}` : key);
2272
+ } else {
2273
+ fields.push({ path, value: formatLeaf(value) });
2274
+ }
2275
+ };
2276
+ if (datasheet !== null && datasheet !== void 0) walk(datasheet, "");
2277
+ return { fields, more };
2278
+ };
2279
+
2280
+ // src/model/hole-groups.ts
2281
+ var holeSize = (sheets, tag) => {
2282
+ const sheet = sheets[tag.toLowerCase()];
2283
+ if (sheet?.kind !== "Hole" || sheet.diameter === void 0 || sheet.zMin === void 0 || sheet.zMax === void 0) {
2284
+ return null;
2285
+ }
2286
+ return { diameter: sheet.diameter, depth: sheet.zMax - sheet.zMin };
2287
+ };
2288
+ var SAME_DEPTH = 0.05;
2289
+ var DEPTH_FLOOR = 0.01;
2290
+ var TOLERANCE2 = 1e-6;
2291
+ var sameDepth = (a, b) => Math.abs(a - b) <= Math.max(DEPTH_FLOOR, Math.max(Math.abs(a), Math.abs(b)) * SAME_DEPTH);
2292
+ var sameDirection2 = (a, b) => Math.abs(a.x - b.x) < TOLERANCE2 && Math.abs(a.y - b.y) < TOLERANCE2 && Math.abs(a.z - b.z) < TOLERANCE2;
2293
+ var sameHoleSets = (features, sheets) => {
2294
+ const sizes = features.map((feature) => holeSize(sheets, feature.tag));
2295
+ const root = features.map((_, at2) => at2);
2296
+ const find = (at2) => {
2297
+ let top = at2;
2298
+ while (root[top] !== top) top = root[top] ?? top;
2299
+ root[at2] = top;
2300
+ return top;
2301
+ };
2302
+ for (let a = 0; a < features.length; a += 1) {
2303
+ for (let b = a + 1; b < features.length; b += 1) {
2304
+ const [first, second] = [sizes[a], sizes[b]];
2305
+ const [one, other] = [features[a], features[b]];
2306
+ if (first && second && one && other && sameDirection2(one.machiningDirection, other.machiningDirection) && first.diameter.toFixed(3) === second.diameter.toFixed(3) && sameDepth(first.depth, second.depth)) {
2307
+ const [low, high] = [find(a), find(b)].sort((x, y) => x - y);
2308
+ if (high !== void 0 && low !== void 0) root[high] = low;
2309
+ }
2310
+ }
2311
+ }
2312
+ const sets = /* @__PURE__ */ new Map();
2313
+ features.forEach((feature, at2) => {
2314
+ const top = find(at2);
2315
+ sets.set(top, [...sets.get(top) ?? [], feature]);
2316
+ });
2317
+ return [...sets.values()];
2318
+ };
2319
+ var featureRows = (tags, features, sheets) => {
2320
+ const byTag = new Map(features.map((feature) => [feature.tag, feature]));
2321
+ const listed = tags.flatMap((tag) => byTag.get(tag) ?? []);
2322
+ const holes = listed.filter((feature) => holeSize(sheets, feature.tag));
2323
+ const setOf = /* @__PURE__ */ new Map();
2324
+ for (const set of sameHoleSets(holes, sheets)) {
2325
+ if (set.length > 1) for (const feature of set) setOf.set(feature.tag, set);
2326
+ }
2327
+ const placed = /* @__PURE__ */ new Set();
2328
+ return tags.flatMap((tag) => {
2329
+ const set = setOf.get(tag);
2330
+ if (!set) return [{ kind: "feature", tag }];
2331
+ const [first, ...rest] = set;
2332
+ if (!first || placed.has(first.tag)) return [];
2333
+ placed.add(first.tag);
2334
+ return [
2335
+ {
2336
+ kind: "holes",
2337
+ key: first.tag,
2338
+ tags: [first.tag, ...rest.map((feature) => feature.tag)],
2339
+ diameter: holeSize(sheets, first.tag)?.diameter ?? 0
2340
+ }
2341
+ ];
2342
+ });
2343
+ };
2344
+
2345
+ // src/model/pinch-mark.ts
2346
+ import { MM_PER_INCH as MM_PER_INCH5 } from "@toolpath/tool-support";
2347
+ var MAX_DRAWN_TOOL_MM = 3 * MM_PER_INCH5;
2348
+ var pinchMark = (datasheet, sheet, featureType) => {
2349
+ const pinch = readPinch(datasheet);
2350
+ const discs = pinch?.discs.filter(
2351
+ (disc) => disc.diameter >= PINCH_TOOL_MM && disc.diameter <= MAX_DRAWN_TOOL_MM
2352
+ ) ?? [];
2353
+ if (!pinch || discs.length === 0) return null;
2354
+ const fillet = sheet?.filletRadius;
2355
+ const corner = !isSurfaceType(featureType) && fillet !== void 0 && fillet > 0 ? fillet : void 0;
2356
+ return { ...pinch, discs, ...corner !== void 0 ? { corner } : {} };
2357
+ };
2358
+ var pinchLabel = (mark, units) => {
2359
+ const length = (mm2) => `${lengthValue(mm2, units)} ${unitLabel(units)}`;
2360
+ const tightest = Math.min(...mark.discs.map((disc) => disc.diameter));
2361
+ const tool = mark.corner !== void 0 ? `bull nose R ${length(mark.corner)}` : "widest tool";
2362
+ const places = mark.discs.length;
2363
+ return {
2364
+ label: `${mark.unresolved ? "\u2264 " : ""}\u2300 ${length(tightest)}`,
2365
+ note: places > 1 ? `${tool} \xB7 ${places} places` : tool
2366
+ };
2367
+ };
2368
+
2369
+ // src/model/tool-frame.ts
2370
+ var extent = (values) => {
2371
+ let low = Infinity;
2372
+ let high = -Infinity;
2373
+ for (const value of values) {
2374
+ if (value < low) low = value;
2375
+ if (value > high) high = value;
2376
+ }
2377
+ return { low, high };
2378
+ };
2379
+ var dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
2380
+ var cross = (a, b) => [
2381
+ a[1] * b[2] - a[2] * b[1],
2382
+ a[2] * b[0] - a[0] * b[2],
2383
+ a[0] * b[1] - a[1] * b[0]
2384
+ ];
2385
+ var scale = (a, by) => [a[0] * by, a[1] * by, a[2] * by];
2386
+ var add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
2387
+ var unit = (a) => scale(a, 1 / Math.hypot(a[0], a[1], a[2]));
2388
+ var ON_DEPTH_MM = 0.05;
2389
+ var ON_CORNER_MM = 0.02;
2390
+ var POINT_DISC_MM = 0.01;
2391
+ var ON_WALL_MM = 0.1;
2392
+ var ON_WALL_SHARE = 0.02;
2393
+ var WALL_NORMAL_ALONG = 0.7;
2394
+ var SAME_PLACE_MM = 0.05;
2395
+ var MIN_HEIGHT_MM = 0.05;
2396
+ var readTriangles = ({ positions, normals }, origin) => {
2397
+ const triangles = [];
2398
+ for (let at2 = 0; at2 + 8 < positions.length; at2 += 9) {
2399
+ const corner = (k) => [
2400
+ (positions[at2 + k] ?? 0) - origin[0],
2401
+ (positions[at2 + k + 1] ?? 0) - origin[1],
2402
+ (positions[at2 + k + 2] ?? 0) - origin[2]
2403
+ ];
2404
+ const n = at2 / 9 * 3;
2405
+ triangles.push({
2406
+ a: corner(0),
2407
+ b: corner(3),
2408
+ c: corner(6),
2409
+ normal: [normals[n] ?? 0, normals[n + 1] ?? 0, normals[n + 2] ?? 0]
2410
+ });
2411
+ }
2412
+ return triangles;
2413
+ };
2414
+ var pointsOf = (triangles) => triangles.flatMap(({ a, b, c }) => [a, b, c]);
2415
+ var flatten = (point, { u, v }) => ({ x: dot(point, u), y: dot(point, v) });
2416
+ var wallSegments = (triangles, frame) => triangles.filter(({ normal }) => Math.abs(dot(normal, frame.w)) < WALL_NORMAL_ALONG).flatMap(({ a, b, c, normal }) => {
2417
+ const [fa, fb, fc] = [flatten(a, frame), flatten(b, frame), flatten(c, frame)];
2418
+ const air = flatten(normal, frame);
2419
+ return [
2420
+ { p: fa, q: fb, air },
2421
+ { p: fb, q: fc, air },
2422
+ { p: fc, q: fa, air }
2423
+ ];
2424
+ });
2425
+ var nearestOn = ({ p, q }, at2) => {
2426
+ const dx = q.x - p.x;
2427
+ const dy = q.y - p.y;
2428
+ const length = dx * dx + dy * dy;
2429
+ const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((at2.x - p.x) * dx + (at2.y - p.y) * dy) / length));
2430
+ return { x: p.x + t * dx, y: p.y + t * dy };
2431
+ };
2432
+ var nearestWall = (segments, at2) => segments.reduce((best, segment) => {
2433
+ const point = nearestOn(segment, at2);
2434
+ const distance2 = Math.hypot(point.x - at2.x, point.y - at2.y);
2435
+ if (best && distance2 >= best.distance) return best;
2436
+ const open = (at2.x - point.x) * segment.air.x + (at2.y - point.y) * segment.air.y >= 0;
2437
+ return { point, distance: distance2, open };
2438
+ }, null);
2439
+ var fitsDiscs = (triangles, frame, discs) => {
2440
+ const points = discs.filter((disc) => disc.diameter < POINT_DISC_MM);
2441
+ const wide = discs.filter((disc) => disc.diameter >= POINT_DISC_MM);
2442
+ if (points.length > 0) {
2443
+ const flat = pointsOf(triangles).map((point) => flatten(point, frame));
2444
+ const onCorners = points.every(
2445
+ (disc) => flat.some((point) => Math.hypot(point.x - disc.x, point.y - disc.y) <= ON_CORNER_MM)
2446
+ );
2447
+ if (!onCorners) return false;
2448
+ }
2449
+ if (wide.length === 0) return points.length > 0;
2450
+ const segments = wallSegments(triangles, frame);
2451
+ if (segments.length === 0) return false;
2452
+ let touches = false;
2453
+ for (const disc of wide) {
2454
+ const nearest = nearestWall(segments, disc);
2455
+ if (!nearest || !nearest.open) return false;
2456
+ const slack = ON_WALL_MM + ON_WALL_SHARE * disc.diameter;
2457
+ const radius = disc.diameter / 2;
2458
+ if (nearest.distance < radius - slack) return false;
2459
+ if (nearest.distance <= radius + slack) touches = true;
2460
+ }
2461
+ return touches;
2462
+ };
2463
+ var placeInFrame = ({ u, v, w }, x, y, z) => add(add(scale(u, x), scale(v, y)), scale(w, z));
2464
+ var AXES = [
2465
+ [1, 0, 0],
2466
+ [0, 1, 0],
2467
+ [0, 0, 1]
2468
+ ];
2469
+ var candidateFrames = (points, zMin, zMax, direction, strict = true) => {
2470
+ const along = unit(direction);
2471
+ const frames = [];
2472
+ for (const sign of [1, -1]) {
2473
+ const w = scale(along, sign);
2474
+ const { low, high } = extent(points.map((point) => dot(point, w)));
2475
+ if (strict && (Math.abs(low - zMin) > ON_DEPTH_MM || Math.abs(high - zMax) > ON_DEPTH_MM)) {
2476
+ continue;
2477
+ }
2478
+ const across = AXES.reduce(
2479
+ (best, axis) => Math.abs(dot(axis, w)) < Math.abs(dot(best, w)) ? axis : best
2480
+ );
2481
+ const u0 = unit(add(across, scale(w, -dot(across, w))));
2482
+ const v0 = cross(w, u0);
2483
+ for (let quarter = 0; quarter < 4; quarter++) {
2484
+ const angle = quarter * Math.PI / 2;
2485
+ const u = add(scale(u0, Math.cos(angle)), scale(v0, Math.sin(angle)));
2486
+ for (const hand of [1, -1]) frames.push({ u, v: scale(cross(w, u), hand), w });
2487
+ }
2488
+ }
2489
+ return frames;
2490
+ };
2491
+ var distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
2492
+ var exactFrame = (triangles, discs, zMin, zMax, direction) => {
2493
+ const fits = candidateFrames(pointsOf(triangles), zMin, zMax, direction).filter(
2494
+ (frame) => fitsDiscs(triangles, frame, discs)
2495
+ );
2496
+ const [first] = fits;
2497
+ if (!first) return null;
2498
+ const placed = (frame) => discs.map((disc) => placeInFrame(frame, disc.x, disc.y, zMin));
2499
+ const firstPlaces = placed(first);
2500
+ const agree = fits.every(
2501
+ (frame) => placed(frame).every((at2, index) => distance(at2, firstPlaces[index] ?? at2) <= SAME_PLACE_MM)
2502
+ );
2503
+ return agree ? first : null;
2504
+ };
2505
+ var discMiss = (triangles, frame, discs) => {
2506
+ let miss = 0;
2507
+ const points = discs.filter((disc) => disc.diameter < POINT_DISC_MM);
2508
+ if (points.length > 0) {
2509
+ const flat = pointsOf(triangles).map((point) => flatten(point, frame));
2510
+ for (const disc of points) {
2511
+ miss += extent(flat.map((point) => Math.hypot(point.x - disc.x, point.y - disc.y))).low;
2512
+ }
2513
+ }
2514
+ const wide = discs.filter((disc) => disc.diameter >= POINT_DISC_MM);
2515
+ if (wide.length === 0) return miss;
2516
+ const segments = wallSegments(triangles, frame);
2517
+ let touch = Infinity;
2518
+ for (const disc of wide) {
2519
+ const radius = disc.diameter / 2;
2520
+ const nearest = nearestWall(segments, disc);
2521
+ if (!nearest) return Infinity;
2522
+ if (!nearest.open) miss += disc.diameter * 4;
2523
+ miss += Math.max(0, radius - nearest.distance);
2524
+ touch = Math.min(touch, Math.abs(nearest.distance - radius));
2525
+ }
2526
+ return miss + touch;
2527
+ };
2528
+ var bestFrame = (triangles, discs, zMin, zMax, direction) => {
2529
+ if (triangles.length === 0 || discs.length === 0) return null;
2530
+ const exact = exactFrame(triangles, discs, zMin, zMax, direction);
2531
+ if (exact) return { frame: exact, lift: 0 };
2532
+ const points = pointsOf(triangles);
2533
+ const fitting = candidateFrames(points, zMin, zMax, direction);
2534
+ const frames = fitting.length > 0 ? fitting : candidateFrames(points, zMin, zMax, direction, false);
2535
+ const best = frames.reduce((was, frame) => {
2536
+ const miss = discMiss(triangles, frame, discs);
2537
+ return !was || miss < was.miss ? { frame, miss } : was;
2538
+ }, null);
2539
+ if (!best) return null;
2540
+ const { low } = extent(points.map((point) => dot(point, best.frame.w)));
2541
+ return { frame: best.frame, lift: fitting.length > 0 ? 0 : low - zMin };
2542
+ };
2543
+ var asVec = ([x, y, z]) => ({ x, y, z });
2544
+ var placePinchTool = (mark, triangles, direction, origin = { x: 0, y: 0, z: 0 }) => {
2545
+ const shift = [origin.x, origin.y, origin.z];
2546
+ const faces = readTriangles(triangles, shift);
2547
+ const found = bestFrame(faces, mark.discs, mark.zMin, mark.zMax, [
2548
+ direction.x,
2549
+ direction.y,
2550
+ direction.z
2551
+ ]);
2552
+ const disc = mark.discs.reduce(
2553
+ (tightest, each) => !tightest || each.diameter < tightest.diameter ? each : tightest,
2554
+ null
2555
+ );
2556
+ if (!found || !disc) return null;
2557
+ const { frame, lift } = found;
2558
+ const radius = disc.diameter / 2;
2559
+ const base = add(placeInFrame(frame, disc.x, disc.y, mark.zMin + lift), shift);
2560
+ const contact = nearestWall(wallSegments(faces, frame), disc)?.point;
2561
+ const toward = contact ? { x: contact.x - disc.x, y: contact.y - disc.y } : { x: 1, y: 0 };
2562
+ const length = Math.hypot(toward.x, toward.y);
2563
+ const across = add(
2564
+ scale(frame.u, length > 1e-9 ? toward.x / length : 1),
2565
+ scale(frame.v, length > 1e-9 ? toward.y / length : 0)
2566
+ );
2567
+ return {
2568
+ base: asVec(base),
2569
+ axis: asVec(frame.w),
2570
+ diameter: disc.diameter,
2571
+ height: Math.max(mark.zMax - mark.zMin, MIN_HEIGHT_MM),
2572
+ cornerRadius: Math.min(Math.max(mark.corner ?? 0, 0), radius),
2573
+ across: asVec(across)
2574
+ };
2575
+ };
2576
+
2577
+ // src/model/reach.ts
2578
+ var numbers = (value) => Array.isArray(value) && value.every((each) => typeof each === "number" && Number.isFinite(each)) ? value : null;
2579
+ var readReachCurve = (datasheet) => {
2580
+ const curve = datasheet?.reachCurve;
2581
+ const offsets = numbers(curve?.horizontalOffset);
2582
+ const heights = numbers(curve?.verticalOffset);
2583
+ if (!offsets || !heights || offsets.length === 0 || offsets.length !== heights.length) return null;
2584
+ return { offsets, heights };
2585
+ };
2586
+ var runStart = ({ offsets }, index) => index === 0 ? 0 : offsets[index - 1] ?? 0;
2587
+ var peakHeight = ({ heights }) => Math.max(0, ...heights);
2588
+ var TAIL_MM = 12.7;
2589
+ var peakSlack = (peak) => Math.max(0.05, peak * 0.01);
2590
+ var peakStart = (curve) => {
2591
+ const peak = peakHeight(curve);
2592
+ const slack = peakSlack(peak);
2593
+ const index = curve.heights.findIndex((height) => height >= peak - slack);
2594
+ return runStart(curve, index === -1 ? 0 : index);
2595
+ };
2596
+ var trimmedCurve = (curve) => {
2597
+ const last = curve.offsets[curve.offsets.length - 1] ?? 0;
2598
+ const extent2 = peakStart(curve) + TAIL_MM;
2599
+ if (extent2 >= last) return { curve, cut: false };
2600
+ const keep = curve.offsets.findIndex((offset) => offset >= extent2);
2601
+ return {
2602
+ curve: {
2603
+ offsets: [...curve.offsets.slice(0, keep), extent2],
2604
+ heights: curve.heights.slice(0, keep + 1)
2605
+ },
2606
+ cut: true
2607
+ };
2608
+ };
2609
+ var featureAcross = (sheet) => {
2610
+ if (sheet?.diameter !== void 0 && sheet.diameter > 0)
2611
+ return { width: sheet.diameter, kind: "diameter" };
2612
+ if (sheet?.pinchDiameter !== void 0 && sheet.pinchDiameter > 0)
2613
+ return { width: sheet.pinchDiameter, kind: "clearance" };
2614
+ return null;
2615
+ };
2616
+ var isThroughType = (featureType) => /(^|_)through(_|$)/.test(featureType.toLowerCase());
2617
+ var featureProfile = (sheet, featureType) => {
2618
+ const depth = sheet?.zMax !== void 0 && sheet.zMin !== void 0 ? sheet.zMax - sheet.zMin : null;
2619
+ return {
2620
+ across: featureAcross(sheet),
2621
+ depth: depth !== null && depth > 0 ? depth : null,
2622
+ through: isThroughType(featureType)
2623
+ };
2624
+ };
2625
+
2626
+ // src/model/rule-set-file.ts
2627
+ var FILE_KIND = "toolpath-dfm-rules";
2628
+ var serializeRuleSet = (rules, meta = {}, vocabulary = MILLING_RULES) => JSON.stringify(
2629
+ {
2630
+ kind: FILE_KIND,
2631
+ version: 1,
2632
+ process: vocabulary.process,
2633
+ ...meta.name ? { name: meta.name } : {},
2634
+ ...meta.description ? { description: meta.description } : {},
2635
+ units: "mm",
2636
+ rules: [...rules]
2637
+ },
2638
+ null,
2639
+ 2
2640
+ );
2641
+ var PROCESS_NAMES = { milling: "Milling", turning: "Turning" };
2642
+ var fileProcess = (raw) => {
2643
+ if (Array.isArray(raw) || typeof raw !== "object" || raw === null) return "milling";
2644
+ const { process } = raw;
2645
+ return process === void 0 || process === null ? "milling" : process;
2646
+ };
2647
+ var parseRuleSetFile = (text, vocabulary = MILLING_RULES) => {
2648
+ let raw;
2649
+ try {
2650
+ raw = JSON.parse(text);
2651
+ } catch {
2652
+ return { error: "This is not JSON." };
2653
+ }
2654
+ const list = Array.isArray(raw) ? raw : typeof raw === "object" && raw !== null && Array.isArray(raw.rules) ? raw.rules : null;
2655
+ if (!list) return { error: 'No rules in it: expected an object with a "rules" list.' };
2656
+ const units = Array.isArray(raw) ? void 0 : raw.units;
2657
+ if (units !== void 0 && units !== "mm")
2658
+ return { error: `Its lengths are in ${JSON.stringify(units)}; only "mm" can be read.` };
2659
+ const process = fileProcess(raw);
2660
+ if (process !== vocabulary.process) {
2661
+ return process === "milling" || process === "turning" ? {
2662
+ error: `This is a ${process} rule set \u2014 import it in ${PROCESS_NAMES[process]} mode.`
2663
+ } : {
2664
+ error: `Its process is ${JSON.stringify(process)}; only "milling" or "turning" can be read.`
2665
+ };
2666
+ }
2667
+ const seen = /* @__PURE__ */ new Set();
2668
+ const withIds = list.map((each) => {
2669
+ if (typeof each !== "object" || each === null) return each;
2670
+ const rule = each;
2671
+ const id = typeof rule.id === "string" && !seen.has(rule.id) ? rule.id : newRuleId();
2672
+ seen.add(id);
2673
+ return { ...rule, id };
2674
+ });
2675
+ const rules = parseRules(withIds, vocabulary) ?? [];
2676
+ if (rules.length === 0) return { error: "None of its rules could be read." };
2677
+ const name = typeof raw === "object" && raw !== null && typeof raw.name === "string" ? raw.name : void 0;
2678
+ return { rules, skipped: list.length - rules.length, ...name ? { name } : {} };
2679
+ };
2680
+ var OP_WORDS = {
2681
+ gte: "at or above (\u2265)",
2682
+ gt: "above (>)",
2683
+ lte: "at or below (\u2264)",
2684
+ lt: "below (<)",
2685
+ eq: "equal, within half a percent (=)",
2686
+ ne: "not equal (\u2260)",
2687
+ between: "in a range: `value` \u2264 x < `max` by default; `lowOp` (gte|gt) and `highOp` (lt|lte) change the ends"
2688
+ };
2689
+ var SCRIPTS = {
2690
+ milling: {
2691
+ intro: "You are helping a machinist or designer set up design-for-manufacturing (DFM) checks for CNC-milled parts in the Toolpath CAD viewer. Interview them, then produce ONE JSON document in the format below.",
2692
+ opening: "Begin the interview in your very first reply \u2014 do not summarise this prompt, ask whether to start, or explain the format. Run it in four rounds, in a conversational tone, following up where an answer needs it: an opening round about the shop, then a milling round, then a drilling round, then a closing round. Ask only about things the metrics below can measure from the model geometry: depths, diameters, corner radii, tool sizes, areas, feature kinds. Do not ask about surface finish, tolerances, material hardness, fixturing, quantities or lead time \u2014 nothing here measures them.",
2693
+ rounds: [
2694
+ "### Round 1 \u2014 the shop",
2695
+ "1. What they make and in what materials. This sets the defaults, not a rule.",
2696
+ "2. Examples of features that have challenged them before: a deep pocket that snapped a tool, a hole that needed a special drill, a corner radius that forced a re-quote, an undercut nobody saw until CAM. Ask for the feature and its rough size, then turn each into one rule: a deep pocket is `pocket` with `featureLd` or `depthBelowTop`; a small hole is `hole` with `diameter`; a deep hole is `hole` with `ld`; a tight corner is `milled` with `cornerRadius` or `maxTool`. If no metric below measures the example, say so and move on; never invent a metric.",
2697
+ "",
2698
+ "### Round 2 \u2014 milling (pockets, slots, walls, bosses, profiles, 3D surfaces)",
2699
+ "3. The smallest end mill they are happy to run: `milled` with `maxTool` below it, and `milled` with `cornerRadius` below half of it.",
2700
+ "4. The depth-to-diameter ratio where milled work starts to cost them, and where it becomes a problem: two or three bands of `milled` with `ld` (warning, caution, critical).",
2701
+ "5. The deepest their end mills reach below the top of the part: `milled` with `depthBelowTop`. Whether they also want a feature's own depth capped: `milled` with `featureDepth`.",
2702
+ "6. Sharp inside corners: whether every one should be flagged (`milled` with `sharpCorner`), or only on certain feature kinds.",
2703
+ "7. Floor fillets: whether a floor fillet larger than their ball or bull-nose tooling should be flagged (`pocket` with `floorFillet` above a radius).",
2704
+ "8. Milled features to call out on sight regardless of size: `undercut` with `present`; `contour` with `present` for 3D surfaces; `any` with `tilted` for 5-axis work; `boss` with `present` for thin standing material.",
2705
+ "9. Large features that drive cycle time, if they quote: `pocket` with `projectedFloorArea` or `milled` with `surfaceArea` above a size.",
2706
+ "",
2707
+ "### Round 3 \u2014 drilling (holes, threads, countersinks)",
2708
+ "10. The smallest drill they stock: `hole` with `diameter` below it. Whether they want holes that have no drill fit, so must be milled, called out: `hole` with `maxDrill` at 0.",
2709
+ "11. The depth-to-diameter ratio where holes get hard, and where they need gun drilling or a second setup: two bands of `hole` with `ld`, usually looser than milled work.",
2710
+ "12. Threads: the smallest thread they tap (`hole` with `threadDiameter` below it), and whether every threaded hole should be marked (`threadedHole` with `present`).",
2711
+ "13. Countersinks and counterbores: whether to call out countersinks (`chamfer` with `countersink`) or counterbored holes (`hole` with `counterbore`).",
2712
+ "",
2713
+ "### Round 4 \u2014 closing",
2714
+ "14. Anything they want flagged across every feature, milled or drilled \u2014 for example one overall depth below the top of the part their longest tooling cannot reach: `any` with `depthBelowTop`.",
2715
+ "15. Which of the rules so far are critical (red), which are cautions (orange) and which are just worth a look (yellow)."
2716
+ ],
2717
+ counting: "A rule only fires on features that have the measurement; a feature that lacks it is not flagged. Only *required* features count \u2014 ones that own a face no other feature could machine.",
2718
+ example: [
2719
+ { id: "r1", subject: "hole", metric: "ld", op: "gte", value: 8, color: "#ffd60a" },
2720
+ {
2721
+ id: "r2",
2722
+ subject: "milled",
2723
+ metric: "featureLd",
2724
+ op: "between",
2725
+ value: 4,
2726
+ max: 8,
2727
+ lowOp: "gte",
2728
+ highOp: "lt",
2729
+ color: "#f76b15"
2730
+ },
2731
+ { id: "r3", subject: "milled", metric: "sharpCorner", op: "gte", value: 0, color: "#e5484d" },
2732
+ { id: "r4", subject: "undercut", metric: "present", op: "gte", value: 0, color: "#8e4ec6" }
2733
+ ]
2734
+ },
2735
+ turning: {
2736
+ intro: "You are helping a machinist or designer set up design-for-manufacturing (DFM) checks for parts turned on a lathe in the Toolpath CAD viewer. Interview them, then produce ONE JSON document in the format below.",
2737
+ opening: "Begin the interview in your very first reply \u2014 do not summarise this prompt, ask whether to start, or explain the format. Run it in three rounds, in a conversational tone, following up where an answer needs it: an opening round about the shop, then a turning round, then a closing round. Ask only about things the metrics below can measure from the model geometry: diameters, lengths, grooves, threads, axial holes, inside corner radii, tolerance, feature kinds. Do not ask about surface finish, material hardness, workholding, quantities or lead time \u2014 nothing here measures them.",
2738
+ rounds: [
2739
+ "### Round 1 \u2014 the shop",
2740
+ "1. What they make, in what materials, and on what lathes: the largest bar or chuck, and whether they have live tooling. This sets the defaults, not a rule.",
2741
+ "2. Examples of turned features that have challenged them before: a narrow groove that broke an insert, a long small bore that chattered, a fine thread, a sharp inside corner no insert could make. Ask for the feature and its rough size, then turn each into one rule: a narrow groove is `groove` with `grooveWidth`; a deep bore is `id` with `ld`; a fine thread is `thread` with `threadPitch`; a small drilled hole is `hole` with `holeDiameter`. If no metric below measures the example, say so and move on; never invent a metric.",
2742
+ "",
2743
+ "### Round 2 \u2014 turning (outside, inside, faces, grooves, threads, axial holes)",
2744
+ "3. The largest diameter their lathes take: `any` with `maxDiameter` above it. The longest stretch they turn without support: `od` with `length`.",
2745
+ "4. Grooves: the narrowest grooving insert they stock (`groove` with `grooveWidth` below it), and how far they can plunge (`groove` with `grooveDepth`, or `grooveAspect` for depth over width).",
2746
+ "5. Boring: the depth-to-diameter ratio where a boring bar starts to chatter, and where it becomes a problem: two bands of `id` with `ld`. The narrowest bore their smallest bar passes: `id` with `entryDiameter` below it.",
2747
+ "6. Threads: the finest pitch they cut (`thread` with `threadPitch` below it), and the smallest thread (`thread` with `threadDiameter` below it).",
2748
+ "7. Axial holes: the smallest drill they stock (`hole` with `holeDiameter` below it), the deepest they drill (`hole` with `holeDepth`, or `hole` with `ld`), and whether to call out stepped holes (`hole` with `holeSteps`), flat bottoms (`blindHole` with `flatBottom`) or tapped holes (`threadedHole` with `present`).",
2749
+ "8. Inside corners: the smallest nose radius they run (`any` with `cornerRadius` below it), and whether every sharp inside corner should be flagged (`any` with `sharpCorner`).",
2750
+ "9. Turned features to call out on sight regardless of size: `face` with `axialTool` for work only an axially mounted tool reaches; `type:unknown` with `present` for a stretch the Engine could not name.",
2751
+ "",
2752
+ "### Round 3 \u2014 closing",
2753
+ "10. The tightest tolerance they hold without special care: `any` with `tolerance` below it.",
2754
+ "11. Which of the rules so far are critical (red), which are cautions (orange) and which are just worth a look (yellow)."
2755
+ ],
2756
+ counting: "A rule only fires on features that have the measurement; a feature that lacks it is not flagged. A stretch both ends of the part reach is one feature, and breaks a rule only if it breaks it from both ends.",
2757
+ example: [
2758
+ {
2759
+ id: "r1",
2760
+ subject: "groove",
2761
+ metric: "grooveWidth",
2762
+ op: "lte",
2763
+ value: 1.5,
2764
+ color: "#ffd60a"
2765
+ },
2766
+ {
2767
+ id: "r2",
2768
+ subject: "id",
2769
+ metric: "ld",
2770
+ op: "between",
2771
+ value: 4,
2772
+ max: 8,
2773
+ lowOp: "gte",
2774
+ highOp: "lt",
2775
+ color: "#f76b15"
2776
+ },
2777
+ { id: "r3", subject: "any", metric: "sharpCorner", op: "gte", value: 0, color: "#e5484d" },
2778
+ { id: "r4", subject: "thread", metric: "present", op: "gte", value: 0, color: "#8e4ec6" }
2779
+ ]
2780
+ }
2781
+ };
2782
+ var llmRulePrompt = (current = [], vocabulary = MILLING_RULES) => {
2783
+ const script = SCRIPTS[vocabulary.process];
2784
+ const groups = vocabulary.subjects.filter((each) => !each.value.startsWith(TYPE_SUBJECT));
2785
+ const types = vocabulary.subjects.filter((each) => each.value.startsWith(TYPE_SUBJECT));
2786
+ const metricLines = vocabulary.metrics.map((metric) => {
2787
+ const unit2 = quantityUnit(metric.quantity, "mm");
2788
+ const kind = metric.quantity === "flag" ? 'yes/no \u2014 write `op: "gte"` and `value: 0`; the rule fires whenever it is so' : metric.quantity === "presence" ? 'no measurement \u2014 write `op: "gte"` and `value: 0`; flags every feature of the subject' : `${metric.quantity}${unit2 ? `, in ${unit2}` : ""}; a new rule usually starts around ${metric.start.mm}`;
2789
+ const kinds = metric.kinds ? ` Only features of kind ${metric.kinds.join("/")} report it.` : "";
2790
+ return `- \`${metric.key}\` \u2014 "${metric.label}" (${kind}). ${metric.hint}.${kinds}`;
2791
+ });
2792
+ const example = serializeRuleSet(
2793
+ script.example,
2794
+ { name: "Example set", description: "Shows one rule of each shape" },
2795
+ vocabulary
2796
+ );
2797
+ return [
2798
+ "# Write a Toolpath DFM rule set",
2799
+ "",
2800
+ script.intro,
2801
+ "",
2802
+ script.opening,
2803
+ "",
2804
+ 'Where the user has current rules (listed at the end), do not ask whether to keep them. Weave each one into the relevant question as a suggested starting point \u2014 "your set currently warns at 4\xD7D; where does deep work start to cost you?" \u2014 so the user accepts, adjusts or drops it in their own words. The finished set should feel like theirs, not a copy of the old one.',
2805
+ "",
2806
+ "Each question is tied to the subject and metric it becomes. Skip one when an earlier answer has already settled it, and offer sensible defaults from their answers rather than asking about every metric. Every rule needs an `op` and a `value`, even a yes/no one.",
2807
+ "",
2808
+ ...script.rounds,
2809
+ "",
2810
+ `The user may stop at any point \u2014 "that's enough", "just give me the rules", "generate it" \u2014 and you then finish from what you have: rules for every answer so far, their current rules carried over for anything not yet discussed, and sensible defaults from the shop description for the rest. Never insist on completing a round first, and never leave a placeholder for an unanswered question.`,
2811
+ "",
2812
+ "Finishing: when the questions are answered or the user stops, list the rules you intend to write in plain words, one line each with its colour, and ask the user to confirm or change them. Once they confirm, output the JSON in a single ```json code block and nothing after it. The user will paste it into the viewer (DFM Rules \u2192 Import), so the block must be the complete file in the format below \u2014 no comments, no trailing text, no placeholder values.",
2813
+ "",
2814
+ "## Format",
2815
+ "",
2816
+ `Top level: \`{"kind":"toolpath-dfm-rules","version":1,"process":"${vocabulary.process}","name":"\u2026","description":"\u2026","units":"mm","rules":[\u2026]}\`.`,
2817
+ "Every length is in millimetres, areas in mm\xB2, angles in degrees, ratios plain. Convert from inches yourself (1 in = 25.4 mm).",
2818
+ "",
2819
+ "Each rule is one sentence \u2014 *{subject} with {metric} {op} {value}* \u2014 with the fields:",
2820
+ "- `id`: a short unique string.",
2821
+ "- `subject`: which features (a value from the list below).",
2822
+ "- `metric`: what is measured (a key from the list below).",
2823
+ "- `op`: one of " + OPS.map((op) => `\`${op}\``).join(", ") + ".",
2824
+ ...OPS.map((op) => ` - \`${op}\`: ${OP_WORDS[op]}`),
2825
+ "- `value`: the threshold (canonical units). For `between`, the low end; add `max` for the high end.",
2826
+ "- `color`: `#rrggbb`, from the palette: " + RULE_COLORS.map((each) => `${each.name} \`${each.value}\``).join(", ") + ". Red for critical, orange for caution, yellow for warning; purple, green and cyan for anything else. Where a feature breaks several rules the part shows the earliest palette colour.",
2827
+ "",
2828
+ script.counting,
2829
+ "",
2830
+ "## Subjects (`subject`)",
2831
+ "",
2832
+ "Broad groups:",
2833
+ ...groups.map((each) => `- \`${each.value}\` \u2014 ${each.label}`),
2834
+ "",
2835
+ "One exact Engine feature type:",
2836
+ ...types.map((each) => `- \`${each.value}\` \u2014 ${each.label}`),
2837
+ "",
2838
+ "## Metrics (`metric`)",
2839
+ "",
2840
+ ...metricLines,
2841
+ "",
2842
+ "## Example",
2843
+ "",
2844
+ "```json",
2845
+ example,
2846
+ "```",
2847
+ ...current.length > 0 ? [
2848
+ "",
2849
+ "## The user's current rules",
2850
+ "",
2851
+ "Use these as the suggested starting points in your questions, as described above. Do not ask whether to keep them; do not copy them unchanged unless the user confirms each threshold:",
2852
+ "",
2853
+ "```json",
2854
+ serializeRuleSet(current, {}, vocabulary),
2855
+ "```"
2856
+ ] : [],
2857
+ ""
2858
+ ].join("\n");
2859
+ };
2860
+
2861
+ // src/model/turning-metrics.ts
2862
+ var OD_KINDS = ["OdProfile", "OdGroove", "OdThread"];
2863
+ var ID_KINDS = ["IdProfile", "IdGroove", "IdThread"];
2864
+ var FACE_KINDS = ["Face", "FaceProfile", "FaceGroove"];
2865
+ var GROOVE_KINDS = ["OdGroove", "IdGroove", "FaceGroove"];
2866
+ var THREAD_KINDS = ["OdThread", "IdThread", "AxialHole"];
2867
+ var PROFILE_KINDS = ["OdProfile", "IdProfile", "FaceProfile"];
2868
+ var HOLE_KINDS2 = ["AxialHole"];
2869
+ var SIZE2 = "Size";
2870
+ var GROOVES = "Grooves";
2871
+ var THREADS = "Threads";
2872
+ var HOLES = "Holes";
2873
+ var TOOLS2 = "Tool access";
2874
+ var TOLERANCE3 = "Tolerance";
2875
+ var TURNING_METRICS = [
2876
+ PRESENCE_METRIC,
2877
+ define({
2878
+ key: "maxDiameter",
2879
+ label: "diameter",
2880
+ group: SIZE2,
2881
+ quantity: "length",
2882
+ op: "gte",
2883
+ start: { mm: 100, inch: 101.6 },
2884
+ hint: "Its largest diameter: what the lathe must swing, or the bar it is cut from"
2885
+ }),
2886
+ define({
2887
+ key: "minDiameter",
2888
+ label: "smallest diameter",
2889
+ group: SIZE2,
2890
+ quantity: "length",
2891
+ op: "lte",
2892
+ start: { mm: 6, inch: 6.35 },
2893
+ kinds: ["OdProfile", "IdProfile", "OdGroove", "IdGroove", "OdThread", "IdThread", "AxialHole"],
2894
+ hint: "Its smallest diameter: how thin the outside gets, or how narrow a bore. Not for faces"
2895
+ }),
2896
+ define({
2897
+ key: "length",
2898
+ label: "length along the axis",
2899
+ group: SIZE2,
2900
+ quantity: "length",
2901
+ op: "gte",
2902
+ start: { mm: 50, inch: 50.8 },
2903
+ kinds: [
2904
+ "AxialHole",
2905
+ "FaceGroove",
2906
+ "FaceProfile",
2907
+ "IdGroove",
2908
+ "IdProfile",
2909
+ "IdThread",
2910
+ "OdGroove",
2911
+ "OdProfile",
2912
+ "OdThread",
2913
+ "Unknown"
2914
+ ],
2915
+ hint: "How far it runs along the spindle axis. None for a flat face"
2916
+ }),
2917
+ define({
2918
+ key: "ld",
2919
+ label: "L/D from the end",
2920
+ group: SIZE2,
2921
+ quantity: "ratio",
2922
+ op: "gte",
2923
+ start: 4,
2924
+ kinds: ["AxialHole", "IdProfile", "IdGroove", "IdThread"],
2925
+ hint: "How far in a boring bar or drill reaches over the bore it goes through: a hole\u2019s depth over its narrowest step; inside work, from the end it is cut from to its far end, over its entry diameter. Cut from both ends, the easier end"
2926
+ }),
2927
+ define({
2928
+ key: "grooveWidth",
2929
+ label: "groove width",
2930
+ group: GROOVES,
2931
+ quantity: "length",
2932
+ op: "lte",
2933
+ start: { mm: 1.5, inch: 1.5875 },
2934
+ kinds: GROOVE_KINDS,
2935
+ hint: "How wide the groove is: the narrowest insert that cuts it in one plunge"
2936
+ }),
2937
+ define({
2938
+ key: "grooveDepth",
2939
+ label: "groove depth",
2940
+ group: GROOVES,
2941
+ quantity: "length",
2942
+ op: "gte",
2943
+ start: { mm: 5, inch: 5.08 },
2944
+ kinds: GROOVE_KINDS,
2945
+ hint: "How far the groove goes in: radially on the outside or in a bore, along the axis in a face"
2946
+ }),
2947
+ define({
2948
+ key: "grooveAspect",
2949
+ label: "groove depth/width",
2950
+ group: GROOVES,
2951
+ quantity: "ratio",
2952
+ op: "gte",
2953
+ start: 3,
2954
+ kinds: GROOVE_KINDS,
2955
+ hint: "Its depth over its width: how far a grooving insert reaches past its own width"
2956
+ }),
2957
+ define({
2958
+ key: "threadPitch",
2959
+ label: "thread pitch",
2960
+ group: THREADS,
2961
+ quantity: "length",
2962
+ op: "lte",
2963
+ start: { mm: 0.5, inch: 0.4233 },
2964
+ kinds: THREAD_KINDS,
2965
+ hint: "The distance between thread crests (0.423 mm is 60 TPI)"
2966
+ }),
2967
+ define({
2968
+ key: "threadDiameter",
2969
+ label: "thread diameter",
2970
+ group: THREADS,
2971
+ quantity: "length",
2972
+ op: "lte",
2973
+ start: { mm: 6, inch: 6.35 },
2974
+ kinds: THREAD_KINDS,
2975
+ hint: "The catalog thread\u2019s nominal major diameter. None where the catalog lists no thread for it"
2976
+ }),
2977
+ define({
2978
+ key: "threadDepth",
2979
+ label: "thread depth",
2980
+ group: THREADS,
2981
+ quantity: "length",
2982
+ op: "gte",
2983
+ start: { mm: 1, inch: 1.27 },
2984
+ kinds: ["OdThread", "IdThread"],
2985
+ hint: "How deep the thread form is cut"
2986
+ }),
2987
+ define({
2988
+ key: "holeDiameter",
2989
+ label: "hole diameter",
2990
+ group: HOLES,
2991
+ quantity: "length",
2992
+ op: "lte",
2993
+ start: { mm: 3, inch: 3.175 },
2994
+ kinds: HOLE_KINDS2,
2995
+ hint: "Its narrowest step: the drill it is drilled with"
2996
+ }),
2997
+ define({
2998
+ key: "holeDepth",
2999
+ label: "hole depth",
3000
+ group: HOLES,
3001
+ quantity: "length",
3002
+ op: "gte",
3003
+ start: { mm: 50, inch: 50.8 },
3004
+ kinds: HOLE_KINDS2,
3005
+ hint: "How far it runs along the axis"
3006
+ }),
3007
+ define({
3008
+ key: "holeSteps",
3009
+ label: "number of steps",
3010
+ group: HOLES,
3011
+ quantity: "count",
3012
+ op: "gte",
3013
+ start: 2,
3014
+ kinds: HOLE_KINDS2,
3015
+ hint: "How many diameters it steps through, from the mouth inward"
3016
+ }),
3017
+ define({
3018
+ key: "flatBottom",
3019
+ label: "a flat bottom",
3020
+ group: HOLES,
3021
+ quantity: "flag",
3022
+ op: "gte",
3023
+ start: 0,
3024
+ kinds: HOLE_KINDS2,
3025
+ hint: "A blind hole that ends flat rather than in a drill point"
3026
+ }),
3027
+ define({
3028
+ key: "cornerRadius",
3029
+ label: "inside corner radius",
3030
+ group: TOOLS2,
3031
+ quantity: "length",
3032
+ op: "lte",
3033
+ start: { mm: 0.4, inch: 0.3969 },
3034
+ hint: "The radius of its tightest concave corner, which an insert\u2019s nose radius must fit \u2014 zero when sharp. None where it has no inside corner"
3035
+ }),
3036
+ define({
3037
+ key: "sharpCorner",
3038
+ label: "a sharp inside corner",
3039
+ group: TOOLS2,
3040
+ quantity: "flag",
3041
+ op: "gte",
3042
+ start: 0,
3043
+ hint: "A concave corner with no radius, which no insert makes"
3044
+ }),
3045
+ define({
3046
+ key: "entryDiameter",
3047
+ label: "entry diameter",
3048
+ group: TOOLS2,
3049
+ quantity: "length",
3050
+ op: "lte",
3051
+ start: { mm: 10, inch: 9.525 },
3052
+ hint: "For a feature in a hole, the narrowest diameter a bar passes on its way in from the end it is cut from. None where nothing stands between"
3053
+ }),
3054
+ define({
3055
+ key: "axialTool",
3056
+ label: "an axially mounted tool",
3057
+ group: TOOLS2,
3058
+ quantity: "flag",
3059
+ op: "gte",
3060
+ start: 0,
3061
+ hint: "Only a tool mounted along the spindle axis reaches it: a drill, or a face-grooving tool"
3062
+ }),
3063
+ define({
3064
+ key: "tolerance",
3065
+ label: "tolerance",
3066
+ group: TOLERANCE3,
3067
+ quantity: "length",
3068
+ op: "lte",
3069
+ start: { mm: 0.01, inch: 0.0127 },
3070
+ hint: "The most it may deviate from the model"
3071
+ })
3072
+ ];
3073
+ var GROUP_SUBJECTS2 = [
3074
+ { value: "any", label: "Any turned feature", group: "Groups" },
3075
+ { value: "od", label: "Outside (OD) features", group: "Groups", kinds: OD_KINDS },
3076
+ { value: "id", label: "Inside (ID) features", group: "Groups", kinds: ID_KINDS },
3077
+ { value: "face", label: "End-face features", group: "Groups", kinds: FACE_KINDS },
3078
+ { value: "groove", label: "Grooves", group: "Groups", kinds: GROOVE_KINDS },
3079
+ { value: "thread", label: "Threads", group: "Groups", kinds: THREAD_KINDS },
3080
+ { value: "hole", label: "Axial holes", group: "Groups", kinds: HOLE_KINDS2 },
3081
+ { value: "throughHole", label: "Through holes", group: "Groups", kinds: HOLE_KINDS2 },
3082
+ { value: "blindHole", label: "Blind holes", group: "Groups", kinds: HOLE_KINDS2 },
3083
+ { value: "threadedHole", label: "Threaded holes", group: "Groups", kinds: HOLE_KINDS2 },
3084
+ { value: "profile", label: "Profiles", group: "Groups", kinds: PROFILE_KINDS }
3085
+ ];
3086
+ var TYPES2 = [
3087
+ ["od_profile", "OD profiles", "OdProfile"],
3088
+ ["od_groove", "OD grooves", "OdGroove"],
3089
+ ["od_thread", "OD threads", "OdThread"],
3090
+ ["face", "Faces", "Face"],
3091
+ ["face_profile", "Face profiles", "FaceProfile"],
3092
+ ["face_groove", "Face grooves", "FaceGroove"],
3093
+ ["axial_hole", "Axial hole features", "AxialHole"],
3094
+ ["id_profile", "ID profiles", "IdProfile"],
3095
+ ["id_groove", "ID grooves", "IdGroove"],
3096
+ ["id_thread", "ID threads", "IdThread"],
3097
+ ["unknown", "Unknown features", "Unknown"]
3098
+ ];
3099
+ var TURNING_SUBJECTS = [
3100
+ ...GROUP_SUBJECTS2,
3101
+ ...TYPES2.map(([type, label, kind]) => ({
3102
+ value: `${TYPE_SUBJECT}${type}`,
3103
+ label,
3104
+ group: "Feature types",
3105
+ kinds: [kind]
3106
+ }))
3107
+ ];
3108
+ var QUICK_TURNING_SUBJECTS = [
3109
+ "any",
3110
+ "od",
3111
+ "id",
3112
+ "face",
3113
+ "groove",
3114
+ "thread",
3115
+ "hole"
3116
+ ];
3117
+ var QUICK_TURNING_METRICS = [
3118
+ "present",
3119
+ "maxDiameter",
3120
+ "length",
3121
+ "ld",
3122
+ "grooveWidth",
3123
+ "grooveDepth",
3124
+ "threadPitch",
3125
+ "holeDiameter",
3126
+ "holeDepth",
3127
+ "cornerRadius",
3128
+ "sharpCorner",
3129
+ "entryDiameter"
3130
+ ];
3131
+ var CAPITALS = { od: "OD", id: "ID" };
3132
+ var turnedTypeLabel = (featureType) => {
3133
+ const words = featureType.split("_").filter((word) => word !== "").map((word) => CAPITALS[word] ?? word).join(" ");
3134
+ return words.charAt(0).toUpperCase() + words.slice(1);
3135
+ };
3136
+ var endLabel = (feature) => {
3137
+ const approaches = "approaches" in feature && Array.isArray(feature.approaches) ? feature.approaches : feature.machiningDirection.z > 0 ? ["PlusZ"] : feature.machiningDirection.z < 0 ? ["MinusZ"] : [];
3138
+ const plus = approaches.includes("PlusZ");
3139
+ const minus = approaches.includes("MinusZ");
3140
+ return plus && minus ? "from both ends" : plus ? "from the +Z end" : minus ? "from the \u2212Z end" : "";
3141
+ };
3142
+ var TURNING_RULES = {
3143
+ process: "turning",
3144
+ metrics: TURNING_METRICS,
3145
+ subjects: TURNING_SUBJECTS,
3146
+ quickSubjects: QUICK_TURNING_SUBJECTS,
3147
+ quickMetrics: QUICK_TURNING_METRICS,
3148
+ fallbackMetric: PRESENCE_METRIC,
3149
+ featureLabel: (feature) => ({
3150
+ name: turnedTypeLabel(feature.featureType),
3151
+ detail: endLabel(feature)
3152
+ }),
3153
+ considerationsTitle: "Turning considerations",
3154
+ fileName: "dfm-turning-rules.json"
3155
+ };
3156
+
3157
+ // src/model/turned-check.ts
3158
+ var regionsOf = (feature) => Array.isArray(feature.regionIdxs) ? feature.regionIdxs : [];
3159
+ var stretchKey = (feature) => {
3160
+ const regions = regionsOf(feature);
3161
+ return regions.length === 0 ? `tag:${feature.featureTag}` : `${feature.featureType}:${[...regions].sort((a, b) => a - b).join(",")}`;
3162
+ };
3163
+ var isApproach = (value) => value === "PlusZ" || value === "MinusZ";
3164
+ var turnedStretches = (features) => {
3165
+ const groups = /* @__PURE__ */ new Map();
3166
+ for (const feature of features) {
3167
+ const key = stretchKey(feature);
3168
+ groups.set(key, [...groups.get(key) ?? [], feature]);
3169
+ }
3170
+ const merged = [...groups.values()].flatMap((copies) => {
3171
+ const [first] = copies;
3172
+ if (!first) return [];
3173
+ const approaches = [...new Set(copies.map((each) => each.approach).filter(isApproach))];
3174
+ const only = approaches.length === 1 ? approaches[0] : void 0;
3175
+ const z = only === "PlusZ" ? 1 : only === "MinusZ" ? -1 : 0;
3176
+ return [{ first, copies, approaches, machiningDirection: { x: 0, y: 0, z } }];
3177
+ });
3178
+ const numbered = dfmFeatures(
3179
+ merged.map(({ first, machiningDirection }) => ({
3180
+ featureTag: first.featureTag,
3181
+ featureType: first.featureType,
3182
+ machiningDirection,
3183
+ regionIdxs: regionsOf(first)
3184
+ }))
3185
+ );
3186
+ return merged.flatMap(({ copies, approaches }, at2) => {
3187
+ const feature = numbered[at2];
3188
+ return feature ? [{ ...feature, copies, approaches }] : [];
3189
+ });
3190
+ };
3191
+ var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3192
+ var finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
3193
+ var ratio = (top, bottom) => top !== null && bottom !== null && bottom > 0 ? top / bottom : null;
3194
+ var narrowestStep = (facts) => {
3195
+ const steps = Array.isArray(facts.steps) ? facts.steps : [];
3196
+ const diameters = steps.map((step) => finite(record(step)?.diameter)).filter((each) => each !== null && each > 0);
3197
+ return diameters.length > 0 ? Math.min(...diameters) : null;
3198
+ };
3199
+ var threadSpec = (facts, field) => finite(record(facts.threadSpec)?.[field]) ?? finite(record(record(facts.thread)?.threadSpec)?.[field]);
3200
+ var partEnds = (stretches) => {
3201
+ const zs = stretches.flatMap(
3202
+ (stretch) => stretch.copies.flatMap((copy) => {
3203
+ const sheet = record(copy.datasheet);
3204
+ return [finite(sheet?.zMin), finite(sheet?.zMax)].filter(
3205
+ (each) => each !== null
3206
+ );
3207
+ })
3208
+ );
3209
+ return zs.length > 0 ? { low: Math.min(...zs), high: Math.max(...zs) } : null;
3210
+ };
3211
+ var turnedLd = (sheet, facts, kind, approach, ends) => {
3212
+ if (kind === "AxialHole") return ratio(finite(facts.depth), narrowestStep(facts));
3213
+ const inside = ID_KINDS;
3214
+ if (!kind || !inside.includes(kind) || !ends || !approach) return null;
3215
+ const zMin = finite(sheet.zMin);
3216
+ const zMax = finite(sheet.zMax);
3217
+ const reach = approach === "PlusZ" ? zMin === null ? null : ends.high - zMin : zMax === null ? null : zMax - ends.low;
3218
+ const rMin = finite(sheet.rMin);
3219
+ const bore = finite(sheet.entryDiameter) ?? (rMin === null ? null : 2 * rMin);
3220
+ return ratio(reach, bore);
3221
+ };
3222
+ var HAIR = 1e-3;
3223
+ var FLAT_CONE = 179.5;
3224
+ var copyMeasure = (metric, copy, ends) => {
3225
+ const sheet = record(copy.datasheet);
3226
+ if (!sheet) return null;
3227
+ const facts = record(sheet.facts) ?? {};
3228
+ const kind = typeof facts.kind === "string" ? facts.kind : kindOfType(copy.featureType);
3229
+ const kinds = metric.kinds;
3230
+ if (kinds && !(kind && kinds.includes(kind))) return null;
3231
+ const doubled = (value) => {
3232
+ const radius = finite(value);
3233
+ return radius === null ? null : 2 * radius;
3234
+ };
3235
+ switch (metric.key) {
3236
+ case "maxDiameter":
3237
+ return doubled(sheet.rMax);
3238
+ case "minDiameter":
3239
+ return doubled(sheet.rMin);
3240
+ case "length": {
3241
+ const zMin = finite(sheet.zMin);
3242
+ const zMax = finite(sheet.zMax);
3243
+ return zMin === null || zMax === null || zMax - zMin < HAIR ? null : zMax - zMin;
3244
+ }
3245
+ case "ld":
3246
+ return turnedLd(sheet, facts, kind, copy.approach ?? null, ends);
3247
+ case "grooveWidth":
3248
+ return finite(facts.width);
3249
+ case "grooveDepth":
3250
+ case "threadDepth":
3251
+ case "holeDepth":
3252
+ return finite(facts.depth);
3253
+ case "grooveAspect":
3254
+ return ratio(finite(facts.depth), finite(facts.width));
3255
+ case "threadPitch":
3256
+ return finite(facts.pitch) ?? threadSpec(facts, "threadPitch");
3257
+ case "threadDiameter":
3258
+ return threadSpec(facts, "basicDiameter");
3259
+ case "holeDiameter":
3260
+ return narrowestStep(facts);
3261
+ case "holeSteps":
3262
+ return Array.isArray(facts.steps) ? facts.steps.length : null;
3263
+ case "flatBottom": {
3264
+ if (facts.bottom === void 0) return 0;
3265
+ const cone = finite(record(facts.bottom)?.coneDeg);
3266
+ return cone === null ? null : cone >= FLAT_CONE ? 1 : 0;
3267
+ }
3268
+ // Zero is a figure here: a sharp corner, which a "radius ≤" rule is most of all about.
3269
+ case "cornerRadius":
3270
+ return finite(sheet.minConcaveRadius);
3271
+ case "sharpCorner": {
3272
+ const radius = finite(sheet.minConcaveRadius);
3273
+ return radius === null ? 0 : radius <= HAIR ? 1 : 0;
3274
+ }
3275
+ // Absent where nothing stands between the feature and its end: nothing to pass, so clean.
3276
+ case "entryDiameter":
3277
+ return finite(sheet.entryDiameter);
3278
+ case "axialTool":
3279
+ return typeof sheet.mounting === "string" ? sheet.mounting === "Axial" ? 1 : 0 : null;
3280
+ case "tolerance":
3281
+ return finite(record(sheet.toleranceBand)?.atolMax);
3282
+ default:
3283
+ return null;
3284
+ }
3285
+ };
3286
+ var kindOfType = (featureType) => featureType.split("_").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
3287
+ var stretchFacts = (stretch) => stretch.copies.map((copy) => record(record(copy.datasheet)?.facts)).find((each) => each !== void 0);
3288
+ var stretchIs = (subject, stretch) => {
3289
+ const type = stretch.featureType.toLowerCase();
3290
+ if (subject.startsWith(TYPE_SUBJECT)) return type === subject.slice(TYPE_SUBJECT.length);
3291
+ const facts = stretchFacts(stretch);
3292
+ const kind = typeof facts?.kind === "string" ? facts.kind : kindOfType(type);
3293
+ const hole = kind === "AxialHole";
3294
+ switch (subject) {
3295
+ case "any":
3296
+ return true;
3297
+ case "thread":
3298
+ return kind === "OdThread" || kind === "IdThread" || hole && record(facts?.thread) !== void 0;
3299
+ case "throughHole":
3300
+ return hole && facts?.through === true;
3301
+ case "blindHole":
3302
+ return hole && facts?.through === false;
3303
+ case "threadedHole":
3304
+ return hole && record(facts?.thread) !== void 0;
3305
+ default: {
3306
+ const kinds = subjectInfo(subject, TURNING_RULES)?.kinds;
3307
+ return kinds !== void 0 && kinds.includes(kind);
3308
+ }
3309
+ }
3310
+ };
3311
+ var stretchHit = (stretch, rule, metric, ends) => {
3312
+ if (metric.quantity === "presence") return { rule, metric, value: 1 };
3313
+ const measured = stretch.copies.filter((copy) => record(copy.datasheet) !== void 0);
3314
+ if (measured.length === 0) return null;
3315
+ const values = [];
3316
+ for (const copy of measured) {
3317
+ const value2 = copyMeasure(metric, copy, ends);
3318
+ if (value2 === null || !breaks(rule, metric.quantity, value2)) return null;
3319
+ values.push(value2);
3320
+ }
3321
+ const value = worseUpward(rule, metric) ? Math.min(...values) : Math.max(...values);
3322
+ return { rule, metric, value };
3323
+ };
3324
+ var checkTurnedPart = (stretches, rules) => {
3325
+ const ends = partEnds(stretches);
3326
+ const hitsByTag = new Map(
3327
+ stretches.map((stretch) => [
3328
+ stretch.tag.toLowerCase(),
3329
+ rules.flatMap((rule) => {
3330
+ const metric = metricInfo(rule.metric, TURNING_RULES);
3331
+ if (!metric || !stretchIs(rule.subject, stretch)) return [];
3332
+ const hit = stretchHit(stretch, rule, metric, ends);
3333
+ return hit ? [hit] : [];
3334
+ })
3335
+ ])
3336
+ );
3337
+ const required = new Set(stretches.map((stretch) => stretch.tag.toLowerCase()));
3338
+ const paintedFaces = /* @__PURE__ */ new Map();
3339
+ for (const stretch of stretches) {
3340
+ const color = worstColor(hitsByTag.get(stretch.tag.toLowerCase()));
3341
+ if (!color) continue;
3342
+ for (const region of stretch.regionIdxs) {
3343
+ const painted = paintedFaces.get(region);
3344
+ if (!painted || compareColors(color, painted.color) < 0)
3345
+ paintedFaces.set(region, { tag: stretch.tag, color });
3346
+ }
3347
+ }
3348
+ return {
3349
+ hitsByTag,
3350
+ required,
3351
+ byRule: ruleResults(rules, stretches, hitsByTag, required),
3352
+ paintedFaces,
3353
+ paintedWhole: /* @__PURE__ */ new Map()
3354
+ };
3355
+ };
3356
+
3357
+ export {
3358
+ directionLabel,
3359
+ dfmFeatures,
3360
+ featureTypeLabel,
3361
+ PRESENCE_METRIC,
3362
+ QUICK_SUBJECTS,
3363
+ MILLING_RULES,
3364
+ metricInfo,
3365
+ metricsForSubject,
3366
+ lengthValue,
3367
+ unitLabel,
3368
+ OPS,
3369
+ RANGE_LOWS,
3370
+ RANGE_HIGHS,
3371
+ opSymbol,
3372
+ RULE_COLORS,
3373
+ compareColors,
3374
+ CRITICAL_COLOR,
3375
+ WARNING_COLOR,
3376
+ subjectLabel,
3377
+ quantityUnit,
3378
+ toField,
3379
+ fromField,
3380
+ formatMeasured,
3381
+ describeRule,
3382
+ makeRule,
3383
+ upperOf,
3384
+ lowOf,
3385
+ highOf,
3386
+ withMetric,
3387
+ withOp,
3388
+ withSubject,
3389
+ parseRules,
3390
+ hitFigure,
3391
+ brokenRules,
3392
+ featureColor,
3393
+ withSurroundedFaces,
3394
+ checkPart,
3395
+ readPinch,
3396
+ PINCH_TOOL_MM,
3397
+ isNoReading,
3398
+ featureSheet,
3399
+ isSurfaceType,
3400
+ maxBallDiameter,
3401
+ featureMeasurements,
3402
+ MAX_DATASHEET_FIELDS,
3403
+ datasheetFields,
3404
+ sameDepth,
3405
+ featureRows,
3406
+ pinchMark,
3407
+ pinchLabel,
3408
+ placePinchTool,
3409
+ readReachCurve,
3410
+ runStart,
3411
+ peakHeight,
3412
+ trimmedCurve,
3413
+ featureProfile,
3414
+ FILE_KIND,
3415
+ serializeRuleSet,
3416
+ parseRuleSetFile,
3417
+ llmRulePrompt,
3418
+ turnedTypeLabel,
3419
+ TURNING_RULES,
3420
+ turnedStretches,
3421
+ checkTurnedPart
3422
+ };