@toolpath/tool-scraper 2.1.0 → 2.3.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 (43) hide show
  1. package/dist/conventions.d.ts +48 -2
  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/measure.d.ts +14 -10
  9. package/dist/measure.js +14 -13
  10. package/dist/node/cad-mirror.d.ts +58 -1
  11. package/dist/node/cad-mirror.js +56 -8
  12. package/dist/node/cli.d.ts +4 -1
  13. package/dist/node/cli.js +192 -18
  14. package/dist/node/holder-import.d.ts +223 -0
  15. package/dist/node/holder-import.js +379 -0
  16. package/dist/node/index.d.ts +1 -0
  17. package/dist/node/index.js +1 -0
  18. package/dist/node/paths.d.ts +16 -0
  19. package/dist/node/paths.js +20 -0
  20. package/dist/profiles.d.ts +275 -0
  21. package/dist/profiles.js +295 -0
  22. package/dist/provenance.d.ts +9 -1
  23. package/dist/provenance.js +9 -1
  24. package/dist/records.d.ts +60 -20
  25. package/dist/records.js +31 -19
  26. package/dist/registry.d.ts +61 -5
  27. package/dist/registry.js +109 -6
  28. package/dist/vendors/kennametal/holding.d.ts +35 -0
  29. package/dist/vendors/kennametal/holding.js +112 -0
  30. package/dist/vendors/kennametal/index.d.ts +1 -0
  31. package/dist/vendors/kennametal/index.js +1 -0
  32. package/dist/vendors/maritool/holding.d.ts +79 -0
  33. package/dist/vendors/maritool/holding.js +164 -0
  34. package/dist/vendors/maritool/index.d.ts +1 -0
  35. package/dist/vendors/maritool/index.js +1 -0
  36. package/dist/vendors/maritool/scrape.d.ts +37 -13
  37. package/dist/vendors/maritool/scrape.js +53 -14
  38. package/dist/vendors/regofix/holding.d.ts +35 -0
  39. package/dist/vendors/regofix/holding.js +108 -0
  40. package/dist/vendors/regofix/index.d.ts +1 -0
  41. package/dist/vendors/regofix/index.js +1 -0
  42. package/dist/vendors/regofix/scrape.js +2 -2
  43. package/package.json +3 -2
