@toolpath/tool-scraper 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/columns.d.ts +5 -3
  2. package/dist/columns.js +11 -5
  3. package/dist/conventions.d.ts +54 -0
  4. package/dist/conventions.js +59 -0
  5. package/dist/errors.d.ts +30 -0
  6. package/dist/errors.js +30 -0
  7. package/dist/families/emuge.d.ts +3 -1
  8. package/dist/families/emuge.js +8 -4
  9. package/dist/family.d.ts +16 -2
  10. package/dist/holding.d.ts +396 -0
  11. package/dist/holding.js +360 -0
  12. package/dist/index.d.ts +23 -13
  13. package/dist/index.js +23 -13
  14. package/dist/node/cad-mirror.d.ts +58 -1
  15. package/dist/node/cad-mirror.js +56 -8
  16. package/dist/node/cli.d.ts +4 -1
  17. package/dist/node/cli.js +198 -19
  18. package/dist/node/holder-import.d.ts +223 -0
  19. package/dist/node/holder-import.js +379 -0
  20. package/dist/node/index.d.ts +1 -0
  21. package/dist/node/index.js +1 -0
  22. package/dist/node/paths.d.ts +16 -0
  23. package/dist/node/paths.js +20 -0
  24. package/dist/profiles.d.ts +265 -0
  25. package/dist/profiles.js +295 -0
  26. package/dist/records.d.ts +57 -12
  27. package/dist/records.js +37 -13
  28. package/dist/registry.d.ts +86 -5
  29. package/dist/registry.js +149 -8
  30. package/dist/vendors/destinytool/records.js +8 -0
  31. package/dist/vendors/emuge/records.d.ts +83 -3
  32. package/dist/vendors/emuge/records.js +184 -16
  33. package/dist/vendors/kennametal/family.d.ts +119 -0
  34. package/dist/vendors/kennametal/family.js +155 -0
  35. package/dist/vendors/kennametal/holding.d.ts +35 -0
  36. package/dist/vendors/kennametal/holding.js +112 -0
  37. package/dist/vendors/kennametal/index.d.ts +2 -0
  38. package/dist/vendors/kennametal/index.js +2 -0
  39. package/dist/vendors/kennametal/records.d.ts +16 -0
  40. package/dist/vendors/kennametal/records.js +32 -0
  41. package/dist/vendors/kennametal/scrape.d.ts +25 -2
  42. package/dist/vendors/kennametal/scrape.js +28 -3
  43. package/dist/vendors/maritool/holding.d.ts +79 -0
  44. package/dist/vendors/maritool/holding.js +164 -0
  45. package/dist/vendors/maritool/index.d.ts +1 -0
  46. package/dist/vendors/maritool/index.js +1 -0
  47. package/dist/vendors/maritool/scrape.d.ts +37 -13
  48. package/dist/vendors/maritool/scrape.js +53 -14
  49. package/dist/vendors/regofix/holding.d.ts +35 -0
  50. package/dist/vendors/regofix/holding.js +108 -0
  51. package/dist/vendors/regofix/index.d.ts +1 -0
  52. package/dist/vendors/regofix/index.js +1 -0
  53. package/dist/vendors/regofix/scrape.js +2 -2
  54. package/package.json +1 -1
