@toolpath/dfm 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1599 @@
1
+ // src/model/dfm-metrics.ts
2
+ import { MM_PER_INCH } from "@toolpath/tool-support";
3
+ var define = ({ start, path, ...spec }) => ({
4
+ ...spec,
5
+ start: typeof start === "number" ? { mm: start, inch: start } : start,
6
+ ...path === void 0 ? {} : { path: typeof path === "string" ? [path] : path }
7
+ });
8
+ var SMALL = { mm: 1, inch: 1.27 };
9
+ var TOOL = { mm: 3, inch: 3.175 };
10
+ var DEEP = { mm: 50, inch: 50.8 };
11
+ var AREA = { mm: 1e3, inch: 645.16 };
12
+ var cd = (band, bound) => [
13
+ `facts.cd.${band}.${bound}`,
14
+ `facts.three.cd.${band}.${bound}`
15
+ ];
16
+ var ALWAYS = "Always";
17
+ var SIZE = "Size and depth";
18
+ var TOOLS = "Tool access";
19
+ var AREAS = "Surface area";
20
+ var MAKE = "Machining";
21
+ var HOLE = "Holes";
22
+ var THREAD = "Threads";
23
+ var CHAMFER = "Chamfers";
24
+ var OUTLINES = "Floors, fillets and outlines";
25
+ var FACES = "Faces and profiles";
26
+ var SURFACE = "3D surfaces";
27
+ var UNDERCUT = "Undercuts";
28
+ var PRESENCE_METRIC = define({
29
+ key: "present",
30
+ label: "any size",
31
+ group: ALWAYS,
32
+ quantity: "presence",
33
+ op: "gte",
34
+ start: 0,
35
+ hint: "Flags every feature of this type, whatever its geometry"
36
+ });
37
+ var LD_METRIC = define({
38
+ key: "ld",
39
+ label: "L/D to top of part",
40
+ group: SIZE,
41
+ quantity: "ratio",
42
+ op: "gte",
43
+ start: 5,
44
+ 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"
45
+ });
46
+ var FEATURE_LD_METRIC = define({
47
+ key: "featureLd",
48
+ label: "feature L/D",
49
+ group: SIZE,
50
+ quantity: "ratio",
51
+ op: "gte",
52
+ start: 5,
53
+ 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"
54
+ });
55
+ var FEATURE_METRICS = [
56
+ PRESENCE_METRIC,
57
+ LD_METRIC,
58
+ FEATURE_LD_METRIC,
59
+ define({
60
+ key: "depthBelowTop",
61
+ label: "depth below top",
62
+ group: SIZE,
63
+ quantity: "length",
64
+ op: "gte",
65
+ start: DEEP,
66
+ hint: "How far below the top of the part the feature bottoms out"
67
+ }),
68
+ define({
69
+ key: "featureDepth",
70
+ label: "feature depth",
71
+ group: SIZE,
72
+ quantity: "length",
73
+ op: "gte",
74
+ start: { mm: 25, inch: MM_PER_INCH },
75
+ hint: "The feature\u2019s own depth, top to bottom"
76
+ }),
77
+ define({
78
+ key: "faceCount",
79
+ label: "number of faces",
80
+ group: SIZE,
81
+ quantity: "count",
82
+ op: "gte",
83
+ start: 10,
84
+ hint: "How many faces of the model make it up"
85
+ }),
86
+ define({
87
+ key: "zMin",
88
+ label: "bottom (tool-axis Z)",
89
+ group: SIZE,
90
+ quantity: "length",
91
+ op: "lte",
92
+ start: 0,
93
+ signed: true,
94
+ path: "zMin",
95
+ hint: "The feature\u2019s bottom along its tool axis, in the Engine\u2019s frame"
96
+ }),
97
+ define({
98
+ key: "zMax",
99
+ label: "top (tool-axis Z)",
100
+ group: SIZE,
101
+ quantity: "length",
102
+ op: "gte",
103
+ start: 0,
104
+ signed: true,
105
+ path: "zMax",
106
+ hint: "The feature\u2019s top along its tool axis, in the Engine\u2019s frame"
107
+ }),
108
+ define({
109
+ key: "extendedZMin",
110
+ label: "pass bottom (tool-axis Z)",
111
+ group: SIZE,
112
+ quantity: "length",
113
+ op: "lte",
114
+ start: 0,
115
+ signed: true,
116
+ path: "extendedZMin",
117
+ hint: "Where a pass over it runs out: its bottom, or lower where a chamfer on a far edge deepens it"
118
+ }),
119
+ define({
120
+ key: "extendedZMax",
121
+ label: "pass top (tool-axis Z)",
122
+ group: SIZE,
123
+ quantity: "length",
124
+ op: "gte",
125
+ start: 0,
126
+ signed: true,
127
+ path: "extendedZMax",
128
+ hint: "Where a pass over it starts: its top, or higher where a chamfer or fillet sits on its mouth"
129
+ }),
130
+ define({
131
+ key: "reachHeight",
132
+ label: "material height beside it",
133
+ group: SIZE,
134
+ quantity: "length",
135
+ op: "gte",
136
+ start: DEEP,
137
+ hint: "The highest the material stands anywhere outboard of it (its reach curve), which a tool\u2019s holder must clear"
138
+ }),
139
+ define({
140
+ key: "maxTool",
141
+ label: "largest tool that fits",
142
+ group: TOOLS,
143
+ quantity: "length",
144
+ op: "lte",
145
+ start: TOOL,
146
+ path: cd("ignore", "min"),
147
+ hint: "The widest cutter that still reaches every point"
148
+ }),
149
+ define({
150
+ key: "cornerRadius",
151
+ label: "inside corner radius",
152
+ group: TOOLS,
153
+ quantity: "length",
154
+ op: "lte",
155
+ start: SMALL,
156
+ hint: "The radius of its tightest corner between walls, as drawn \u2014 zero when sharp. Not a floor fillet"
157
+ }),
158
+ define({
159
+ key: "maxToolSomewhere",
160
+ label: "largest tool that fits anywhere",
161
+ group: TOOLS,
162
+ quantity: "length",
163
+ op: "lte",
164
+ start: TOOL,
165
+ path: cd("ignore", "max"),
166
+ hint: "The widest cutter that fits somewhere in it"
167
+ }),
168
+ define({
169
+ key: "maxToolDeviate",
170
+ label: "largest tool within tolerance",
171
+ group: TOOLS,
172
+ quantity: "length",
173
+ op: "lte",
174
+ start: TOOL,
175
+ path: cd("deviate", "min"),
176
+ hint: "The widest cutter that reaches every point, allowing the reported tolerance band"
177
+ }),
178
+ define({
179
+ key: "maxToolAdaptive",
180
+ label: "largest adaptive tool",
181
+ group: TOOLS,
182
+ quantity: "length",
183
+ op: "lte",
184
+ start: TOOL,
185
+ path: cd("effectiveAdaptive", "min"),
186
+ hint: "The widest cutter that reaches every point once adaptive-cutting allowances are applied"
187
+ }),
188
+ define({
189
+ key: "terminalCornerRadius",
190
+ label: "finishing corner radius limit",
191
+ group: TOOLS,
192
+ quantity: "length",
193
+ op: "lte",
194
+ start: SMALL,
195
+ path: ["facts.cd.terminalCornerRadius", "facts.three.cd.terminalCornerRadius"],
196
+ hint: "The corner radius a finishing tool must not exceed"
197
+ }),
198
+ define({
199
+ key: "maxBottomDiameter",
200
+ label: "largest tool bottom",
201
+ group: TOOLS,
202
+ quantity: "length",
203
+ op: "lte",
204
+ start: TOOL,
205
+ path: ["facts.maxBottomDiameter", "facts.three.maxBottomDiameter"],
206
+ kinds: ["Pocket", "Boss", "Face", "Three", "Chamfer"],
207
+ hint: "The widest bottom a finishing tool may have"
208
+ }),
209
+ define({
210
+ key: "pinchPoints",
211
+ label: "tight spots",
212
+ group: TOOLS,
213
+ quantity: "count",
214
+ op: "gte",
215
+ start: 2,
216
+ hint: "How many places it narrows to its tightest clearance (the Engine reports up to ten)"
217
+ }),
218
+ define({
219
+ key: "pinchDiameter",
220
+ label: "minimum clearance",
221
+ group: TOOLS,
222
+ quantity: "length",
223
+ op: "lte",
224
+ start: TOOL,
225
+ 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"
226
+ }),
227
+ define({
228
+ key: "sharpCorner",
229
+ label: "a sharp inside corner",
230
+ group: TOOLS,
231
+ quantity: "flag",
232
+ op: "gte",
233
+ start: 0,
234
+ hint: "A vertical corner between its walls with no radius, which no end mill makes. Not a floor fillet"
235
+ }),
236
+ define({
237
+ key: "noToolFits",
238
+ label: "no room for any tool",
239
+ group: TOOLS,
240
+ quantity: "flag",
241
+ op: "gte",
242
+ start: 0,
243
+ hint: "No cutter fits anywhere in it"
244
+ }),
245
+ define({
246
+ key: "tilted",
247
+ label: "a tilted tool axis",
248
+ group: TOOLS,
249
+ quantity: "flag",
250
+ op: "gte",
251
+ start: 0,
252
+ hint: "Machined along a direction that is not one of the part\u2019s six square ones: a 5-axis setup"
253
+ }),
254
+ define({
255
+ key: "surfaceArea",
256
+ label: "surface area",
257
+ group: AREAS,
258
+ quantity: "area",
259
+ op: "gte",
260
+ start: AREA,
261
+ hint: "Its whole surface: the Engine\u2019s partition of it, or its faces\u2019 areas together"
262
+ }),
263
+ define({
264
+ key: "projectedFloorArea",
265
+ label: "floor area (projected)",
266
+ group: AREAS,
267
+ quantity: "area",
268
+ op: "gte",
269
+ start: AREA,
270
+ path: ["projectedFloorArea", "floorishArea"],
271
+ hint: "Area machined floor-wise, square to the tool axis"
272
+ }),
273
+ define({
274
+ key: "projectedWallArea",
275
+ label: "wall area (projected)",
276
+ group: AREAS,
277
+ quantity: "area",
278
+ op: "gte",
279
+ start: AREA,
280
+ path: ["projectedWallArea", "wallishArea"],
281
+ hint: "Area machined wall-wise, along the tool axis"
282
+ }),
283
+ define({
284
+ key: "floorArea",
285
+ label: "floor surface",
286
+ group: AREAS,
287
+ quantity: "area",
288
+ op: "gte",
289
+ start: AREA,
290
+ path: "areas.floorArea",
291
+ hint: "Surface square to the tool axis"
292
+ }),
293
+ define({
294
+ key: "wallArea",
295
+ label: "wall surface",
296
+ group: AREAS,
297
+ quantity: "area",
298
+ op: "gte",
299
+ start: AREA,
300
+ path: "areas.wallArea",
301
+ hint: "Surface running along the tool axis"
302
+ }),
303
+ define({
304
+ key: "filletArea",
305
+ label: "fillet surface",
306
+ group: AREAS,
307
+ quantity: "area",
308
+ op: "gte",
309
+ start: AREA,
310
+ path: "areas.filletArea",
311
+ hint: "The concave rounds between floors and walls"
312
+ }),
313
+ define({
314
+ key: "chamferArea",
315
+ label: "chamfer surface",
316
+ group: AREAS,
317
+ quantity: "area",
318
+ op: "gte",
319
+ start: AREA,
320
+ path: "areas.chamferArea",
321
+ hint: "The bevels"
322
+ }),
323
+ define({
324
+ key: "contourArea",
325
+ label: "3D surface",
326
+ group: AREAS,
327
+ quantity: "area",
328
+ op: "gte",
329
+ start: AREA,
330
+ path: "areas.contourArea",
331
+ hint: "Surface with no flat to stand on"
332
+ }),
333
+ define({
334
+ key: "hasFloor",
335
+ label: "a floor",
336
+ group: MAKE,
337
+ quantity: "flag",
338
+ op: "gte",
339
+ start: 0,
340
+ path: "hasFloor",
341
+ hint: "It has a floor machined square to the tool axis"
342
+ }),
343
+ define({
344
+ key: "hasWall",
345
+ label: "a wall",
346
+ group: MAKE,
347
+ quantity: "flag",
348
+ op: "gte",
349
+ start: 0,
350
+ path: "hasWall",
351
+ hint: "It has a wall machined along the tool axis"
352
+ }),
353
+ define({
354
+ key: "axialStock",
355
+ label: "axial stock to leave",
356
+ group: MAKE,
357
+ quantity: "length",
358
+ op: "gte",
359
+ start: { mm: 0.1, inch: 0.127 },
360
+ path: "axialStockToLeave",
361
+ hint: "Material left along the tool axis for a later operation"
362
+ }),
363
+ define({
364
+ key: "radialStock",
365
+ label: "radial stock to leave",
366
+ group: MAKE,
367
+ quantity: "length",
368
+ op: "gte",
369
+ start: { mm: 0.1, inch: 0.127 },
370
+ path: "radialStockToLeave",
371
+ hint: "Material left radially for a later operation"
372
+ }),
373
+ define({
374
+ key: "tolIgnore",
375
+ label: "ignored tolerance",
376
+ group: MAKE,
377
+ quantity: "length",
378
+ op: "lte",
379
+ start: { mm: 0.01, inch: 0.0127 },
380
+ path: "toleranceBand.atolIgnore",
381
+ hint: "Deviation at or below this is ignored"
382
+ }),
383
+ define({
384
+ key: "tolDeviate",
385
+ label: "reported tolerance",
386
+ group: MAKE,
387
+ quantity: "length",
388
+ op: "lte",
389
+ start: { mm: 0.02, inch: 0.0254 },
390
+ path: "toleranceBand.atolDeviate",
391
+ hint: "Deviation up to this is reported"
392
+ }),
393
+ define({
394
+ key: "tolMax",
395
+ label: "tolerance",
396
+ group: MAKE,
397
+ quantity: "length",
398
+ op: "lte",
399
+ start: { mm: 0.05, inch: 0.0508 },
400
+ path: "toleranceBand.atolMax",
401
+ hint: "The most it may deviate from the model"
402
+ }),
403
+ define({
404
+ key: "passLength",
405
+ label: "pass length",
406
+ group: OUTLINES,
407
+ quantity: "length",
408
+ op: "gte",
409
+ start: { mm: 500, inch: 508 },
410
+ path: ["facts.wallLength.length", "facts.length"],
411
+ kinds: ["Pocket", "Boss", "Wall", "Profile"],
412
+ hint: "How far a pass around its outline travels, with a tool of no width"
413
+ }),
414
+ define({
415
+ key: "diameter",
416
+ label: "diameter",
417
+ group: HOLE,
418
+ quantity: "length",
419
+ op: "lte",
420
+ start: { mm: 1.5, inch: 1.5875 },
421
+ path: "facts.diameter",
422
+ kinds: ["Hole"],
423
+ hint: "What the hole is drilled to \u2014 narrower than modelled for a threaded hole"
424
+ }),
425
+ define({
426
+ key: "coneAngle",
427
+ label: "tip angle",
428
+ group: HOLE,
429
+ quantity: "angle",
430
+ op: "lte",
431
+ start: 118,
432
+ path: "facts.fullConeDeg",
433
+ kinds: ["Hole"],
434
+ hint: "The full angle of the cone at its bottom; 180\xB0 for a flat bottom"
435
+ }),
436
+ define({
437
+ key: "counterbore",
438
+ label: "a counterbore",
439
+ group: HOLE,
440
+ quantity: "flag",
441
+ op: "gte",
442
+ start: 0,
443
+ path: "facts.isCounterbore",
444
+ kinds: ["Hole"],
445
+ hint: "A larger bore at its mouth"
446
+ }),
447
+ define({
448
+ key: "maxSpot",
449
+ label: "largest spot drill",
450
+ group: HOLE,
451
+ quantity: "length",
452
+ op: "lte",
453
+ start: TOOL,
454
+ path: "facts.maxSpotDiameter",
455
+ kinds: ["Hole"],
456
+ hint: "The widest spot drill that reaches it without collision"
457
+ }),
458
+ define({
459
+ key: "minDrill",
460
+ label: "smallest drill",
461
+ group: HOLE,
462
+ quantity: "length",
463
+ op: "gte",
464
+ start: TOOL,
465
+ path: "facts.minDrillDiameter",
466
+ kinds: ["Hole"],
467
+ hint: "The narrowest drill that leaves it within its undersize"
468
+ }),
469
+ define({
470
+ key: "maxDrill",
471
+ label: "largest drill",
472
+ group: HOLE,
473
+ quantity: "length",
474
+ op: "lte",
475
+ start: TOOL,
476
+ path: "facts.maxDrillDiameter",
477
+ kinds: ["Hole"],
478
+ hint: "The widest drill that leaves it within its oversize"
479
+ }),
480
+ define({
481
+ key: "maxEndmill",
482
+ label: "largest endmill",
483
+ group: HOLE,
484
+ quantity: "length",
485
+ op: "lte",
486
+ start: TOOL,
487
+ path: "facts.maxEndmillDiameter",
488
+ kinds: ["Hole"],
489
+ hint: "The widest endmill that can machine it"
490
+ }),
491
+ define({
492
+ key: "floorFillet",
493
+ label: "floor fillet",
494
+ group: OUTLINES,
495
+ quantity: "length",
496
+ op: "lte",
497
+ start: { mm: 0.5, inch: 0.396875 },
498
+ path: "facts.filletRadius",
499
+ kinds: ["Hole", "Pocket", "Boss", "Dovetail", "Tslot"],
500
+ hint: "The radius where the floor meets the walls; zero when sharp"
501
+ }),
502
+ define({
503
+ key: "filletHeight",
504
+ label: "floor fillet height",
505
+ group: OUTLINES,
506
+ quantity: "length",
507
+ op: "gte",
508
+ start: SMALL,
509
+ path: "facts.filletHeight",
510
+ kinds: ["Hole", "Pocket", "Boss"],
511
+ hint: "How high the floor blend runs; zero when sharp"
512
+ }),
513
+ define({
514
+ key: "threaded",
515
+ label: "a thread",
516
+ group: THREAD,
517
+ quantity: "flag",
518
+ op: "gte",
519
+ start: 0,
520
+ path: "facts.threading.spec.threadPitch",
521
+ kinds: ["Hole"],
522
+ hint: "The model threads it"
523
+ }),
524
+ define({
525
+ key: "threadPitch",
526
+ label: "thread pitch",
527
+ group: THREAD,
528
+ quantity: "length",
529
+ op: "lte",
530
+ start: { mm: 0.5, inch: 0.4536 },
531
+ path: "facts.threading.spec.threadPitch",
532
+ kinds: ["Hole"],
533
+ hint: "A modelled thread\u2019s pitch (0.454 mm is 56 TPI)"
534
+ }),
535
+ define({
536
+ key: "threadDiameter",
537
+ label: "thread size",
538
+ group: THREAD,
539
+ quantity: "length",
540
+ op: "lte",
541
+ start: { mm: 3, inch: 3.175 },
542
+ path: "facts.threading.spec.basicDiameter",
543
+ kinds: ["Hole"],
544
+ hint: "The thread\u2019s nominal major diameter"
545
+ }),
546
+ define({
547
+ key: "threadMinorMin",
548
+ label: "thread minor diameter (min)",
549
+ group: THREAD,
550
+ quantity: "length",
551
+ op: "lte",
552
+ start: TOOL,
553
+ path: "facts.threading.spec.minMinorDiameter",
554
+ kinds: ["Hole"],
555
+ hint: "The smallest minor diameter allowed"
556
+ }),
557
+ define({
558
+ key: "threadMinorMax",
559
+ label: "thread minor diameter (max)",
560
+ group: THREAD,
561
+ quantity: "length",
562
+ op: "lte",
563
+ start: TOOL,
564
+ path: "facts.threading.spec.maxMinorDiameter",
565
+ kinds: ["Hole"],
566
+ hint: "The largest minor diameter allowed"
567
+ }),
568
+ define({
569
+ key: "threadMajorMin",
570
+ label: "thread major diameter (min)",
571
+ group: THREAD,
572
+ quantity: "length",
573
+ op: "lte",
574
+ start: TOOL,
575
+ path: "facts.threading.spec.minMajorDiameter",
576
+ kinds: ["Hole"],
577
+ hint: "The smallest major diameter allowed"
578
+ }),
579
+ define({
580
+ key: "threadPercentage",
581
+ label: "thread percentage",
582
+ group: THREAD,
583
+ quantity: "percent",
584
+ op: "gte",
585
+ start: 75,
586
+ path: "facts.threading.spec.threadPercentage",
587
+ kinds: ["Hole"],
588
+ hint: "How much of the theoretical thread depth is to be formed"
589
+ }),
590
+ define({
591
+ key: "chamferAngle",
592
+ label: "chamfer angle",
593
+ group: CHAMFER,
594
+ quantity: "angle",
595
+ op: "gte",
596
+ start: 60,
597
+ path: "facts.bevel.angleDeg",
598
+ kinds: ["Chamfer"],
599
+ 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"
600
+ }),
601
+ define({
602
+ key: "chamferSlant",
603
+ label: "bevel length",
604
+ group: CHAMFER,
605
+ quantity: "length",
606
+ op: "gte",
607
+ start: { mm: 2, inch: 2.54 },
608
+ path: "facts.bevel.slant",
609
+ kinds: ["Chamfer"],
610
+ hint: "How far the bevel runs along its slope: the cutting edge a tool needs to span it in one pass"
611
+ }),
612
+ define({
613
+ key: "chamferRoom",
614
+ label: "room under the bevel (tool-axis Z)",
615
+ group: CHAMFER,
616
+ quantity: "length",
617
+ op: "gte",
618
+ start: 0,
619
+ signed: true,
620
+ path: "facts.bevel.lowerAdjacentZMin",
621
+ kinds: ["Chamfer"],
622
+ hint: "The highest bottom among the features it stands on: how far a tool can overshoot below it"
623
+ }),
624
+ define({
625
+ key: "openPocketEdge",
626
+ label: "an open pocket edge",
627
+ group: CHAMFER,
628
+ quantity: "flag",
629
+ op: "gte",
630
+ start: 0,
631
+ path: "facts.bevel.isOpenPocketBottom",
632
+ kinds: ["Chamfer"],
633
+ hint: "It breaks the edge along an open pocket\u2019s floor"
634
+ }),
635
+ define({
636
+ key: "countersink",
637
+ label: "a countersink",
638
+ group: CHAMFER,
639
+ quantity: "flag",
640
+ op: "gte",
641
+ start: 0,
642
+ path: "facts.bevel.countersink.outerRadius",
643
+ kinds: ["Chamfer"],
644
+ hint: "The bevel is a countersink of revolution"
645
+ }),
646
+ define({
647
+ key: "countersinkOuter",
648
+ label: "countersink radius",
649
+ group: CHAMFER,
650
+ quantity: "length",
651
+ op: "gte",
652
+ start: TOOL,
653
+ path: "facts.bevel.countersink.outerRadius",
654
+ kinds: ["Chamfer"],
655
+ hint: "The radius it opens to at its upper edge"
656
+ }),
657
+ define({
658
+ key: "countersinkInner",
659
+ label: "countersink pilot radius",
660
+ group: CHAMFER,
661
+ quantity: "length",
662
+ op: "lte",
663
+ start: SMALL,
664
+ path: "facts.bevel.countersink.innerRadius",
665
+ kinds: ["Chamfer"],
666
+ hint: "The radius of the pilot hole it starts from"
667
+ }),
668
+ define({
669
+ key: "topFace",
670
+ label: "the top face",
671
+ group: FACES,
672
+ quantity: "flag",
673
+ op: "gte",
674
+ start: 0,
675
+ path: "facts.isTopFace",
676
+ kinds: ["Face"],
677
+ hint: "The highest surface of the part along the tool axis"
678
+ }),
679
+ define({
680
+ key: "facing",
681
+ label: "facing",
682
+ group: FACES,
683
+ quantity: "flag",
684
+ op: "gte",
685
+ start: 0,
686
+ path: "facts.isFacing",
687
+ kinds: ["Face"],
688
+ hint: "A top face that is to be faced off"
689
+ }),
690
+ define({
691
+ key: "modifiedProfile",
692
+ label: "a bridged outline",
693
+ group: FACES,
694
+ quantity: "flag",
695
+ op: "gte",
696
+ start: 0,
697
+ path: "facts.isModified",
698
+ kinds: ["Profile"],
699
+ hint: "The outline is a bridged reading of the part\u2019s silhouette"
700
+ }),
701
+ define({
702
+ key: "surfaceRadius",
703
+ label: "surface blend radius",
704
+ group: SURFACE,
705
+ quantity: "length",
706
+ op: "lte",
707
+ start: SMALL,
708
+ signed: true,
709
+ path: ["facts.filletRadius", "facts.three.filletRadius"],
710
+ kinds: ["Three", "Chamfer"],
711
+ hint: "Signed by which way it turns: positive over an edge, negative into a corner"
712
+ }),
713
+ define({
714
+ key: "maxStepdown",
715
+ label: "largest stepdown",
716
+ group: SURFACE,
717
+ quantity: "length",
718
+ op: "lte",
719
+ start: SMALL,
720
+ path: ["facts.maxStepdown", "facts.three.maxStepdown"],
721
+ kinds: ["Three", "Chamfer"],
722
+ hint: "The deepest cut taken in one pass down it"
723
+ }),
724
+ define({
725
+ key: "cuspHeight",
726
+ label: "finish scallop",
727
+ group: SURFACE,
728
+ quantity: "length",
729
+ op: "lte",
730
+ start: { mm: 0.01, inch: 0.0127 },
731
+ path: ["facts.surfaceFinishCuspHeight", "facts.three.surfaceFinishCuspHeight"],
732
+ kinds: ["Three", "Chamfer"],
733
+ hint: "How much scallop the finishing pass may leave"
734
+ }),
735
+ define({
736
+ key: "surfaceSharpCorner",
737
+ label: "a sharp surface corner",
738
+ group: SURFACE,
739
+ quantity: "flag",
740
+ op: "gte",
741
+ start: 0,
742
+ path: ["facts.hasSharpCorner", "facts.three.hasSharpCorner"],
743
+ kinds: ["Three", "Chamfer"],
744
+ hint: "It includes a sharp corner a tool must respect"
745
+ }),
746
+ define({
747
+ key: "ballOnly",
748
+ label: "ball-only finishing",
749
+ group: SURFACE,
750
+ quantity: "flag",
751
+ op: "gte",
752
+ start: 0,
753
+ path: ["facts.useOnlyBallToolsForFinish", "facts.three.useOnlyBallToolsForFinish"],
754
+ kinds: ["Three", "Chamfer"],
755
+ hint: "Only ball tools suit the finishing pass"
756
+ }),
757
+ define({
758
+ key: "fitCornerRadius",
759
+ label: "surface corner radius",
760
+ group: SURFACE,
761
+ quantity: "length",
762
+ op: "lte",
763
+ start: SMALL,
764
+ path: ["facts.toolFit.cornerRadius", "facts.three.toolFit.cornerRadius"],
765
+ kinds: ["Three", "Chamfer"],
766
+ hint: "The corner radius its shape admits"
767
+ }),
768
+ define({
769
+ key: "fitToolDiameter",
770
+ label: "largest tool its shape admits",
771
+ group: SURFACE,
772
+ quantity: "length",
773
+ op: "lte",
774
+ start: TOOL,
775
+ path: ["facts.toolFit.toolDiameter", "facts.three.toolFit.toolDiameter"],
776
+ kinds: ["Three", "Chamfer"],
777
+ hint: "The widest full-diameter tool its shape admits"
778
+ }),
779
+ define({
780
+ key: "fitToolBottom",
781
+ label: "largest tool bottom its shape admits",
782
+ group: SURFACE,
783
+ quantity: "length",
784
+ op: "lte",
785
+ start: TOOL,
786
+ path: ["facts.toolFit.toolBottomDiameter", "facts.three.toolFit.toolBottomDiameter"],
787
+ kinds: ["Three", "Chamfer"],
788
+ hint: "The widest tool bottom its shape admits"
789
+ }),
790
+ define({
791
+ key: "undercutDepth",
792
+ label: "undercut depth",
793
+ group: UNDERCUT,
794
+ quantity: "length",
795
+ op: "gte",
796
+ start: { mm: 5, inch: 5.08 },
797
+ path: "facts.undercutDepth",
798
+ kinds: ["Tslot"],
799
+ hint: "How far the groove runs back from its opening"
800
+ }),
801
+ define({
802
+ key: "maxEntry",
803
+ label: "largest tool that gets in",
804
+ group: UNDERCUT,
805
+ quantity: "length",
806
+ op: "lte",
807
+ start: { mm: 6, inch: 6.35 },
808
+ path: "facts.maxEntryCd",
809
+ kinds: ["Tslot"],
810
+ hint: "The widest tool that can come down through the opening above it"
811
+ }),
812
+ define({
813
+ key: "closedSlot",
814
+ label: "a closed outline",
815
+ group: UNDERCUT,
816
+ quantity: "flag",
817
+ op: "gte",
818
+ start: 0,
819
+ path: "facts.isClosed",
820
+ kinds: ["Tslot"],
821
+ hint: "Its walls close on themselves in plan view"
822
+ }),
823
+ define({
824
+ key: "externalUndercut",
825
+ label: "an external undercut",
826
+ group: UNDERCUT,
827
+ quantity: "flag",
828
+ op: "gte",
829
+ start: 0,
830
+ path: "facts.isExternal",
831
+ kinds: ["Tslot", "Dovetail"],
832
+ hint: "It runs around material standing in it, or runs out somewhere"
833
+ }),
834
+ define({
835
+ key: "taper",
836
+ label: "dovetail taper",
837
+ group: UNDERCUT,
838
+ quantity: "angle",
839
+ op: "gte",
840
+ start: 45,
841
+ path: "facts.taperDeg",
842
+ kinds: ["Dovetail"],
843
+ hint: "Between the overhanging wall and the tool axis"
844
+ }),
845
+ define({
846
+ key: "floorWidth",
847
+ label: "dovetail floor width",
848
+ group: UNDERCUT,
849
+ quantity: "length",
850
+ op: "lte",
851
+ start: { mm: 6, inch: 6.35 },
852
+ path: "facts.floorWidth",
853
+ kinds: ["Dovetail"],
854
+ hint: "The widest clearance over the floor"
855
+ }),
856
+ define({
857
+ key: "topOpening",
858
+ label: "dovetail top opening",
859
+ group: UNDERCUT,
860
+ quantity: "length",
861
+ op: "lte",
862
+ start: { mm: 6, inch: 6.35 },
863
+ path: "facts.topOpeningWidth",
864
+ kinds: ["Dovetail"],
865
+ hint: "The clearance through the opening at its top"
866
+ }),
867
+ define({
868
+ key: "bottomOpening",
869
+ label: "dovetail bottom opening",
870
+ group: UNDERCUT,
871
+ quantity: "length",
872
+ op: "lte",
873
+ start: { mm: 6, inch: 6.35 },
874
+ path: "facts.bottomOpeningWidth",
875
+ kinds: ["Dovetail"],
876
+ hint: "The clearance just above the floor, under the overhangs"
877
+ }),
878
+ define({
879
+ key: "invalidDovetail",
880
+ label: "unmeasured geometry",
881
+ group: UNDERCUT,
882
+ quantity: "flag",
883
+ op: "gte",
884
+ start: 0,
885
+ path: "facts.isInvalidGeometry",
886
+ kinds: ["Dovetail"],
887
+ hint: "A width could not be measured, so no tool is offered on its figures"
888
+ })
889
+ ];
890
+ var HOLE_KINDS = ["Hole"];
891
+ var POCKET_KINDS = ["Pocket"];
892
+ var SURFACE_KINDS = ["Three"];
893
+ var UNDERCUT_KINDS = ["Tslot", "Dovetail"];
894
+ var GROUP_SUBJECTS = [
895
+ { value: "any", label: "Any feature", group: "Groups" },
896
+ { value: "milled", label: "Milled features", group: "Groups" },
897
+ { value: "drilled", label: "Drilled features", group: "Groups", kinds: HOLE_KINDS },
898
+ { value: "hole", label: "Holes", group: "Groups", kinds: HOLE_KINDS },
899
+ { value: "threadedHole", label: "Threaded holes", group: "Groups", kinds: HOLE_KINDS },
900
+ { value: "pocket", label: "Pockets", group: "Groups", kinds: POCKET_KINDS },
901
+ { value: "slot", label: "Slots", group: "Groups", kinds: UNDERCUT_KINDS },
902
+ { value: "undercut", label: "Undercuts", group: "Groups", kinds: UNDERCUT_KINDS },
903
+ { value: "boss", label: "Bosses", group: "Groups", kinds: ["Boss"] },
904
+ { value: "countersink", label: "Countersinks", group: "Groups", kinds: ["Hole", "Chamfer"] },
905
+ { value: "profile", label: "Profiles", group: "Groups", kinds: ["Profile"] },
906
+ { value: "contour", label: "3D surfaces", group: "Groups", kinds: SURFACE_KINDS },
907
+ { value: "wall", label: "Walls", group: "Groups", kinds: ["Wall"] },
908
+ { value: "face", label: "Faces", group: "Groups", kinds: ["Face", "Three"] },
909
+ { value: "fillet", label: "Fillets", group: "Groups", kinds: SURFACE_KINDS },
910
+ { value: "chamfer", label: "Chamfers", group: "Groups", kinds: ["Chamfer"] }
911
+ ];
912
+ var TYPES = [
913
+ ["through_hole", "Through holes", HOLE_KINDS],
914
+ ["blind_hole", "Blind holes", HOLE_KINDS],
915
+ ["filleted_blind_hole", "Filleted blind holes", HOLE_KINDS],
916
+ ["tapered_through_hole", "Tapered through holes", HOLE_KINDS],
917
+ ["threaded_through_hole", "Threaded through holes", HOLE_KINDS],
918
+ ["threaded_blind_hole", "Threaded blind holes", HOLE_KINDS],
919
+ ["synthetic_hole", "Synthetic holes", HOLE_KINDS],
920
+ ["thread", "Threads", HOLE_KINDS],
921
+ ["sink", "Countersinks", ["Hole", "Chamfer"]],
922
+ ["back_sink", "Back countersinks", ["Hole", "Chamfer"]],
923
+ ["pocket", "Closed pockets", POCKET_KINDS],
924
+ ["open_pocket", "Open pockets", POCKET_KINDS],
925
+ ["through_pocket", "Through pockets", POCKET_KINDS],
926
+ ["filleted_pocket", "Filleted pockets", POCKET_KINDS],
927
+ ["filleted_open_pocket", "Filleted open pockets", POCKET_KINDS],
928
+ ["boss", "Bosses", ["Boss"]],
929
+ ["filleted_boss", "Filleted bosses", ["Boss"]],
930
+ ["wall", "Wall features", ["Wall"]],
931
+ ["face", "Face features", ["Face"]],
932
+ ["slanted_face", "Slanted faces", ["Face", "Three"]],
933
+ ["profile", "Profiles", ["Profile"]],
934
+ ["chamfer", "Chamfer features", ["Chamfer"]],
935
+ ["back_chamfer", "Back chamfers", ["Chamfer"]],
936
+ ["fillet", "Fillet features", SURFACE_KINDS],
937
+ ["inner_fillet", "Inner fillets", SURFACE_KINDS],
938
+ ["outer_fillet", "Outer fillets", SURFACE_KINDS],
939
+ ["contour_surface", "Contour surfaces", SURFACE_KINDS],
940
+ ["u_slot", "U-slots", UNDERCUT_KINDS],
941
+ ["undercut_wall", "Undercut walls", UNDERCUT_KINDS],
942
+ ["undercut_tslot", "Undercut T-slots", ["Tslot"]],
943
+ ["undercut_filleted_tslot", "Filleted undercut T-slots", ["Tslot"]],
944
+ ["undercut_dovetail", "Undercut dovetails", ["Dovetail"]],
945
+ ["undercut_filleted_dovetail", "Filleted undercut dovetails", ["Dovetail"]]
946
+ ];
947
+ var TYPE_SUBJECT = "type:";
948
+ var FEATURE_SUBJECTS = [
949
+ ...GROUP_SUBJECTS,
950
+ ...TYPES.map(([type, label, kinds]) => ({
951
+ value: `${TYPE_SUBJECT}${type}`,
952
+ label,
953
+ group: "Feature types",
954
+ kinds
955
+ }))
956
+ ];
957
+ var QUICK_SUBJECTS = [
958
+ "any",
959
+ "milled",
960
+ "hole",
961
+ "threadedHole",
962
+ "countersink",
963
+ "pocket",
964
+ "boss",
965
+ "wall",
966
+ "face",
967
+ "profile",
968
+ "undercut",
969
+ "fillet",
970
+ "chamfer",
971
+ "contour"
972
+ ];
973
+ var QUICK_METRICS = [
974
+ "present",
975
+ "ld",
976
+ "featureLd",
977
+ "depthBelowTop",
978
+ "featureDepth",
979
+ "diameter",
980
+ "cornerRadius",
981
+ "maxTool",
982
+ "pinchDiameter",
983
+ "sharpCorner",
984
+ "surfaceArea",
985
+ "floorFillet",
986
+ "threadPitch",
987
+ "chamferAngle",
988
+ "undercutDepth",
989
+ "tilted"
990
+ ];
991
+ var subjectInfo = (value) => FEATURE_SUBJECTS.find((each) => each.value === value);
992
+ var metricInfo = (key) => FEATURE_METRICS.find((each) => each.key === key);
993
+ var metricsForSubject = (subject) => {
994
+ const kinds = subjectInfo(subject)?.kinds;
995
+ if (!kinds) return FEATURE_METRICS;
996
+ return FEATURE_METRICS.filter(
997
+ (each) => !each.kinds || each.kinds.some((kind) => kinds.includes(kind))
998
+ );
999
+ };
1000
+
1001
+ // src/model/dfm-rules.ts
1002
+ import { MM_PER_INCH as MM_PER_INCH3 } from "@toolpath/tool-support";
1003
+
1004
+ // src/model/units.ts
1005
+ import { MM_PER_INCH as MM_PER_INCH2 } from "@toolpath/tool-support";
1006
+ var LENGTH_PLACES = { mm: 3, inch: 3 };
1007
+ var FIELD_LENGTH_PLACES = { mm: 3, inch: 4 };
1008
+ var UNIT_LABELS = { mm: "mm", inch: "in" };
1009
+ var inUnits = (mm, units) => units === "inch" ? mm / MM_PER_INCH2 : mm;
1010
+ var lengthValue = (mm, units) => inUnits(mm, units).toFixed(LENGTH_PLACES[units]);
1011
+ var unitLabel = (units) => UNIT_LABELS[units];
1012
+
1013
+ // src/model/dfm-rules.ts
1014
+ var EQUAL_TOLERANCE = 5e-3;
1015
+ var OPS = ["gte", "gt", "lte", "lt", "eq", "ne", "between"];
1016
+ var OP_SYMBOLS = {
1017
+ gte: "\u2265",
1018
+ gt: ">",
1019
+ lte: "\u2264",
1020
+ lt: "<",
1021
+ eq: "=",
1022
+ ne: "\u2260",
1023
+ between: "range"
1024
+ };
1025
+ var RANGE_LOWS = ["gte", "gt"];
1026
+ var RANGE_HIGHS = ["lt", "lte"];
1027
+ var opSymbol = (op) => OP_SYMBOLS[op];
1028
+ var RULE_COLORS = [
1029
+ { name: "Red", value: "#e5484d" },
1030
+ { name: "Orange", value: "#f76b15" },
1031
+ { name: "Yellow", value: "#ffd60a" },
1032
+ { name: "Purple", value: "#8e4ec6" },
1033
+ { name: "Green", value: "#30a46c" },
1034
+ { name: "Cyan", value: "#00a2c7" }
1035
+ ];
1036
+ var compareColors = (a, b) => {
1037
+ const rank = (color) => {
1038
+ const at = RULE_COLORS.findIndex((each) => each.value === color);
1039
+ return at === -1 ? RULE_COLORS.length : at;
1040
+ };
1041
+ return rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0);
1042
+ };
1043
+ var CRITICAL_COLOR = "#e5484d";
1044
+ var WARNING_COLOR = "#ffd60a";
1045
+ var RETIRED_COLORS = {
1046
+ // Taken out of this palette.
1047
+ "#e93d82": CRITICAL_COLOR,
1048
+ "#99d52a": "#30a46c",
1049
+ // The first palette.
1050
+ "#dc2626": CRITICAL_COLOR,
1051
+ "#f97316": "#f76b15",
1052
+ "#eab308": WARNING_COLOR,
1053
+ "#16a34a": "#30a46c",
1054
+ "#0d9488": "#00a2c7",
1055
+ "#2563eb": "#00a2c7",
1056
+ "#9333ea": "#8e4ec6",
1057
+ "#db2777": CRITICAL_COLOR,
1058
+ // The Toolpath tokens.
1059
+ "#ba5b4b": CRITICAL_COLOR,
1060
+ "#843e32": CRITICAL_COLOR,
1061
+ "#e07a48": "#f76b15",
1062
+ "#d6632a": "#f76b15",
1063
+ "#cd8e04": WARNING_COLOR,
1064
+ "#ffe066": WARNING_COLOR,
1065
+ "#68b688": "#30a46c",
1066
+ "#6bb0b3": "#00a2c7"
1067
+ };
1068
+ var subjectLabel = (value) => subjectInfo(value)?.label ?? value;
1069
+ var trim = (value, places) => String(Number(value.toFixed(places)));
1070
+ var SQUARE_MM_PER_SQUARE_INCH = MM_PER_INCH3 ** 2;
1071
+ var quantityUnit = (quantity, units) => {
1072
+ switch (quantity) {
1073
+ case "length":
1074
+ return units === "inch" ? "in" : "mm";
1075
+ case "area":
1076
+ return units === "inch" ? "in\xB2" : "mm\xB2";
1077
+ case "angle":
1078
+ return "\xB0";
1079
+ case "percent":
1080
+ return "%";
1081
+ default:
1082
+ return "";
1083
+ }
1084
+ };
1085
+ var withUnit = (value, unit) => unit === "" ? value : unit === "\xB0" || unit === "%" ? `${value}${unit}` : `${value} ${unit}`;
1086
+ var toUnits = (quantity, value, units) => units !== "inch" ? value : quantity === "length" ? value / MM_PER_INCH3 : quantity === "area" ? value / SQUARE_MM_PER_SQUARE_INCH : value;
1087
+ var toField = (quantity, value, units) => {
1088
+ const shown = toUnits(quantity, value, units);
1089
+ return quantity === "length" ? trim(shown, FIELD_LENGTH_PLACES[units]) : quantity === "count" ? trim(shown, 0) : trim(shown, 3);
1090
+ };
1091
+ var fromField = (metric, text, units) => {
1092
+ const parsed = Number(text.trim());
1093
+ if (text.trim() === "" || !Number.isFinite(parsed) || parsed < 0 && !metric.signed) return null;
1094
+ if (metric.quantity === "count" && !Number.isInteger(parsed)) return null;
1095
+ if (units !== "inch") return parsed;
1096
+ return metric.quantity === "length" ? parsed * MM_PER_INCH3 : metric.quantity === "area" ? parsed * SQUARE_MM_PER_SQUARE_INCH : parsed;
1097
+ };
1098
+ var formatMeasured = (quantity, value, units) => {
1099
+ const shown = toUnits(quantity, value, units);
1100
+ switch (quantity) {
1101
+ case "length":
1102
+ return units === "inch" ? `${trim(shown, LENGTH_PLACES.inch)}"` : `${trim(shown, LENGTH_PLACES.mm)} mm`;
1103
+ case "area":
1104
+ return `${Number(shown.toFixed(3)).toLocaleString(void 0, { maximumFractionDigits: 3 })} ${quantityUnit("area", units)}`;
1105
+ case "angle":
1106
+ return `${trim(shown, 3)}\xB0`;
1107
+ case "percent":
1108
+ return `${trim(shown, 3)}%`;
1109
+ case "count":
1110
+ return trim(shown, 0);
1111
+ case "flag":
1112
+ case "presence":
1113
+ return "";
1114
+ default:
1115
+ return trim(shown, 3);
1116
+ }
1117
+ };
1118
+ var describeRule = (rule, units) => {
1119
+ const info = metricInfo(rule.metric);
1120
+ const subject = subjectLabel(rule.subject);
1121
+ if (!info) return subject;
1122
+ if (info.quantity === "presence") return `${subject}, ${info.label}`;
1123
+ if (info.quantity === "flag") return `${subject} with ${info.label}`;
1124
+ const unit = quantityUnit(info.quantity, units);
1125
+ const shown = (value) => withUnit(toField(info.quantity, value, units), unit);
1126
+ 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)}`;
1127
+ };
1128
+ var newRuleId = () => Math.random().toString(36).slice(2, 10);
1129
+ var makeRule = (subject, metricKey, units, color = WARNING_COLOR) => {
1130
+ const info = metricInfo(metricKey) ?? LD_METRIC;
1131
+ return {
1132
+ id: newRuleId(),
1133
+ subject,
1134
+ metric: info.key,
1135
+ op: info.op,
1136
+ value: info.start[units],
1137
+ color
1138
+ };
1139
+ };
1140
+ var upperOf = (rule) => rule.max ?? rule.value + Math.max(Math.abs(rule.value), 1);
1141
+ var lowOf = (rule) => rule.lowOp ?? "gte";
1142
+ var highOf = (rule) => rule.highOp ?? "lt";
1143
+ var withoutRange = ({ max: _max, lowOp: _low, highOp: _high, ...rest }) => rest;
1144
+ var withMetric = (rule, metricKey, units) => {
1145
+ const info = metricInfo(metricKey);
1146
+ if (!info) return rule;
1147
+ return { ...withoutRange(rule), metric: info.key, op: info.op, value: info.start[units] };
1148
+ };
1149
+ var withOp = (rule, op) => op === "between" ? { ...rule, op, max: upperOf(rule), lowOp: lowOf(rule), highOp: highOf(rule) } : { ...withoutRange(rule), op };
1150
+ var withSubject = (rule, subject, units) => {
1151
+ const metrics = metricsForSubject(subject);
1152
+ const moved = { ...rule, subject };
1153
+ return metrics.some((each) => each.key === rule.metric) ? moved : withMetric(moved, (metrics[0] ?? LD_METRIC).key, units);
1154
+ };
1155
+ var breaks = (rule, quantity, value) => {
1156
+ if (quantity === "presence") return true;
1157
+ if (quantity === "flag") return value > 0;
1158
+ if (rule.op === "gte") return value >= rule.value;
1159
+ if (rule.op === "gt") return value > rule.value;
1160
+ if (rule.op === "lte") return value <= rule.value;
1161
+ if (rule.op === "lt") return value < rule.value;
1162
+ if (rule.op === "between") {
1163
+ const high = upperOf(rule);
1164
+ const aboveLow = lowOf(rule) === "gt" ? value > rule.value : value >= rule.value;
1165
+ const belowHigh = highOf(rule) === "lte" ? value <= high : value < high;
1166
+ return aboveLow && belowHigh;
1167
+ }
1168
+ const equal = Math.abs(value - rule.value) <= Math.max(Math.abs(rule.value) * EQUAL_TOLERANCE, 1e-6);
1169
+ return rule.op === "ne" ? !equal : equal;
1170
+ };
1171
+ var worseUpward = (rule, metric) => rule.op === "gte" || rule.op === "gt" ? true : rule.op === "lte" || rule.op === "lt" ? false : metric.op !== "lte" && metric.op !== "lt";
1172
+ var COLOR = /^#[0-9a-f]{6}$/i;
1173
+ var isValue = (value, metric) => typeof value === "number" && Number.isFinite(value) && (value >= 0 || metric?.signed === true) && (metric?.quantity !== "count" || Number.isInteger(value));
1174
+ var parseRule = (raw) => {
1175
+ if (!raw || typeof raw !== "object") return null;
1176
+ const {
1177
+ id,
1178
+ subject,
1179
+ metric: key,
1180
+ op,
1181
+ value,
1182
+ max,
1183
+ lowOp,
1184
+ highOp,
1185
+ color
1186
+ } = raw;
1187
+ const info = typeof key === "string" ? metricInfo(key) : void 0;
1188
+ const valid = typeof id === "string" && typeof subject === "string" && subjectInfo(subject) !== 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);
1189
+ if (!valid) return null;
1190
+ const hex = color.toLowerCase();
1191
+ return {
1192
+ id,
1193
+ subject,
1194
+ metric: key,
1195
+ op,
1196
+ value,
1197
+ ...op === "between" ? {
1198
+ max,
1199
+ ...RANGE_LOWS.includes(lowOp) ? { lowOp } : {},
1200
+ ...RANGE_HIGHS.includes(highOp) ? { highOp } : {}
1201
+ } : {},
1202
+ color: RETIRED_COLORS[hex] ?? hex
1203
+ };
1204
+ };
1205
+ var parseRules = (raw) => Array.isArray(raw) ? raw.flatMap((each) => parseRule(each) ?? []) : null;
1206
+
1207
+ // src/model/hole-groups.ts
1208
+ var holeSize = (sheets, tag) => {
1209
+ const sheet = sheets[tag.toLowerCase()];
1210
+ if (sheet?.kind !== "Hole" || sheet.diameter === void 0 || sheet.zMin === void 0 || sheet.zMax === void 0) {
1211
+ return null;
1212
+ }
1213
+ return { diameter: sheet.diameter, depth: sheet.zMax - sheet.zMin };
1214
+ };
1215
+ var SAME_DEPTH = 0.05;
1216
+ var DEPTH_FLOOR = 0.01;
1217
+ var TOLERANCE = 1e-6;
1218
+ var sameDepth = (a, b) => Math.abs(a - b) <= Math.max(DEPTH_FLOOR, Math.max(Math.abs(a), Math.abs(b)) * SAME_DEPTH);
1219
+ 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;
1220
+ var sameHoleSets = (features, sheets) => {
1221
+ const sizes = features.map((feature) => holeSize(sheets, feature.tag));
1222
+ const root = features.map((_, at) => at);
1223
+ const find = (at) => {
1224
+ let top = at;
1225
+ while (root[top] !== top) top = root[top] ?? top;
1226
+ root[at] = top;
1227
+ return top;
1228
+ };
1229
+ for (let a = 0; a < features.length; a += 1) {
1230
+ for (let b = a + 1; b < features.length; b += 1) {
1231
+ const [first, second] = [sizes[a], sizes[b]];
1232
+ const [one, other] = [features[a], features[b]];
1233
+ if (first && second && one && other && sameDirection(one.machiningDirection, other.machiningDirection) && first.diameter.toFixed(3) === second.diameter.toFixed(3) && sameDepth(first.depth, second.depth)) {
1234
+ const [low, high] = [find(a), find(b)].sort((x, y) => x - y);
1235
+ if (high !== void 0 && low !== void 0) root[high] = low;
1236
+ }
1237
+ }
1238
+ }
1239
+ const sets = /* @__PURE__ */ new Map();
1240
+ features.forEach((feature, at) => {
1241
+ const top = find(at);
1242
+ sets.set(top, [...sets.get(top) ?? [], feature]);
1243
+ });
1244
+ return [...sets.values()];
1245
+ };
1246
+ var featureRows = (tags, features, sheets) => {
1247
+ const byTag = new Map(features.map((feature) => [feature.tag, feature]));
1248
+ const listed = tags.flatMap((tag) => byTag.get(tag) ?? []);
1249
+ const holes = listed.filter((feature) => holeSize(sheets, feature.tag));
1250
+ const setOf = /* @__PURE__ */ new Map();
1251
+ for (const set of sameHoleSets(holes, sheets)) {
1252
+ if (set.length > 1) for (const feature of set) setOf.set(feature.tag, set);
1253
+ }
1254
+ const placed = /* @__PURE__ */ new Set();
1255
+ return tags.flatMap((tag) => {
1256
+ const set = setOf.get(tag);
1257
+ if (!set) return [{ kind: "feature", tag }];
1258
+ const [first, ...rest] = set;
1259
+ if (!first || placed.has(first.tag)) return [];
1260
+ placed.add(first.tag);
1261
+ return [
1262
+ {
1263
+ kind: "holes",
1264
+ key: first.tag,
1265
+ tags: [first.tag, ...rest.map((feature) => feature.tag)],
1266
+ diameter: holeSize(sheets, first.tag)?.diameter ?? 0
1267
+ }
1268
+ ];
1269
+ });
1270
+ };
1271
+
1272
+ // src/model/geometry.ts
1273
+ var dfmFeatures = (features) => {
1274
+ const read = features.map(
1275
+ ({ featureTag, featureType, machiningDirection: { x, y, z }, regionIdxs }) => ({
1276
+ tag: featureTag,
1277
+ featureType,
1278
+ machiningDirection: { x, y, z },
1279
+ regionIdxs
1280
+ })
1281
+ );
1282
+ const directions = [];
1283
+ read.forEach(({ machiningDirection }) => {
1284
+ if (!directions.some((each) => sameDirection2(each, machiningDirection)))
1285
+ directions.push(machiningDirection);
1286
+ });
1287
+ const numbered = directions.flatMap(
1288
+ (direction) => read.filter((feature) => sameDirection2(feature.machiningDirection, direction))
1289
+ );
1290
+ const numberOf = new Map(numbered.map((feature, index) => [feature, index + 1]));
1291
+ return read.map((feature) => ({ ...feature, number: numberOf.get(feature) ?? 0 }));
1292
+ };
1293
+ var TOLERANCE2 = 1e-6;
1294
+ 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;
1295
+ var featureTypeLabel = (featureType) => {
1296
+ const words = featureType.replace(/_/g, " ").trim();
1297
+ return words.charAt(0).toUpperCase() + words.slice(1);
1298
+ };
1299
+ var partTop = (features, sheets, direction) => {
1300
+ let top = null;
1301
+ for (const other of features) {
1302
+ const zMax = sheets[other.tag.toLowerCase()]?.extendedZMax;
1303
+ if (zMax !== void 0 && sameDirection2(other.machiningDirection, direction)) {
1304
+ top = top === null ? zMax : Math.max(top, zMax);
1305
+ }
1306
+ }
1307
+ return top;
1308
+ };
1309
+ var FLAT_TIP_DEG = 0.5;
1310
+ var isDrilled = (sheet) => sheet?.diameter !== void 0 && sheet.diameter > 0 && !(sheet.tipAngle !== void 0 && sheet.tipAngle >= 180 - FLAT_TIP_DEG);
1311
+ var featureLd = (features, feature, sheets) => {
1312
+ const sheet = sheets[feature.tag.toLowerCase()];
1313
+ const top = sheet ? partTop(features, sheets, feature.machiningDirection) : null;
1314
+ const bottom = sheet?.zMin;
1315
+ if (!sheet || top === null || bottom === void 0) return null;
1316
+ const drilling = isDrilled(sheet);
1317
+ const [basis, across] = sheet.diameter !== void 0 ? ["bore", sheet.diameter] : sheet.pinchDiameter !== void 0 ? ["pinch", sheet.pinchDiameter] : [
1318
+ "radius",
1319
+ sheet.sharpCorners || sheet.cornerRadius === void 0 ? void 0 : 2 * sheet.cornerRadius
1320
+ ];
1321
+ if (across === void 0 || across <= 0) return null;
1322
+ const atLeast = basis !== "bore" && sheet.cornerRadiusUnresolved === true;
1323
+ return {
1324
+ ratio: (top - bottom) / across,
1325
+ ...sheet.zMax !== void 0 ? { featureRatio: (sheet.zMax - bottom) / across } : {},
1326
+ drilling,
1327
+ atLeast,
1328
+ basis,
1329
+ across
1330
+ };
1331
+ };
1332
+
1333
+ // src/model/dfm-explain.ts
1334
+ var figureless = (hit) => hit.metric.quantity === "flag" || hit.metric.quantity === "presence";
1335
+ var hitFigure = (hit, units) => figureless(hit) ? "" : formatMeasured(hit.metric.quantity, hit.value, units);
1336
+
1337
+ // src/model/rule-set-file.ts
1338
+ var FILE_KIND = "toolpath-dfm-rules";
1339
+ var serializeRuleSet = (rules, meta = {}) => JSON.stringify(
1340
+ {
1341
+ kind: FILE_KIND,
1342
+ version: 1,
1343
+ ...meta.name ? { name: meta.name } : {},
1344
+ ...meta.description ? { description: meta.description } : {},
1345
+ units: "mm",
1346
+ rules: [...rules]
1347
+ },
1348
+ null,
1349
+ 2
1350
+ );
1351
+ var parseRuleSetFile = (text) => {
1352
+ let raw;
1353
+ try {
1354
+ raw = JSON.parse(text);
1355
+ } catch {
1356
+ return { error: "This is not JSON." };
1357
+ }
1358
+ const list = Array.isArray(raw) ? raw : typeof raw === "object" && raw !== null && Array.isArray(raw.rules) ? raw.rules : null;
1359
+ if (!list) return { error: 'No rules in it: expected an object with a "rules" list.' };
1360
+ const units = Array.isArray(raw) ? void 0 : raw.units;
1361
+ if (units !== void 0 && units !== "mm")
1362
+ return { error: `Its lengths are in ${JSON.stringify(units)}; only "mm" can be read.` };
1363
+ const seen = /* @__PURE__ */ new Set();
1364
+ const withIds = list.map((each) => {
1365
+ if (typeof each !== "object" || each === null) return each;
1366
+ const rule = each;
1367
+ const id = typeof rule.id === "string" && !seen.has(rule.id) ? rule.id : newRuleId();
1368
+ seen.add(id);
1369
+ return { ...rule, id };
1370
+ });
1371
+ const rules = parseRules(withIds) ?? [];
1372
+ if (rules.length === 0) return { error: "None of its rules could be read." };
1373
+ const name = typeof raw === "object" && raw !== null && typeof raw.name === "string" ? raw.name : void 0;
1374
+ return { rules, skipped: list.length - rules.length, ...name ? { name } : {} };
1375
+ };
1376
+ var OP_WORDS = {
1377
+ gte: "at or above (\u2265)",
1378
+ gt: "above (>)",
1379
+ lte: "at or below (\u2264)",
1380
+ lt: "below (<)",
1381
+ eq: "equal, within half a percent (=)",
1382
+ ne: "not equal (\u2260)",
1383
+ between: "in a range: `value` \u2264 x < `max` by default; `lowOp` (gte|gt) and `highOp` (lt|lte) change the ends"
1384
+ };
1385
+ var llmRulePrompt = (current = []) => {
1386
+ const groups = FEATURE_SUBJECTS.filter((each) => !each.value.startsWith(TYPE_SUBJECT));
1387
+ const types = FEATURE_SUBJECTS.filter((each) => each.value.startsWith(TYPE_SUBJECT));
1388
+ const metricLines = FEATURE_METRICS.map((metric) => {
1389
+ const unit = quantityUnit(metric.quantity, "mm");
1390
+ 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}${unit ? `, in ${unit}` : ""}; a new rule usually starts around ${metric.start.mm}`;
1391
+ const kinds = metric.kinds ? ` Only features of kind ${metric.kinds.join("/")} report it.` : "";
1392
+ return `- \`${metric.key}\` \u2014 "${metric.label}" (${kind}). ${metric.hint}.${kinds}`;
1393
+ });
1394
+ const example = serializeRuleSet(
1395
+ [
1396
+ { id: "r1", subject: "hole", metric: "ld", op: "gte", value: 8, color: "#ffd60a" },
1397
+ {
1398
+ id: "r2",
1399
+ subject: "milled",
1400
+ metric: "featureLd",
1401
+ op: "between",
1402
+ value: 4,
1403
+ max: 8,
1404
+ lowOp: "gte",
1405
+ highOp: "lt",
1406
+ color: "#f76b15"
1407
+ },
1408
+ { id: "r3", subject: "milled", metric: "sharpCorner", op: "gte", value: 0, color: "#e5484d" },
1409
+ { id: "r4", subject: "undercut", metric: "present", op: "gte", value: 0, color: "#8e4ec6" }
1410
+ ],
1411
+ { name: "Example set", description: "Shows one rule of each shape" }
1412
+ );
1413
+ return [
1414
+ "# Write a Toolpath DFM rule set",
1415
+ "",
1416
+ "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.",
1417
+ "",
1418
+ "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.",
1419
+ "",
1420
+ '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.',
1421
+ "",
1422
+ "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.",
1423
+ "",
1424
+ "### Round 1 \u2014 the shop",
1425
+ "1. What they make and in what materials. This sets the defaults, not a rule.",
1426
+ "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.",
1427
+ "",
1428
+ "### Round 2 \u2014 milling (pockets, slots, walls, bosses, profiles, 3D surfaces)",
1429
+ "3. The smallest end mill they are happy to run: `milled` with `maxTool` below it, and `milled` with `cornerRadius` below half of it.",
1430
+ "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).",
1431
+ "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`.",
1432
+ "6. Sharp inside corners: whether every one should be flagged (`milled` with `sharpCorner`), or only on certain feature kinds.",
1433
+ "7. Floor fillets: whether a floor fillet larger than their ball or bull-nose tooling should be flagged (`pocket` with `floorFillet` above a radius).",
1434
+ "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.",
1435
+ "9. Large features that drive cycle time, if they quote: `pocket` with `projectedFloorArea` or `milled` with `surfaceArea` above a size.",
1436
+ "",
1437
+ "### Round 3 \u2014 drilling (holes, threads, countersinks)",
1438
+ "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.",
1439
+ "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.",
1440
+ "12. Threads: the smallest thread they tap (`hole` with `threadDiameter` below it), and whether every threaded hole should be marked (`threadedHole` with `present`).",
1441
+ "13. Countersinks and counterbores: whether to call out countersinks (`chamfer` with `countersink`) or counterbored holes (`hole` with `counterbore`).",
1442
+ "",
1443
+ "### Round 4 \u2014 closing",
1444
+ "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`.",
1445
+ "15. Which of the rules so far are critical (red), which are cautions (orange) and which are just worth a look (yellow).",
1446
+ "",
1447
+ `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.`,
1448
+ "",
1449
+ "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.",
1450
+ "",
1451
+ "## Format",
1452
+ "",
1453
+ 'Top level: `{"kind":"toolpath-dfm-rules","version":1,"name":"\u2026","description":"\u2026","units":"mm","rules":[\u2026]}`.',
1454
+ "Every length is in millimetres, areas in mm\xB2, angles in degrees, ratios plain. Convert from inches yourself (1 in = 25.4 mm).",
1455
+ "",
1456
+ "Each rule is one sentence \u2014 *{subject} with {metric} {op} {value}* \u2014 with the fields:",
1457
+ "- `id`: a short unique string.",
1458
+ "- `subject`: which features (a value from the list below).",
1459
+ "- `metric`: what is measured (a key from the list below).",
1460
+ "- `op`: one of " + OPS.map((op) => `\`${op}\``).join(", ") + ".",
1461
+ ...OPS.map((op) => ` - \`${op}\`: ${OP_WORDS[op]}`),
1462
+ "- `value`: the threshold (canonical units). For `between`, the low end; add `max` for the high end.",
1463
+ "- `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.",
1464
+ "",
1465
+ "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.",
1466
+ "",
1467
+ "## Subjects (`subject`)",
1468
+ "",
1469
+ "Broad groups:",
1470
+ ...groups.map((each) => `- \`${each.value}\` \u2014 ${each.label}`),
1471
+ "",
1472
+ "One exact Engine feature type:",
1473
+ ...types.map((each) => `- \`${each.value}\` \u2014 ${each.label}`),
1474
+ "",
1475
+ "## Metrics (`metric`)",
1476
+ "",
1477
+ ...metricLines,
1478
+ "",
1479
+ "## Example",
1480
+ "",
1481
+ "```json",
1482
+ example,
1483
+ "```",
1484
+ ...current.length > 0 ? [
1485
+ "",
1486
+ "## The user's current rules",
1487
+ "",
1488
+ "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:",
1489
+ "",
1490
+ "```json",
1491
+ serializeRuleSet(current),
1492
+ "```"
1493
+ ] : [],
1494
+ ""
1495
+ ].join("\n");
1496
+ };
1497
+
1498
+ // src/model/reach.ts
1499
+ var numbers = (value) => Array.isArray(value) && value.every((each) => typeof each === "number" && Number.isFinite(each)) ? value : null;
1500
+ var readReachCurve = (datasheet) => {
1501
+ const curve = datasheet?.reachCurve;
1502
+ const offsets = numbers(curve?.horizontalOffset);
1503
+ const heights = numbers(curve?.verticalOffset);
1504
+ if (!offsets || !heights || offsets.length === 0 || offsets.length !== heights.length) return null;
1505
+ return { offsets, heights };
1506
+ };
1507
+ var runStart = ({ offsets }, index) => index === 0 ? 0 : offsets[index - 1] ?? 0;
1508
+ var peakHeight = ({ heights }) => Math.max(0, ...heights);
1509
+ var TAIL_MM = 12.7;
1510
+ var peakSlack = (peak) => Math.max(0.05, peak * 0.01);
1511
+ var peakStart = (curve) => {
1512
+ const peak = peakHeight(curve);
1513
+ const slack = peakSlack(peak);
1514
+ const index = curve.heights.findIndex((height) => height >= peak - slack);
1515
+ return runStart(curve, index === -1 ? 0 : index);
1516
+ };
1517
+ var trimmedCurve = (curve) => {
1518
+ const last = curve.offsets[curve.offsets.length - 1] ?? 0;
1519
+ const extent = peakStart(curve) + TAIL_MM;
1520
+ if (extent >= last) return { curve, cut: false };
1521
+ const keep = curve.offsets.findIndex((offset) => offset >= extent);
1522
+ return {
1523
+ curve: {
1524
+ offsets: [...curve.offsets.slice(0, keep), extent],
1525
+ heights: curve.heights.slice(0, keep + 1)
1526
+ },
1527
+ cut: true
1528
+ };
1529
+ };
1530
+ var featureAcross = (sheet) => {
1531
+ if (sheet?.diameter !== void 0 && sheet.diameter > 0)
1532
+ return { width: sheet.diameter, kind: "diameter" };
1533
+ if (sheet?.pinchDiameter !== void 0 && sheet.pinchDiameter > 0)
1534
+ return { width: sheet.pinchDiameter, kind: "clearance" };
1535
+ return null;
1536
+ };
1537
+ var isThroughType = (featureType) => /(^|_)through(_|$)/.test(featureType.toLowerCase());
1538
+ var featureProfile = (sheet, featureType) => {
1539
+ const depth = sheet?.zMax !== void 0 && sheet.zMin !== void 0 ? sheet.zMax - sheet.zMin : null;
1540
+ return {
1541
+ across: featureAcross(sheet),
1542
+ depth: depth !== null && depth > 0 ? depth : null,
1543
+ through: isThroughType(featureType)
1544
+ };
1545
+ };
1546
+
1547
+ export {
1548
+ PRESENCE_METRIC,
1549
+ TYPE_SUBJECT,
1550
+ FEATURE_SUBJECTS,
1551
+ QUICK_SUBJECTS,
1552
+ QUICK_METRICS,
1553
+ metricInfo,
1554
+ metricsForSubject,
1555
+ LENGTH_PLACES,
1556
+ lengthValue,
1557
+ unitLabel,
1558
+ OPS,
1559
+ RANGE_LOWS,
1560
+ RANGE_HIGHS,
1561
+ opSymbol,
1562
+ RULE_COLORS,
1563
+ compareColors,
1564
+ CRITICAL_COLOR,
1565
+ WARNING_COLOR,
1566
+ subjectLabel,
1567
+ quantityUnit,
1568
+ toField,
1569
+ fromField,
1570
+ formatMeasured,
1571
+ describeRule,
1572
+ makeRule,
1573
+ upperOf,
1574
+ lowOf,
1575
+ highOf,
1576
+ withMetric,
1577
+ withOp,
1578
+ withSubject,
1579
+ breaks,
1580
+ worseUpward,
1581
+ parseRules,
1582
+ sameDepth,
1583
+ featureRows,
1584
+ dfmFeatures,
1585
+ featureTypeLabel,
1586
+ partTop,
1587
+ isDrilled,
1588
+ featureLd,
1589
+ hitFigure,
1590
+ FILE_KIND,
1591
+ serializeRuleSet,
1592
+ parseRuleSetFile,
1593
+ llmRulePrompt,
1594
+ readReachCurve,
1595
+ runStart,
1596
+ peakHeight,
1597
+ trimmedCurve,
1598
+ featureProfile
1599
+ };