@@ -0,0 +1,275 @@
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 { ProfileDatum, ProfilePoint } from '@toolpath/tool-support';
38
+ import type { HolderRecord } from './holding.js';
39
+ import type { BrandName } from './identity.js';
40
+ /** Bumped when {@link ProfilesDocument}'s shape changes in a way a consumer must handle. */
41
+ export declare const PROFILES_VERSION = 1;
42
+ /**
43
+ * How far a measured gage length may sit from the vendor's published one before
44
+ * the profile is called incomplete, in millimetres.
45
+ *
46
+ * **Both bounds come from the data rather than from feel.** The largest
47
+ * *explained* deviation across the 53-holder Kennametal corpus is 0.355 mm —
48
+ * the nose lip on `BT30HC14100M`, real material past the face the vendor
49
+ * measures `L1` to, reproduced by the API to 1.5 nm. The smallest *real*
50
+ * shortfall is 10.6 mm — the collet nut the five BTKV30 STEP models omit, whose
51
+ * bodies stop at the threaded nose. That is a 30x gap, and this sits 2.8x above
52
+ * the first and 10.6x below the second. A family that lands in between is a
53
+ * finding to investigate, not a number to widen.
54
+ */
55
+ export declare const GAUGE_TOLERANCE_MM = 1;
56
+ /** Which interface a {@link MeasuredHolder.sizeClass} belongs to. */
57
+ export type TaperFamily = 'iso7x24' | 'hsk';
58
+ /** Every {@link TaperFamily}, for a message that can list what it knows. */
59
+ export declare const TAPER_FAMILIES: readonly TaperFamily[];
60
+ /**
61
+ * The 7:24 designations this package can read a size out of.
62
+ *
63
+ * A table rather than "letters then digits", for the reason
64
+ * `conventions.IDENTITY_DEVIATIONS` is one: an unknown prefix must be a line
65
+ * somebody added on evidence, not a regex that quietly accepted it. These are
66
+ * the two the scraped catalog states — Kennametal and REGO-FIX declare `BT30`
67
+ * throughout, MariTool states `BT30`, `BT40`, `CAT40` and `CAT50` per part —
68
+ * and a vendor publishing `SK`, `CV` or `ISO` adds its own entry here.
69
+ *
70
+ * **`BTKV` is deliberately absent.** `families/kennametal.ts` records why: a
71
+ * BTKV30 is the same JIS B 6339 cone as a BT30 and differs by seating on the
72
+ * flange face as well, which is `HolderRecord.contact`, so those families
73
+ * declare `BT30` and the distinction stays on the axis that carries it.
74
+ */
75
+ export declare const TAPER_PREFIXES: readonly string[];
76
+ /** A cone of the measured stack, in millimetres, as the API returns one. */
77
+ export interface HolderLayer {
78
+ /** Layer height. */
79
+ readonly thickness: number;
80
+ /** Diameter at the end nearer the nose. */
81
+ readonly bottomDiameter: number;
82
+ /** Diameter at the end nearer the spindle. */
83
+ readonly topDiameter: number;
84
+ }
85
+ /** The import options a measurement was produced with, echoed by the API. */
86
+ export interface ImportOptions {
87
+ /** Simplification tolerance, in mm of radius. */
88
+ readonly tolerance: number;
89
+ /** Whether enclosed bays — a V-flange groove, a thread relief — were raised to their brims. */
90
+ readonly fillBays: boolean;
91
+ /** Whether the holder was turned end for end after the automatic orientation. */
92
+ readonly flipped: boolean;
93
+ }
94
+ /**
95
+ * One holder as the API measured it, plus the two things the API cannot know.
96
+ *
97
+ * `brand` and `catalogNumber` are the caller's: the API is handed a STEP file
98
+ * and hands back geometry, and which part that file is came from the filename
99
+ * `node/cad-mirror.ts` wrote it under.
100
+ */
101
+ export interface MeasuredHolder {
102
+ readonly brand: BrandName;
103
+ readonly catalogNumber: string;
104
+ /** The envelope as a stack of cones, **nose first**. Always complete, taper included. */
105
+ readonly layers: readonly HolderLayer[];
106
+ /**
107
+ * Millimetres from the bottom of the stack to the gauge plane, or null.
108
+ *
109
+ * **Null is not zero.** A straight shank or a Capto carries no cone to place
110
+ * a gauge plane on, and a profile measured off one has no gage-line datum to
111
+ * be stated in.
112
+ */
113
+ readonly gaugeLength: number | null;
114
+ /** 30/40/50 for a 7:24, 25–160 for an HSK, or null where no taper was found. */
115
+ readonly sizeClass: number | null;
116
+ readonly taperFamily: TaperFamily | null;
117
+ /** What pins the numbers to a run. */
118
+ readonly kernelVersion: string;
119
+ readonly options: ImportOptions;
120
+ }
121
+ /**
122
+ * One vertex of a silhouette: `[z, r]`, both in millimetres.
123
+ *
124
+ * `@toolpath/tool-support`'s, re-exported under this package's own name.
125
+ */
126
+ export type { ProfilePoint } from '@toolpath/tool-support';
127
+ /**
128
+ * What `z = 0` means on a profile.
129
+ *
130
+ * `gage-line` is the datum everything downstream assumes — the plane the
131
+ * spindle measures stickout from, with `z` increasing toward the cutting end,
132
+ * so the taper is negative and the nose positive. `nose` is the fallback where
133
+ * the API found no gauge plane to solve, and it is stated rather than silently
134
+ * referenced to an arbitrary end.
135
+ *
136
+ * **Per profile rather than per document**, which is where the reference
137
+ * implementation put it. One `datum` over a batch is only true while every
138
+ * holder in it has a taper, and the first Capto or straight-shank holder makes
139
+ * the document's own header wrong about some of its entries.
140
+ *
141
+ * `@toolpath/tool-support`'s, re-exported under this package's own name. It was
142
+ * declared here, in the drawing package, and in the application between them —
143
+ * three copies of two strings, one of which decides whether a consumer may
144
+ * print a gauge length at all.
145
+ */
146
+ export type { ProfileDatum } from '@toolpath/tool-support';
147
+ /** One holder's measured silhouette, and how far it agrees with the vendor. */
148
+ export interface HolderProfile {
149
+ readonly catalogNumber: string;
150
+ readonly datum: ProfileDatum;
151
+ /** The silhouette, `z` ascending. Two points share a `z` where the solid steps. */
152
+ readonly points: readonly ProfilePoint[];
153
+ /** Where the API put the gauge plane, in mm from the nose, or null. */
154
+ readonly gaugeLengthSolved: number | null;
155
+ /** The vendor's own `L1`, in mm — `HolderRecord.gaugeLengthMm`. */
156
+ readonly gaugeLengthPublished: number;
157
+ readonly sizeClass: number | null;
158
+ readonly taperFamily: TaperFamily | null;
159
+ /** Whether the two gage lengths agree to within {@link GAUGE_TOLERANCE_MM}. */
160
+ readonly complete: boolean;
161
+ /**
162
+ * How far the model falls short of the published gage length, when it does.
163
+ *
164
+ * Present only on an incomplete profile, and only the amount. *Why* a
165
+ * vendor's model stops early is a fact about that family and belongs in the
166
+ * family's own notes rather than repeated on five records as a string a UI
167
+ * would be tempted to print verbatim.
168
+ */
169
+ readonly shortfallMm?: number;
170
+ }
171
+ /** Every measured holder of a run, keyed by the guid its record was minted under. */
172
+ export interface ProfilesDocument {
173
+ readonly profilesVersion: number;
174
+ /**
175
+ * Always millimetres.
176
+ *
177
+ * The API measures in mm whatever the family's unit is, and nothing here
178
+ * converts: a shape measures what it measures, and an inch holder's profile
179
+ * is the same solid as a metric one's. `HolderRecord.gaugeLength` is what
180
+ * carries the vendor's own unit, for display.
181
+ */
182
+ readonly unit: 'millimeters';
183
+ readonly kernelVersion: string;
184
+ readonly options: ImportOptions;
185
+ readonly holderCount: number;
186
+ readonly holders: Readonly<Record<string, HolderProfile>>;
187
+ }
188
+ /**
189
+ * A taper designation as a size and an interface — `BT30` -> 30 / iso7x24.
190
+ *
191
+ * The other half of {@link checkProfile}'s agreement gate: the API measures a
192
+ * `sizeClass` and a `taperFamily` off the solid and cannot tell BT40 from CAT40
193
+ * from ISO40, so what it *can* be held to is the size and the family, and the
194
+ * vendor's own designation is where those come from.
195
+ *
196
+ * **Throws on a designation it does not know**, rather than returning null and
197
+ * letting the check be skipped. A prefix nobody has written down is this
198
+ * package's vocabulary being short, which is a `ScraperConfigError` — and
199
+ * silently not checking a family is how a mirrored STEP file goes unnoticed.
200
+ */
201
+ export declare function taperDesignation(taper: string): {
202
+ sizeClass: number;
203
+ family: TaperFamily;
204
+ };
205
+ /**
206
+ * The measured stack as a silhouette on the gage line.
207
+ *
208
+ * `layers` is nose-first and datumed on nothing; a drawing wants `z` ascending
209
+ * from the spindle end with the gage line at zero. The conversion is one line
210
+ * of arithmetic and one decision:
211
+ *
212
+ * ```
213
+ * zFromNose = 0, then the running sum of thickness
214
+ * z = gaugeLength - zFromNose // nose positive, spindle end negative
215
+ * r = diameter / 2
216
+ * ```
217
+ *
218
+ * On `BT30ER16060M` that puts the nose at `z = +60` and the top of the taper at
219
+ * `60 - 108.4 = -48.4`, which is exactly the range Fusion measured, with the
220
+ * ER16 nut at `r = 16.0` — the scraped `lockNutDiameter` of 32 — at both ends.
221
+ *
222
+ * **The decision is the step faces.** Consecutive layers usually meet at one
223
+ * diameter, and two of `BT30ER16060M`'s 112 do not: the solid jumps vertically
224
+ * there. Emitting one point per boundary would draw those two jumps as slopes
225
+ * across the neighbouring layers and quietly shave material off the envelope,
226
+ * so a boundary whose diameters disagree gets **two points at the same `z`**.
227
+ * That is why {@link checkProfile} requires `z` to be non-decreasing rather
228
+ * than strictly increasing.
229
+ *
230
+ * With `gaugeLength` null there is no gauge plane to datum on and the nose is
231
+ * used instead, which is what {@link HolderProfile.datum} states. An empty
232
+ * stack yields no points rather than throwing — {@link checkProfile} is where a
233
+ * profile too short to draw is refused, and it says so with the part's name.
234
+ */
235
+ export declare function layersToProfile(layers: readonly HolderLayer[], gaugeLength: number | null): ProfilePoint[];
236
+ /**
237
+ * Boundary validation for a measurement, the role `holding.ts`'s gates play for
238
+ * a scraped row.
239
+ *
240
+ * These come from a third-party CAD model rather than a vendor table, so the
241
+ * failures worth catching are the ones that **render as a plausible picture**:
242
+ * a silhouette that runs backwards, a negative radius, a datum outside the
243
+ * part, or a model that is not the holder the row says it is.
244
+ *
245
+ * `declaredTaper` is {@link HolderRecord.taper}. The reference implementation
246
+ * gated on `sizeClass == 30` instead, which was correct when the catalog was
247
+ * BT30 throughout and is not now — MariTool ships CAT40, CAT50, BT40 and nine
248
+ * HSK sizes. Agreement with what the vendor declares catches the same thing
249
+ * and travels: a CAT40 row whose model measures as an HSK is a mirrored file or
250
+ * a mis-scraped row. Geometry cannot tell BT40 from CAT40, so only the size and
251
+ * the family are checked.
252
+ */
253
+ export declare function checkProfile(profile: HolderProfile, declaredTaper: string): void;
254
+ /**
255
+ * Measured holders plus the records they belong to -> the profiles document.
256
+ *
257
+ * **The join is `(brand, catalogNumber)`, and both halves matter.** The catalog
258
+ * number is what a mirrored STEP file is named for, so it is what a measurement
259
+ * carries back; `HolderRecord.catalogNumber` is that same cell, because every
260
+ * mapper reads it from `conventions.catalogColumn(brand)` — Kennametal's
261
+ * `ISO Catalog Number`, MariTool's `Material Number`, which is why the two
262
+ * identity fields are not interchangeable here. The brand is in the key because
263
+ * `node/paths.ts#stepDir` is per vendor: two vendors' catalog numbers live in
264
+ * two directories and have never had to be distinct from each other.
265
+ *
266
+ * **A measured holder matching no record raises.** It means a family was
267
+ * measured and then renamed or dropped, and silently omitting it looks exactly
268
+ * like a holder the vendor publishes no model for — the one thing this document
269
+ * must not be ambiguous about.
270
+ *
271
+ * **Every measurement must agree on the kernel and the options.** A document
272
+ * states one of each, and a batch that spanned a kernel upgrade or two
273
+ * `fillBays` settings would state one and contain both.
274
+ */
275
+ 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
+ }
@@ -44,6 +44,7 @@
44
44
  * is shaped `YYYY-MM-DD` — and `cite: ''` would otherwise satisfy the