@@ -0,0 +1,265 @@
1
+ /**
2
+ * A measured holder envelope, on the gage line, as the drawing wants it.
3
+ *
4
+ * `holding.ts` mints a holder from what the vendor *publishes*. This is the
5
+ * other half: what the holder's own CAD model *measures*, reduced to the
6
+ * silhouette a 2D elevation draws and cross-checked against the published gage
7
+ * length. The measuring is the Toolpath Engine API's — `node/holder-import.ts`
8
+ * makes the calls — and everything here is pure, so the 0/1/2/N cases are
9
+ * testable against literals rather than against a stack that has to be running.
10
+ *
11
+ * ## What replaced 1,800 lines of Python and a GUI
12
+ *
13
+ * The reference implementation sampled each STEP solid from inside a live
14
+ * Fusion 360 process over an MCP bridge, then did its own z-binned envelope,
15
+ * outward-only Douglas–Peucker, undercut fill and 7:24 taper solve. The API
16
+ * returns all of it — the layer stack, the gauge length, the size class and the
17
+ * taper family — so what is left here is a change of datum and a cross-check.
18
+ *
19
+ * The two agree. Four holders re-measured through the API against the profiles
20
+ * Fusion produced for the same STEP files, at the same 0.05 mm tolerance:
21
+ * `BT30ER16060M` 60 vs 60.000003 (2.8 nm), `BT30HC14100M` 100.355 vs
22
+ * 100.354999, `BT30HPVTT038295` 75 vs 75.000000, `BTKV30ER16100M` 89.4 vs
23
+ * 89.400000, with the total envelope height matching to three decimals on all
24
+ * four. The API decimates finer — about twice the layers at the same tolerance
25
+ * — which costs a drawing segments and changes no dimension.
26
+ *
27
+ * ## Its own document, keyed by guid
28
+ *
29
+ * A profile is ~110 points that only an assembly drawing needs, and a catalog
30
+ * is loaded by every page. So this is a second document that ships beside the
31
+ * records and is read lazily, rather than a field on {@link HolderRecord}.
32
+ * Keyed by guid because that is the one identifier that survives a re-scrape.
33
+ *
34
+ * **Collets get no profile.** They publish no CAD model and are not drawn: a
35
+ * collet sits inside the nut, which the holder's own envelope already includes.
36
+ */
37
+ import type { HolderRecord } from './holding.js';
38
+ import type { BrandName } from './identity.js';
39
+ /** Bumped when {@link ProfilesDocument}'s shape changes in a way a consumer must handle. */
40
+ export declare const PROFILES_VERSION = 1;
41
+ /**
42
+ * How far a measured gage length may sit from the vendor's published one before
43
+ * the profile is called incomplete, in millimetres.
44
+ *
45
+ * **Both bounds come from the data rather than from feel.** The largest
46
+ * *explained* deviation across the 53-holder Kennametal corpus is 0.355 mm —
47
+ * the nose lip on `BT30HC14100M`, real material past the face the vendor
48
+ * measures `L1` to, reproduced by the API to 1.5 nm. The smallest *real*
49
+ * shortfall is 10.6 mm — the collet nut the five BTKV30 STEP models omit, whose
50
+ * bodies stop at the threaded nose. That is a 30x gap, and this sits 2.8x above
51
+ * the first and 10.6x below the second. A family that lands in between is a
52
+ * finding to investigate, not a number to widen.
53
+ */
54
+ export declare const GAUGE_TOLERANCE_MM = 1;
55
+ /** Which interface a {@link MeasuredHolder.sizeClass} belongs to. */
56
+ export type TaperFamily = 'iso7x24' | 'hsk';
57
+ /** Every {@link TaperFamily}, for a message that can list what it knows. */
58
+ export declare const TAPER_FAMILIES: readonly TaperFamily[];
59
+ /**
60
+ * The 7:24 designations this package can read a size out of.
61
+ *
62
+ * A table rather than "letters then digits", for the reason
63
+ * `conventions.IDENTITY_DEVIATIONS` is one: an unknown prefix must be a line
64
+ * somebody added on evidence, not a regex that quietly accepted it. These are
65
+ * the two the scraped catalog states — Kennametal and REGO-FIX declare `BT30`
66
+ * throughout, MariTool states `BT30`, `BT40`, `CAT40` and `CAT50` per part —
67
+ * and a vendor publishing `SK`, `CV` or `ISO` adds its own entry here.
68
+ *
69
+ * **`BTKV` is deliberately absent.** `families/kennametal.ts` records why: a
70
+ * BTKV30 is the same JIS B 6339 cone as a BT30 and differs by seating on the
71
+ * flange face as well, which is `HolderRecord.contact`, so those families
72
+ * declare `BT30` and the distinction stays on the axis that carries it.
73
+ */
74
+ export declare const TAPER_PREFIXES: readonly string[];
75
+ /** A cone of the measured stack, in millimetres, as the API returns one. */
76
+ export interface HolderLayer {
77
+ /** Layer height. */
78
+ readonly thickness: number;
79
+ /** Diameter at the end nearer the nose. */
80
+ readonly bottomDiameter: number;
81
+ /** Diameter at the end nearer the spindle. */
82
+ readonly topDiameter: number;
83
+ }
84
+ /** The import options a measurement was produced with, echoed by the API. */
85
+ export interface ImportOptions {
86
+ /** Simplification tolerance, in mm of radius. */
87
+ readonly tolerance: number;
88
+ /** Whether enclosed bays — a V-flange groove, a thread relief — were raised to their brims. */
89
+ readonly fillBays: boolean;
90
+ /** Whether the holder was turned end for end after the automatic orientation. */
91
+ readonly flipped: boolean;
92
+ }
93
+ /**
94
+ * One holder as the API measured it, plus the two things the API cannot know.
95
+ *
96
+ * `brand` and `catalogNumber` are the caller's: the API is handed a STEP file
97
+ * and hands back geometry, and which part that file is came from the filename
98
+ * `node/cad-mirror.ts` wrote it under.
99
+ */
100
+ export interface MeasuredHolder {
101
+ readonly brand: BrandName;
102
+ readonly catalogNumber: string;
103
+ /** The envelope as a stack of cones, **nose first**. Always complete, taper included. */
104
+ readonly layers: readonly HolderLayer[];
105
+ /**
106
+ * Millimetres from the bottom of the stack to the gauge plane, or null.
107
+ *
108
+ * **Null is not zero.** A straight shank or a Capto carries no cone to place
109
+ * a gauge plane on, and a profile measured off one has no gage-line datum to
110
+ * be stated in.
111
+ */
112
+ readonly gaugeLength: number | null;
113
+ /** 30/40/50 for a 7:24, 25–160 for an HSK, or null where no taper was found. */
114
+ readonly sizeClass: number | null;
115
+ readonly taperFamily: TaperFamily | null;
116
+ /** What pins the numbers to a run. */
117
+ readonly kernelVersion: string;
118
+ readonly options: ImportOptions;
119
+ }
120
+ /** One vertex of a silhouette: `[z, r]`, both in millimetres. */
121
+ export type ProfilePoint = readonly [z: number, r: number];
122
+ /**
123
+ * What `z = 0` means on a profile.
124
+ *
125
+ * `gage-line` is the datum everything downstream assumes — the plane the
126
+ * spindle measures stickout from, with `z` increasing toward the cutting end,
127
+ * so the taper is negative and the nose positive. `nose` is the fallback where
128
+ * the API found no gauge plane to solve, and it is stated rather than silently
129
+ * referenced to an arbitrary end.
130
+ *
131
+ * **Per profile rather than per document**, which is where the reference
132
+ * implementation put it. One `datum` over a batch is only true while every
133
+ * holder in it has a taper, and the first Capto or straight-shank holder makes
134
+ * the document's own header wrong about some of its entries.
135
+ */
136
+ export type ProfileDatum = 'gage-line' | 'nose';
137
+ /** One holder's measured silhouette, and how far it agrees with the vendor. */
138
+ export interface HolderProfile {
139
+ readonly catalogNumber: string;
140
+ readonly datum: ProfileDatum;
141
+ /** The silhouette, `z` ascending. Two points share a `z` where the solid steps. */
142
+ readonly points: readonly ProfilePoint[];
143
+ /** Where the API put the gauge plane, in mm from the nose, or null. */
144
+ readonly gaugeLengthSolved: number | null;
145
+ /** The vendor's own `L1`, in mm — `HolderRecord.gaugeLengthMm`. */
146
+ readonly gaugeLengthPublished: number;
147
+ readonly sizeClass: number | null;
148
+ readonly taperFamily: TaperFamily | null;
149
+ /** Whether the two gage lengths agree to within {@link GAUGE_TOLERANCE_MM}. */
150
+ readonly complete: boolean;
151
+ /**
152
+ * How far the model falls short of the published gage length, when it does.
153
+ *
154
+ * Present only on an incomplete profile, and only the amount. *Why* a
155
+ * vendor's model stops early is a fact about that family and belongs in the
156
+ * family's own notes rather than repeated on five records as a string a UI
157
+ * would be tempted to print verbatim.
158
+ */
159
+ readonly shortfallMm?: number;
160
+ }
161
+ /** Every measured holder of a run, keyed by the guid its record was minted under. */
162
+ export interface ProfilesDocument {
163
+ readonly profilesVersion: number;
164
+ /**
165
+ * Always millimetres.
166
+ *
167
+ * The API measures in mm whatever the family's unit is, and nothing here
168
+ * converts: a shape measures what it measures, and an inch holder's profile
169
+ * is the same solid as a metric one's. `HolderRecord.gaugeLength` is what
170
+ * carries the vendor's own unit, for display.
171
+ */
172
+ readonly unit: 'millimeters';
173
+ readonly kernelVersion: string;
174
+ readonly options: ImportOptions;
175
+ readonly holderCount: number;
176
+ readonly holders: Readonly<Record<string, HolderProfile>>;
177
+ }
178
+ /**
179
+ * A taper designation as a size and an interface — `BT30` -> 30 / iso7x24.
180
+ *
181
+ * The other half of {@link checkProfile}'s agreement gate: the API measures a
182
+ * `sizeClass` and a `taperFamily` off the solid and cannot tell BT40 from CAT40
183
+ * from ISO40, so what it *can* be held to is the size and the family, and the
184
+ * vendor's own designation is where those come from.
185
+ *
186
+ * **Throws on a designation it does not know**, rather than returning null and
187
+ * letting the check be skipped. A prefix nobody has written down is this
188
+ * package's vocabulary being short, which is a `ScraperConfigError` — and
189
+ * silently not checking a family is how a mirrored STEP file goes unnoticed.
190
+ */
191
+ export declare function taperDesignation(taper: string): {
192
+ sizeClass: number;
193
+ family: TaperFamily;
194
+ };
195
+ /**
196
+ * The measured stack as a silhouette on the gage line.
197
+ *
198
+ * `layers` is nose-first and datumed on nothing; a drawing wants `z` ascending
199
+ * from the spindle end with the gage line at zero. The conversion is one line
200
+ * of arithmetic and one decision:
201
+ *
202
+ * ```
203
+ * zFromNose = 0, then the running sum of thickness
204
+ * z = gaugeLength - zFromNose // nose positive, spindle end negative
205
+ * r = diameter / 2
206
+ * ```
207
+ *
208
+ * On `BT30ER16060M` that puts the nose at `z = +60` and the top of the taper at
209
+ * `60 - 108.4 = -48.4`, which is exactly the range Fusion measured, with the
210
+ * ER16 nut at `r = 16.0` — the scraped `lockNutDiameter` of 32 — at both ends.
211
+ *
212
+ * **The decision is the step faces.** Consecutive layers usually meet at one
213
+ * diameter, and two of `BT30ER16060M`'s 112 do not: the solid jumps vertically
214
+ * there. Emitting one point per boundary would draw those two jumps as slopes
215
+ * across the neighbouring layers and quietly shave material off the envelope,
216
+ * so a boundary whose diameters disagree gets **two points at the same `z`**.
217
+ * That is why {@link checkProfile} requires `z` to be non-decreasing rather
218
+ * than strictly increasing.
219
+ *
220
+ * With `gaugeLength` null there is no gauge plane to datum on and the nose is
221
+ * used instead, which is what {@link HolderProfile.datum} states. An empty
222
+ * stack yields no points rather than throwing — {@link checkProfile} is where a
223
+ * profile too short to draw is refused, and it says so with the part's name.
224
+ */
225
+ export declare function layersToProfile(layers: readonly HolderLayer[], gaugeLength: number | null): ProfilePoint[];
226
+ /**
227
+ * Boundary validation for a measurement, the role `holding.ts`'s gates play for
228
+ * a scraped row.
229
+ *
230
+ * These come from a third-party CAD model rather than a vendor table, so the
231
+ * failures worth catching are the ones that **render as a plausible picture**:
232
+ * a silhouette that runs backwards, a negative radius, a datum outside the
233
+ * part, or a model that is not the holder the row says it is.
234
+ *
235
+ * `declaredTaper` is {@link HolderRecord.taper}. The reference implementation
236
+ * gated on `sizeClass == 30` instead, which was correct when the catalog was
237
+ * BT30 throughout and is not now — MariTool ships CAT40, CAT50, BT40 and nine
238
+ * HSK sizes. Agreement with what the vendor declares catches the same thing
239
+ * and travels: a CAT40 row whose model measures as an HSK is a mirrored file or
240
+ * a mis-scraped row. Geometry cannot tell BT40 from CAT40, so only the size and
241
+ * the family are checked.
242
+ */
243
+ export declare function checkProfile(profile: HolderProfile, declaredTaper: string): void;
244
+ /**
245
+ * Measured holders plus the records they belong to -> the profiles document.
246
+ *
247
+ * **The join is `(brand, catalogNumber)`, and both halves matter.** The catalog
248
+ * number is what a mirrored STEP file is named for, so it is what a measurement
249
+ * carries back; `HolderRecord.catalogNumber` is that same cell, because every
250
+ * mapper reads it from `conventions.catalogColumn(brand)` — Kennametal's
251
+ * `ISO Catalog Number`, MariTool's `Material Number`, which is why the two
252
+ * identity fields are not interchangeable here. The brand is in the key because
253
+ * `node/paths.ts#stepDir` is per vendor: two vendors' catalog numbers live in
254
+ * two directories and have never had to be distinct from each other.
255
+ *
256
+ * **A measured holder matching no record raises.** It means a family was
257
+ * measured and then renamed or dropped, and silently omitting it looks exactly
258
+ * like a holder the vendor publishes no model for — the one thing this document
259
+ * must not be ambiguous about.
260
+ *
261
+ * **Every measurement must agree on the kernel and the options.** A document
262
+ * states one of each, and a batch that spanned a kernel upgrade or two
263
+ * `fillBays` settings would state one and contain both.
264
+ */
265
+ export declare function buildProfiles(measured: readonly MeasuredHolder[], holders: readonly HolderRecord[]): ProfilesDocument;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * A measured holder envelope, on the gage line, as the drawing wants it.
3
+ *
4
+ * `holding.ts` mints a holder from what the vendor *publishes*. This is the
5
+ * other half: what the holder's own CAD model *measures*, reduced to the
6
+ * silhouette a 2D elevation draws and cross-checked against the published gage
7
+ * length. The measuring is the Toolpath Engine API's — `node/holder-import.ts`
8
+ * makes the calls — and everything here is pure, so the 0/1/2/N cases are
9
+ * testable against literals rather than against a stack that has to be running.
10
+ *
11
+ * ## What replaced 1,800 lines of Python and a GUI
12
+ *
13
+ * The reference implementation sampled each STEP solid from inside a live
14
+ * Fusion 360 process over an MCP bridge, then did its own z-binned envelope,
15
+ * outward-only Douglas–Peucker, undercut fill and 7:24 taper solve. The API
16
+ * returns all of it — the layer stack, the gauge length, the size class and the
17
+ * taper family — so what is left here is a change of datum and a cross-check.
18
+ *
19
+ * The two agree. Four holders re-measured through the API against the profiles
20
+ * Fusion produced for the same STEP files, at the same 0.05 mm tolerance:
21
+ * `BT30ER16060M` 60 vs 60.000003 (2.8 nm), `BT30HC14100M` 100.355 vs
22
+ * 100.354999, `BT30HPVTT038295` 75 vs 75.000000, `BTKV30ER16100M` 89.4 vs
23
+ * 89.400000, with the total envelope height matching to three decimals on all
24
+ * four. The API decimates finer — about twice the layers at the same tolerance
25
+ * — which costs a drawing segments and changes no dimension.
26
+ *
27
+ * ## Its own document, keyed by guid
28
+ *
29
+ * A profile is ~110 points that only an assembly drawing needs, and a catalog
30
+ * is loaded by every page. So this is a second document that ships beside the
31
+ * records and is read lazily, rather than a field on {@link HolderRecord}.
32
+ * Keyed by guid because that is the one identifier that survives a re-scrape.
33
+ *
34
+ * **Collets get no profile.** They publish no CAD model and are not drawn: a
35
+ * collet sits inside the nut, which the holder's own envelope already includes.
36
+ */
37
+ import { ScraperConfigError, VendorResponseError } from './errors.js';
38
+ /** Bumped when {@link ProfilesDocument}'s shape changes in a way a consumer must handle. */
39
+ export const PROFILES_VERSION = 1;
40
+ /**
41
+ * How far a measured gage length may sit from the vendor's published one before
42
+ * the profile is called incomplete, in millimetres.
43
+ *
44
+ * **Both bounds come from the data rather than from feel.** The largest
45
+ * *explained* deviation across the 53-holder Kennametal corpus is 0.355 mm —
46
+ * the nose lip on `BT30HC14100M`, real material past the face the vendor
47
+ * measures `L1` to, reproduced by the API to 1.5 nm. The smallest *real*
48
+ * shortfall is 10.6 mm — the collet nut the five BTKV30 STEP models omit, whose
49
+ * bodies stop at the threaded nose. That is a 30x gap, and this sits 2.8x above
50
+ * the first and 10.6x below the second. A family that lands in between is a
51
+ * finding to investigate, not a number to widen.
52
+ */
53
+ export const GAUGE_TOLERANCE_MM = 1.0;
54
+ /** Every {@link TaperFamily}, for a message that can list what it knows. */
55
+ export const TAPER_FAMILIES = ['iso7x24', 'hsk'];
56
+ /**
57
+ * The 7:24 designations this package can read a size out of.
58
+ *
59
+ * A table rather than "letters then digits", for the reason
60
+ * `conventions.IDENTITY_DEVIATIONS` is one: an unknown prefix must be a line
61
+ * somebody added on evidence, not a regex that quietly accepted it. These are
62
+ * the two the scraped catalog states — Kennametal and REGO-FIX declare `BT30`
63
+ * throughout, MariTool states `BT30`, `BT40`, `CAT40` and `CAT50` per part —
64
+ * and a vendor publishing `SK`, `CV` or `ISO` adds its own entry here.
65
+ *
66
+ * **`BTKV` is deliberately absent.** `families/kennametal.ts` records why: a
67
+ * BTKV30 is the same JIS B 6339 cone as a BT30 and differs by seating on the
68
+ * flange face as well, which is `HolderRecord.contact`, so those families
69
+ * declare `BT30` and the distinction stays on the axis that carries it.
70
+ */
71
+ export const TAPER_PREFIXES = ['BT', 'CAT'];
72
+ /** `HSK63A` -> 63, `HSK100A` -> 100. The form letter is optional; the size is not. */
73
+ const HSK_DESIGNATION = /^HSK(\d+)[A-Z]?$/;
74
+ /**
75
+ * A taper designation as a size and an interface — `BT30` -> 30 / iso7x24.
76
+ *
77
+ * The other half of {@link checkProfile}'s agreement gate: the API measures a
78
+ * `sizeClass` and a `taperFamily` off the solid and cannot tell BT40 from CAT40
79
+ * from ISO40, so what it *can* be held to is the size and the family, and the
80
+ * vendor's own designation is where those come from.
81
+ *
82
+ * **Throws on a designation it does not know**, rather than returning null and
83
+ * letting the check be skipped. A prefix nobody has written down is this
84
+ * package's vocabulary being short, which is a `ScraperConfigError` — and
85
+ * silently not checking a family is how a mirrored STEP file goes unnoticed.
86
+ */
87
+ export function taperDesignation(taper) {
88
+ const hsk = HSK_DESIGNATION.exec(taper);
89
+ if (hsk !== null)
90
+ return { sizeClass: Number(hsk[1]), family: 'hsk' };
91
+ for (const prefix of TAPER_PREFIXES) {
92
+ if (!taper.startsWith(prefix))
93
+ continue;
94
+ const size = taper.slice(prefix.length);
95
+ if (/^\d+$/.test(size))
96
+ return { sizeClass: Number(size), family: 'iso7x24' };
97
+ }
98
+ throw new ScraperConfigError(taper, `is not a taper designation this package can read a size out of ` +
99
+ `(7:24 prefixes: ${TAPER_PREFIXES.join(', ')}; HSK as HSK<size><form>) — ` +
100
+ `add the prefix to TAPER_PREFIXES once it is clear what interface it names`);
101
+ }
102
+ /**
103
+ * The measured stack as a silhouette on the gage line.
104
+ *
105
+ * `layers` is nose-first and datumed on nothing; a drawing wants `z` ascending
106
+ * from the spindle end with the gage line at zero. The conversion is one line
107
+ * of arithmetic and one decision:
108
+ *
109
+ * ```
110
+ * zFromNose = 0, then the running sum of thickness
111
+ * z = gaugeLength - zFromNose // nose positive, spindle end negative
112
+ * r = diameter / 2
113
+ * ```
114
+ *
115
+ * On `BT30ER16060M` that puts the nose at `z = +60` and the top of the taper at
116
+ * `60 - 108.4 = -48.4`, which is exactly the range Fusion measured, with the
117
+ * ER16 nut at `r = 16.0` — the scraped `lockNutDiameter` of 32 — at both ends.
118
+ *
119
+ * **The decision is the step faces.** Consecutive layers usually meet at one
120
+ * diameter, and two of `BT30ER16060M`'s 112 do not: the solid jumps vertically
121
+ * there. Emitting one point per boundary would draw those two jumps as slopes
122
+ * across the neighbouring layers and quietly shave material off the envelope,
123
+ * so a boundary whose diameters disagree gets **two points at the same `z`**.
124
+ * That is why {@link checkProfile} requires `z` to be non-decreasing rather
125
+ * than strictly increasing.
126
+ *
127
+ * With `gaugeLength` null there is no gauge plane to datum on and the nose is
128
+ * used instead, which is what {@link HolderProfile.datum} states. An empty
129
+ * stack yields no points rather than throwing — {@link checkProfile} is where a
130
+ * profile too short to draw is refused, and it says so with the part's name.
131
+ */
132
+ export function layersToProfile(layers, gaugeLength) {
133
+ const first = layers[0];
134
+ if (first === undefined)
135
+ return [];
136
+ let z = gaugeLength ?? 0;
137
+ const points = [[z, first.bottomDiameter / 2]];
138
+ for (const layer of layers) {
139
+ const bottom = layer.bottomDiameter / 2;
140
+ // Exact, not toleranced: the stack is one solid's decimation, so a
141
+ // boundary either repeats a diameter bit for bit or is a real step face.
142
+ if (bottom !== points[points.length - 1][1])
143
+ points.push([z, bottom]);
144
+ z -= layer.thickness;
145
+ points.push([z, layer.topDiameter / 2]);
146
+ }
147
+ return points.reverse();
148
+ }
149
+ /**
150
+ * Boundary validation for a measurement, the role `holding.ts`'s gates play for
151
+ * a scraped row.
152
+ *
153
+ * These come from a third-party CAD model rather than a vendor table, so the
154
+ * failures worth catching are the ones that **render as a plausible picture**:
155
+ * a silhouette that runs backwards, a negative radius, a datum outside the
156
+ * part, or a model that is not the holder the row says it is.
157
+ *
158
+ * `declaredTaper` is {@link HolderRecord.taper}. The reference implementation
159
+ * gated on `sizeClass == 30` instead, which was correct when the catalog was
160
+ * BT30 throughout and is not now — MariTool ships CAT40, CAT50, BT40 and nine
161
+ * HSK sizes. Agreement with what the vendor declares catches the same thing
162
+ * and travels: a CAT40 row whose model measures as an HSK is a mirrored file or
163
+ * a mis-scraped row. Geometry cannot tell BT40 from CAT40, so only the size and
164
+ * the family are checked.
165
+ */
166
+ export function checkProfile(profile, declaredTaper) {
167
+ const what = profile.catalogNumber;
168
+ const { points } = profile;
169
+ if (points.length < 2) {
170
+ throw new VendorResponseError(what, 'a profile needs at least two points');
171
+ }
172
+ let previous = -Infinity;
173
+ for (const [z, r] of points) {
174
+ if (z < previous)
175
+ throw new VendorResponseError(what, `profile z is not ascending at ${z}`);
176
+ if (r < 0)
177
+ throw new VendorResponseError(what, `negative radius ${r} at z ${z}`);
178
+ previous = z;
179
+ }
180
+ const low = points[0][0];
181
+ const high = points[points.length - 1][0];
182
+ if (profile.datum === 'gage-line' && !(low < 0 && 0 < high)) {
183
+ throw new VendorResponseError(what, `the gage line at z=0 is outside the profile (${low} .. ${high}) — ` +
184
+ `the datum was not applied`);
185
+ }
186
+ const declared = taperDesignation(declaredTaper);
187
+ if (profile.sizeClass !== declared.sizeClass || profile.taperFamily !== declared.family) {
188
+ throw new VendorResponseError(what, `the row declares ${declaredTaper} (size ${declared.sizeClass}, ${declared.family}) ` +
189
+ `and its model measures size ${profile.sizeClass} / ${profile.taperFamily} — ` +
190
+ `the wrong STEP file was mirrored, or the row's taper is wrong`);
191
+ }
192
+ }
193
+ /** `brand` and a catalog number as one map key. */
194
+ function partKey(brand, catalogNumber) {
195
+ return `${brand}${catalogNumber}`;
196
+ }
197
+ /**
198
+ * Measured holders plus the records they belong to -> the profiles document.
199
+ *
200
+ * **The join is `(brand, catalogNumber)`, and both halves matter.** The catalog
201
+ * number is what a mirrored STEP file is named for, so it is what a measurement
202
+ * carries back; `HolderRecord.catalogNumber` is that same cell, because every
203
+ * mapper reads it from `conventions.catalogColumn(brand)` — Kennametal's
204
+ * `ISO Catalog Number`, MariTool's `Material Number`, which is why the two
205
+ * identity fields are not interchangeable here. The brand is in the key because
206
+ * `node/paths.ts#stepDir` is per vendor: two vendors' catalog numbers live in
207
+ * two directories and have never had to be distinct from each other.
208
+ *
209
+ * **A measured holder matching no record raises.** It means a family was
210
+ * measured and then renamed or dropped, and silently omitting it looks exactly
211
+ * like a holder the vendor publishes no model for — the one thing this document
212
+ * must not be ambiguous about.
213
+ *
214
+ * **Every measurement must agree on the kernel and the options.** A document
215
+ * states one of each, and a batch that spanned a kernel upgrade or two
216
+ * `fillBays` settings would state one and contain both.
217
+ */
218
+ export function buildProfiles(measured, holders) {
219
+ const head = measured[0];
220
+ if (head === undefined) {
221
+ throw new ScraperConfigError('profiles', 'no measured holders — a profiles document covering nothing is not a ' +
222
+ 'result, and writing one would look exactly like a run that worked');
223
+ }
224
+ const byPart = new Map();
225
+ for (const holder of holders) {
226
+ const key = partKey(holder.brand, holder.catalogNumber);
227
+ const clash = byPart.get(key);
228
+ if (clash !== undefined) {
229
+ throw new VendorResponseError(holder.catalogNumber, `${holder.brand} publishes it twice (${clash.materialNumber} and ` +
230
+ `${holder.materialNumber}) — the catalog number is what a mirrored ` +
231
+ `STEP file is named for, so it cannot identify two holders`);
232
+ }
233
+ byPart.set(key, holder);
234
+ }
235
+ const entries = new Map();
236
+ const inOrder = [...measured].sort((a, b) => a.catalogNumber.localeCompare(b.catalogNumber));
237
+ for (const record of inOrder) {
238
+ const holder = byPart.get(partKey(record.brand, record.catalogNumber));
239
+ if (holder === undefined) {
240
+ throw new VendorResponseError(record.catalogNumber, `was measured and matches no scraped ${record.brand} holder`);
241
+ }
242
+ checkRun(head, record);
243
+ const solved = record.gaugeLength;
244
+ const published = holder.gaugeLengthMm;
245
+ const shortfall = solved === null ? null : published - solved;
246
+ const complete = shortfall !== null && Math.abs(shortfall) <= GAUGE_TOLERANCE_MM;
247
+ const profile = {
248
+ catalogNumber: record.catalogNumber,
249
+ datum: solved === null ? 'nose' : 'gage-line',
250
+ points: layersToProfile(record.layers, solved),
251
+ gaugeLengthSolved: solved,
252
+ gaugeLengthPublished: published,
253
+ sizeClass: record.sizeClass,
254
+ taperFamily: record.taperFamily,
255
+ complete,
256
+ ...(complete || shortfall === null ? {} : { shortfallMm: round(shortfall, 4) }),
257
+ };
258
+ checkProfile(profile, holder.taper);
259
+ // `index.ts` promises one guid space across holders and tools, and this
260
+ // document is keyed by it: two profiles under one guid would be one
261
+ // silently overwriting the other rather than the collision being refused.
262
+ const taken = entries.get(holder.guid);
263
+ if (taken !== undefined) {
264
+ throw new VendorResponseError(holder.guid, `is the guid of both ${taken.catalogNumber} and ${record.catalogNumber} — ` +
265
+ `two holders cannot share one identity`);
266
+ }
267
+ entries.set(holder.guid, profile);
268
+ }
269
+ return {
270
+ profilesVersion: PROFILES_VERSION,
271
+ unit: 'millimeters',
272
+ kernelVersion: head.kernelVersion,
273
+ options: head.options,
274
+ holderCount: entries.size,
275
+ holders: Object.fromEntries(entries),
276
+ };
277
+ }
278
+ /** Every measurement in one document came out of one run of one kernel. */
279
+ function checkRun(head, record) {
280
+ if (record.kernelVersion !== head.kernelVersion) {
281
+ throw new VendorResponseError(record.catalogNumber, `was measured by kernel ${record.kernelVersion} and ${head.catalogNumber} ` +
282
+ `by ${head.kernelVersion} — one document states one kernel version`);
283
+ }
284
+ for (const key of ['tolerance', 'fillBays', 'flipped']) {
285
+ if (record.options[key] !== head.options[key]) {
286
+ throw new VendorResponseError(record.catalogNumber, `was imported with ${key}=${record.options[key]} and ${head.catalogNumber} ` +
287
+ `with ${key}=${head.options[key]} — one document states one set of options`);
288
+ }
289
+ }
290
+ }
291
+ /** Decimal places, so a shortfall is a number and not a float artefact. */
292
+ function round(value, places) {
293
+ const scale = 10 ** places;
294
+ return Math.round(value * scale) / scale;
295
+ }
package/dist/records.d.ts CHANGED
@@ -236,17 +236,31 @@ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
236
236
  * a key in `sometimes` may be missing and its absence is the vendor's silence;
