@toolpath/tool-scraper 2.1.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 (37) hide show
  1. package/dist/conventions.d.ts +36 -0
  2. package/dist/conventions.js +41 -0
  3. package/dist/family.d.ts +16 -2
  4. package/dist/holding.d.ts +396 -0
  5. package/dist/holding.js +360 -0
  6. package/dist/index.d.ts +23 -13
  7. package/dist/index.js +23 -13
  8. package/dist/node/cad-mirror.d.ts +58 -1
  9. package/dist/node/cad-mirror.js +56 -8
  10. package/dist/node/cli.d.ts +4 -1
  11. package/dist/node/cli.js +192 -18
  12. package/dist/node/holder-import.d.ts +223 -0
  13. package/dist/node/holder-import.js +379 -0
  14. package/dist/node/index.d.ts +1 -0
  15. package/dist/node/index.js +1 -0
  16. package/dist/node/paths.d.ts +16 -0
  17. package/dist/node/paths.js +20 -0
  18. package/dist/profiles.d.ts +265 -0
  19. package/dist/profiles.js +295 -0
  20. package/dist/registry.d.ts +61 -5
  21. package/dist/registry.js +109 -6
  22. package/dist/vendors/kennametal/holding.d.ts +35 -0
  23. package/dist/vendors/kennametal/holding.js +112 -0
  24. package/dist/vendors/kennametal/index.d.ts +1 -0
  25. package/dist/vendors/kennametal/index.js +1 -0
  26. package/dist/vendors/maritool/holding.d.ts +79 -0
  27. package/dist/vendors/maritool/holding.js +164 -0
  28. package/dist/vendors/maritool/index.d.ts +1 -0
  29. package/dist/vendors/maritool/index.js +1 -0
  30. package/dist/vendors/maritool/scrape.d.ts +37 -13
  31. package/dist/vendors/maritool/scrape.js +53 -14
  32. package/dist/vendors/regofix/holding.d.ts +35 -0
  33. package/dist/vendors/regofix/holding.js +108 -0
  34. package/dist/vendors/regofix/index.d.ts +1 -0
  35. package/dist/vendors/regofix/index.js +1 -0
  36. package/dist/vendors/regofix/scrape.js +2 -2
  37. 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
+ }
@@ -27,6 +27,7 @@
27
27
  * entry point into this package goes through here.
28
28
  */
29
29
  import { type BoundFamily, type BoundToolholding, type RecordMappers } from './family.js';
30
+ import type { HoldingMappers, HoldingRecord } from './holding.js';
30
31
  import { type ToolRecord } from './records.js';
31
32
  import { type MapperOptions, type ScrapeResult } from './scrape.js';
32
33
  /**
@@ -39,6 +40,26 @@ import { type MapperOptions, type ScrapeResult } from './scrape.js';
39
40
  * map.
40
41
  */
41
42
  export declare const ADAPTERS: Record<string, RecordMappers>;
43
+ /**
44
+ * Brand -> its toolholding mappers, by the kind of thing they build.
45
+ *
46
+ * The toolholding counterpart of {@link ADAPTERS}, and **partial in both
47
+ * directions on purpose**. A brand absent from here can still be scraped: its
48
+ * families bind, its CSVs are written, and its receipt is checked, exactly as
49
+ * before — what it cannot do is mint records. A brand present with a mapper for
50
+ * only one kind is the same statement one level down; MariTool publishes ER
51
+ * collets that this package does not scrape, so it maps holders and nothing
52
+ * else.
53
+ *
54
+ * That is what makes minting records additive rather than a break. Nothing here
55
+ * changes what a vendor with no entry does today, and the refusal only happens
56
+ * where a caller explicitly asks for records from a family whose brand maps
57
+ * none — {@link toHolding}, which names the brand and what it does map.
58
+ *
59
+ * One entry serves two brands for the reason {@link ADAPTERS} states: Kennametal
60
+ * and WIDIA are the same platform and the same table vocabulary.
61
+ */
62
+ export declare const HOLDING_ADAPTERS: Record<string, HoldingMappers>;
42
63
  /**
43
64
  * Every cutting-tool family, validated and bound to its record mapper.
44
65
  *
@@ -46,13 +67,25 @@ export declare const ADAPTERS: Record<string, RecordMappers>;
46
67
  */
47
68
  export declare function boundFamilies(): Map<string, BoundFamily>;
48
69
  /**
49
- * Every holder and collet family, with its facts checked and projected.
50
- *
51
- * They bind no adapter — only cutting tools go through a column map — but
52
- * their facts pass the same gate: a taper or a clamping mode is a per-family
53
- * constant no variant table states, exactly like a drill's flute count.
70
+ * Every holder and collet family, with its facts checked and projected, and
71
+ * bound to the mapper its brand supplies for its kind.
72
+ *
73
+ * Their facts pass the same gate cutting-tool families' do: a taper or a
74
+ * clamping mode is a per-family constant no variant table states, exactly like
75
+ * a drill's flute count.
76
+ *
77
+ * **A family whose brand maps nothing binds `undefined` rather than throwing**,
78
+ * which is where this differs from {@link boundFamilies}. A cutting-tool family
79
+ * with no mapper is a catalog fault — nothing can be done with it — but a
80
+ * toolholding family with no mapper is the state every one of them was in until
81
+ * records existed, and it still scrapes, writes a CSV and checks a receipt.
82
+ * Refusing at bind time would take that away from every consumer that never
83
+ * asked for a record. {@link toHolding} is where the absence is reported, at
84
+ * the one call that cannot proceed without it.
54
85
  */
55
86
  export declare function boundToolholding(): Map<string, BoundToolholding>;
87
+ /** One bound toolholding family by CSV name. */
88
+ export declare function boundHolding(name: string): BoundToolholding;
56
89
  /** One bound cutting-tool family by CSV name. */
57
90
  export declare function boundFamily(name: string): BoundFamily;
58
91
  /**
@@ -108,6 +141,29 @@ export declare function boundFamily(name: string): BoundFamily;
108
141
  * every part by number.
109
142
  */
110
143
  export declare function toRecords(familyName: string, scrape: ScrapeResult, options?: MapperOptions): ToolRecord[];
144
+ /**
145
+ * One toolholding family's scrape, as {@link HoldingRecord}s.
146
+ *
147
+ * {@link toRecords}'s counterpart, and deliberately the same shape: the two
148
+ * checks run before the first row, one incomplete part does not end the family,
149
+ * and the count of what was dropped is not returned because the caller has the
150
+ * row count it passed in and the length it got back.
151
+ *
152
+ * **It refuses only where a caller asked for something this package cannot
153
+ * give.** A toolholding family whose brand maps no mapper binds one anyway
154
+ * (see {@link boundToolholding}) and scrapes exactly as it did before; this is
155
+ * the one call that cannot proceed without one, so this is where the absence is
156
+ * named — with the brand and with what that brand does map, the way
157
+ * {@link boundFamilies} names a missing tool mapper.
158
+ *
159
+ * `checkColumnsExist` has no counterpart here: a holder family carries no
160
+ * `ColumnMap`, because the columns a holder publishes are the vendor's own and
161
+ * are read by that vendor's mapper rather than through a canonical name. What
162
+ * does still run is {@link checkIdentityColumns}, which catches the failure that
163
+ * matters most — a re-scrape whose part-number column was renamed still parses,
164
+ * still has the right row count, and mints every guid off an empty string.
165
+ */
166
+ export declare function toHolding(familyName: string, scrape: ScrapeResult, options?: MapperOptions): HoldingRecord[];
111
167
  /**
112
168
  * Forget what has been bound.
113
169
  *