45
45
  * compiler while citing nothing.
46
46
  */
47
+ import { type Provenance } from '@toolpath/tool-support';
47
48
  /** What a per-family constant can be. */
48
49
  export type FactValue = string | number | boolean;
49
50
  /**
@@ -53,9 +54,16 @@ export type FactValue = string | number | boolean;
53
54
  * before the guesses and finishes on the ones only a person stands behind —
54
55
  * `derived` first, then `assumed`. (`vendor-stated` leads the order but is
55
56
  * filtered out of that document entirely; see {@link assumptions}.)
57
+ *
58
+ * **`@toolpath/tool-support`'s `PROVENANCE`**, re-exported under this package's
59
+ * own name. The order is load-bearing here and is the shared list's, so the
60
+ * ordering that document is read in cannot drift from the vocabulary a drawing
61
+ * marks a derived dimension by. Three identical declarations of these three
62
+ * strings stood before it — this one, the drawing's `Provenance`, and the
63
+ * application's.
56
64
  */
57
65
  export declare const SOURCES: readonly ["vendor-stated", "derived", "assumed"];
58
- export type FactSource = (typeof SOURCES)[number];
66
+ export type FactSource = Provenance;
59
67
  /**
60
68
  * One per-family constant, with its provenance.
61
69
  *
@@ -44,6 +44,7 @@
44
44
  * is shaped `YYYY-MM-DD` — and `cite: ''` would otherwise satisfy the
45
45
  * compiler while citing nothing.
46
46
  */
47
+ import { PROVENANCE } from '@toolpath/tool-support';
47
48
  import { ScraperConfigError } from './errors.js';
48
49
  import { compare } from './order.js';
49
50
  /**
@@ -53,8 +54,15 @@ import { compare } from './order.js';
53
54
  * before the guesses and finishes on the ones only a person stands behind —
54
55
  * `derived` first, then `assumed`. (`vendor-stated` leads the order but is
55
56
  * filtered out of that document entirely; see {@link assumptions}.)
57
+ *
58
+ * **`@toolpath/tool-support`'s `PROVENANCE`**, re-exported under this package's
59
+ * own name. The order is load-bearing here and is the shared list's, so the
60
+ * ordering that document is read in cannot drift from the vocabulary a drawing
61
+ * marks a derived dimension by. Three identical declarations of these three
62
+ * strings stood before it — this one, the drawing's `Provenance`, and the
63
+ * application's.
56
64
  */
57
- export const SOURCES = ['vendor-stated', 'derived', 'assumed'];
65
+ export const SOURCES = PROVENANCE;
58
66
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
59
67
  /** What a note has to say, per source kind. */
60
68
  const WANTED = {