237
237
  * a key in neither list is not part of that kind's record at all.
238
238
  *
239
- * Both `sometimes` entries today are a flute count nobody publishes:
240
- *
241
- * - the **end mill's**, for Harvey's two deburring families — they publish
242
- * right- and left-hand tooth counts and no flute count, so there is nothing
243
- * to read and 0 is not a substitute;
244
- * - the **tap's**, for EMUGE-FRANKEN, which states no flute count anywhere a
245
- * scrape can reach not on the grouped product, the variant listing, the
246
- * per-part detail record or any facet while its own tap families run 2, 3
247
- * and 4 flutes across their size range, so no per-family constant could be
248
- * true of every row. Kennametal's taps publish a `Z` column and keep filling
249
- * it: `sometimes` permits the key, it does not forbid it.
239
+ * Every `sometimes` entry today is one vendor publishing nothing where another
240
+ * publishes a number. `sometimes` permits the key, it does not forbid it — the
241
+ * vendors that state these keep filling them.
242
+ *
243
+ * - the **end mill's `NOF`**, for Harvey's two deburring families — they
244
+ * publish right- and left-hand tooth counts and no flute count, so there is
245
+ * nothing to read and 0 is not a substitute;
246
+ * - the **tap's `NOF`**, for EMUGE-FRANKEN, which states no flute count
247
+ * anywhere a scrape can reach not on the grouped product, the variant
248
+ * listing, the per-part detail record or any facet while its own tap
249
+ * families run 2, 3 and 4 flutes across their size range, so no per-family
250
+ * constant could be true of every row. Kennametal's taps publish a `Z`
251
+ * column and keep filling it;
252
+ * - the **drill's `SIG`**, for the one EMUGE-FRANKEN drill whose point-angle
253
+ * cell is empty. This one is a hole in a column the vendor otherwise fills,
254
+ * not a column it never had, and it is the reason the key had to move: the
255
+ * EMUGE drill family reads `SIG` from a column rather than from a fact, so
256
+ * a single blank cell refused the row, and `registry.toRecords` maps a
257
+ * family's rows together — 2,669 drills were lost to the 2,670th. Every
258
+ * Kennametal drill supplies `SIG` from a fact and none can be absent.
259
+ *
260
+ * A point angle is not a field to guess at. It sets the drill tip's length, so
261
+ * a CAM system that assumed 140 deg for a part ground at 118 would cut a hole
262
+ * to the wrong depth — an absence a consumer must check for is recoverable and
263
+ * a plausible wrong number is not.
250
264
  */
251
265
  export declare const RECORD_GEOMETRY: Record<ToolKind, {
252
266
  readonly always: readonly GeometryName[];
@@ -325,6 +339,37 @@ export interface ToolRecord {
325
339
  * consumer that needs a per-part string has `catalogNumber`.
326
340
  */
327
341
  readonly description: string;
342
+ /**
343
+ * The vendor's own name for the product line this part belongs to —
344
+ * `FRANKEN TOP-Cut`, `MultiDRILL`, `KenCut™ FF`, `Viper` — or `null` where
345
+ * the vendor names none.
346
+ *
347
+ * **Null is the vendor's silence, not an empty name**, the same three-state
348
+ * reasoning {@link ToolRecord.materialGroups} makes with {@link UNSPECIFIED}
349
+ * and for the same reason: a consumer faceting a catalog by product line has
350
+ * to be able to tell "EMUGE calls this Alu-Cut" from "nobody has decided what
351
+ * Harvey's line is called", and `''` collapses the two.
352
+ *
353
+ * **Verbatim, and never inferred** — the {@link ToolRecord.coating} rule.
354
+ * EMUGE's own index says `FRANKEN Expert` for the eight end mills its
355
+ * marketing calls "Cut & Form", and Destiny Tool ships `viper-mini` and
356
+ * `python` beside `Viper` and `Raptor`; case-folding either here would be
357
+ * this package authoring a vendor's catalog. The one thing an adapter may do
358
+ * is map a vendor's *code* onto that same vendor's *own* published name for
359
+ * it — see `vendors/emuge/records.ts`'s `PRODUCT_LINES`, where both sides of
360
+ * every entry are EMUGE's and the article page each name came from is cited.
361
+ *
362
+ * **Where it comes from is a fact about the vendor's data, not a convention.**
363
+ * Three sources are in use and each is the only one its vendor offers: a
364
+ * scraped column read per part (EMUGE, Destiny Tool), a page title fetched
365
+ * per family (Kennametal, WIDIA), and nothing at all (Harvey Tool, whose
366
+ * product-line title is already this record's `description` — a second copy
367
+ * of one string is the thing that field's own docstring refuses).
368
+ *
369
+ * **Never a copy of another field on this record**, for the reason
370
+ * {@link ToolRecord.description} states it.
371
+ */
372
+ readonly productLine: string | null;
328
373
  readonly kind: ToolKind;
329
374
  readonly unit: UnitSystem;
330
375
  readonly substrate: string;
@@ -388,7 +433,7 @@ export interface ToolRecord {
388
433
  * interchange value, and a mapper that mutated one would be reaching back
389
434
  * across the seam this type exists to draw.
390
435
  */
391
- export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous'>>): ToolRecord;
436
+ export declare function toolRecord(fields: Omit<ToolRecord, 'guid' | 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'> & Partial<Pick<ToolRecord, 'materialGroups' | 'materialGroupsSource' | 'nonFerrous' | 'productLine'>>): ToolRecord;
392
437
  /**
393
438
  * A family's canonical-field → CSV-column-label mapping, validated.
394
439
  *