@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,360 @@
1
+ /**
2
+ * What a holder and a collet become, and the two gates that refuse one.
3
+ *
4
+ * `records.ts` is the cutting-tool half of this package's output. This is the
5
+ * toolholding half, and it is a separate module rather than a third `ToolKind`
6
+ * because the three vocabularies genuinely do not overlap: a tool answers `DC`,
7
+ * a flute count and a workpiece material group; a holder answers a taper, a
8
+ * clamping mode and a gage length; a collet answers a series and a capacity
9
+ * band. `families/index.ts` states the same rule for the config tables —
10
+ *
11
+ * > Separate tables rather than a `kind` on one, because a holder and a collet
12
+ * > are not variants of a thing.
13
+ *
14
+ * — and the records follow it. What they *do* share is identity, the unit rule
15
+ * and the guid space, which is why both live here and not in two more files:
16
+ * `identity.recordGuid` mints a holder and a tool into one namespace per brand,
17
+ * so a consumer building a catalog from both can refuse a collision between
18
+ * them.
19
+ *
20
+ * ## Two copies of a dimension, and only where something compares them
21
+ *
22
+ * A fit-bearing dimension is kept twice — once in the family's native unit for
23
+ * display, once in canonical millimetres for arithmetic. The reason is that fit
24
+ * and filtering are different questions: a 3/8 in shank is 9.525 mm and
25
+ * genuinely seats in a metric 9-10 mm collet, so a fit comparison must convert,
26
+ * while a range *filter* must still refuse to, because "between 9 and 10 mm" is
27
+ * not a question an inch tool answers.
28
+ *
29
+ * Only {@link HolderRecord.bore}, {@link HolderRecord.gaugeLength},
30
+ * {@link ColletRecord.clampMin} and {@link ColletRecord.clampMax} get a twin.
31
+ * Everything else — usable length, body diameter, adjustment range — is
32
+ * displayed and never compared, and a second copy of a number nothing reads is
33
+ * a field to keep in sync for free.
34
+ *
35
+ * **The twin is derived here rather than read from the other unit column**, and
36
+ * that is a deliberate departure from the reference implementation, which read
37
+ * `D1_mm` directly whatever the family's unit was. Vendors publish pairs that
38
+ * disagree — see {@link checkUnitAgreement} — and a record whose `bore` and
39
+ * `boreMm` came from two contradicting cells is one record stating two sizes.
40
+ * Deriving makes `boreMm` exactly `bore` in millimetres by construction, so the
41
+ * pair cannot drift and a test can pin one to the other.
42
+ *
43
+ * ## Which refusal a vendor's row earns
44
+ *
45
+ * The split is **blank versus wrong**, and it decides whether one bad part ends
46
+ * a family:
47
+ *
48
+ * - A cell the vendor left **blank** that a part cannot exist without — no gage
49
+ * length, no bore on a bore-clamping holder, no capacity on a collet — is an
50
+ * `errors.IncompletePartError`. `registry.toHolding` warns and drops the row,
51
+ * the same call `registry.toRecords` makes for a cutting tool, and for the
52
+ * same reason: MariTool leaves `Shank Size` blank on four HSK holders out of
53
+ * 527, and losing five families over four rows is not a trade worth making.
54
+ * - A value that is **present and unreadable**, or two present values that
55
+ * **contradict** — a clamping mode this package has no word for, a holder
56
+ * that names both a bore and a collet series, a collet whose nominal size
57
+ * falls outside its own published capacity — is an `errors.VendorResponseError`
58
+ * and stops the family. Those say the vendor's vocabulary or this package's
59
+ * reading of it has moved, and skipping past one quietly is how a scraper
60
+ * starts publishing a catalog nobody checked.
61
+ */
62
+ import { dimensionalColumn, UNIT_SUFFIX } from './conventions.js';
63
+ import { IncompletePartError, ScraperConfigError, VendorResponseError } from './errors.js';
64
+ import { BRANDS, productLink, recordGuid } from './identity.js';
65
+ import { convertLength, fractionValue } from './measure.js';
66
+ import { consoleWarn } from './scrape.js';
67
+ /** Every {@link ClampingMode}, for a message that can list what it knows. */
68
+ export const CLAMPING_MODES = ['bore', 'collet', 'shrink', 'hydraulic'];
69
+ /**
70
+ * The clamping modes that grip a shank directly, and therefore publish a bore.
71
+ *
72
+ * One rule over three values rather than three rules: the gate in
73
+ * {@link checkHolder} asks whether a holder takes a shank or a collet, and
74
+ * every mode but `collet` takes a shank.
75
+ */
76
+ export const BORE_CLAMPINGS = ['bore', 'shrink', 'hydraulic'];
77
+ /** Every {@link ContactMode}, for the same reason {@link CLAMPING_MODES} is a list. */
78
+ export const CONTACT_MODES = ['taper', 'face'];
79
+ /**
80
+ * A per-family constant a toolholding mapper cannot proceed without.
81
+ *
82
+ * `family.fact`'s counterpart for a family with no `id` and no `ToolKind`. The
83
+ * two are separate rather than one widened function because the subject of the
84
+ * message differs: a cutting-tool family is named by its vendor-local id and a
85
+ * toolholding family by the catalog name a human reads.
86
+ *
87
+ * Refusing rather than defaulting, for the reason `family.fact` states: every
88
+ * default is a claim the family never made, and a missing `taper` becoming
89
+ * `''` ships a holder that fits no spindle and raises nothing.
90
+ */
91
+ export function holdingFact(family, key, value) {
92
+ if (value === undefined) {
93
+ throw new ScraperConfigError(family.catalogName, `a ${family.kind} family must state ${key} as a fact`);
94
+ }
95
+ return value;
96
+ }
97
+ /**
98
+ * A value the vendor left blank on one part, refused as an incomplete part.
99
+ *
100
+ * The toolholding counterpart of `columns.required`, and it throws the same
101
+ * type for the same reason: this is the one refusal `registry.toHolding` skips
102
+ * past, because a single part with an unpublished cell must not end a family's
103
+ * conversion. See `errors.IncompletePartError` for why the others must not be
104
+ * skipped alike.
105
+ */
106
+ export function published(value, what, label) {
107
+ if (value === null || value === undefined || value === '') {
108
+ throw new IncompletePartError(what, `publishes no ${label}`);
109
+ }
110
+ return value;
111
+ }
112
+ /**
113
+ * Round a converted value to six decimals.
114
+ *
115
+ * **This removes error rather than adding precision.** 9.525 mm is exactly
116
+ * 0.375 in, but `9.525 / 25.4` is `0.37500000000000006` in binary floating
117
+ * point, and that is the number that would land in a catalog and in a
118
+ * prefix-matched size string. Six places is far coarser than the ~1e-14 the
119
+ * error reaches at these magnitudes and far finer than the four decimals a
120
+ * vendor prints, so nothing anybody stated is lost.
121
+ */
122
+ function round6(value) {
123
+ return Math.round(value * 1e6) / 1e6;
124
+ }
125
+ /**
126
+ * `value`, stated in `from`, as a number in `to` — a no-op when they agree.
127
+ *
128
+ * The one conversion every toolholding mapper makes, so that the rounding above
129
+ * is applied in one place rather than wherever somebody remembers to.
130
+ */
131
+ export function asUnit(value, from, to) {
132
+ return from === to ? value : round6(convertLength(value, from, to));
133
+ }
134
+ export function millimeters(value, unit) {
135
+ return value === null ? null : asUnit(value, unit, 'millimeters');
136
+ }
137
+ /**
138
+ * One dimension in `unit`, converted from the other system where that is all
139
+ * the vendor published.
140
+ *
141
+ * **The fallback is load-bearing, not defensive.** Kennametal's `D1` is a unit
142
+ * pair on the BT30 hydraulic chucks and metric-only on the HSK63A HP line — an
143
+ * *inch* family with no `D1_in` column at all. A bare suffixed read is correct
144
+ * on the first and yields null on the second, producing a holder with no bore:
145
+ * it matches no tool, raises nothing, and looks like an empty result rather
146
+ * than a bug.
147
+ *
148
+ * The grammar is `measure.fractionValue`'s, which is the package's one reader
149
+ * for a machinist's number and refuses a range rather than summing it.
150
+ */
151
+ export function dim(row, label, unit) {
152
+ const native = fractionValue(row[dimensionalColumn(label, unit)] ?? '');
153
+ if (native !== null)
154
+ return native;
155
+ const other = unit === 'millimeters' ? 'inches' : 'millimeters';
156
+ const fallback = fractionValue(row[dimensionalColumn(label, other)] ?? '');
157
+ if (fallback === null)
158
+ return null;
159
+ return round6(convertLength(fallback, other, unit));
160
+ }
161
+ /** Half a unit in the last decimal place a cell actually printed. */
162
+ function halfUlp(raw) {
163
+ const fraction = raw.includes('.') ? raw.slice(raw.indexOf('.') + 1) : '';
164
+ return 0.5 * 10 ** -fraction.length;
165
+ }
166
+ /**
167
+ * Report where a vendor's own millimetre and inch cells disagree.
168
+ *
169
+ * **Vendors really do publish contradictory pairs.** Kennametal's `16ERSS0312`
170
+ * states `D1`'s metric cell as `0.3125` — the inch value sitting in the metric
171
+ * column, a factor of 25.4 out — and `25ER130M` publishes `CCCN` as both
172
+ * 12.0 mm and 0.437 in, which is 11.1 mm. Both are in the source HTML.
173
+ *
174
+ * **This reports; it does not gate.** Two disagreeing cells cannot say which
175
+ * one is wrong, so refusing the family would trade a knowable warning for an
176
+ * unusable pipeline, and correcting a cell here would make this package a place
177
+ * tool data is authored by hand. What protects the output instead is that
178
+ * {@link dim} reads the family's *native* column and never the other one, plus
179
+ * {@link checkCollet}'s native-unit test that a nominal size falls inside its
180
+ * own published capacity. Every disagreement found so far sits in the column
181
+ * {@link dim} ignores; escalate this to a gate if one ever lands in a native
182
+ * column.
183
+ *
184
+ * **The tolerance is the vendor's own rounding, not a percentage.** A relative
185
+ * tolerance cannot tell rounding from error at small sizes: `16ER010M`
186
+ * publishes 0.5 mm as `0.02` in, correct to the two decimals it states and
187
+ * 1.6 % off as a ratio. Half a unit in each column's last printed place is
188
+ * exactly the slack the printed precision allows, and it is nowhere near the
189
+ * 7.6 mm a value in the wrong column produces.
190
+ *
191
+ * Returns whether it warned, so a caller can count.
192
+ */
193
+ export function checkUnitAgreement(row, label, what, warn = consoleWarn) {
194
+ const rawMm = (row[label + UNIT_SUFFIX.millimeters] ?? '').trim();
195
+ const rawIn = (row[label + UNIT_SUFFIX.inches] ?? '').trim();
196
+ const mm = fractionValue(rawMm);
197
+ const inch = fractionValue(rawIn);
198
+ if (mm === null || inch === null)
199
+ return false;
200
+ const slack = halfUlp(rawIn) * convertLength(1, 'inches', 'millimeters') + halfUlp(rawMm);
201
+ const asMm = convertLength(inch, 'inches', 'millimeters');
202
+ if (Math.abs(mm - asMm) <= slack)
203
+ return false;
204
+ warn(` WARNING: ${what}: ${label} disagrees across unit systems — ` +
205
+ `${mm} mm vs ${inch} in (= ${asMm} mm); the native column is used`);
206
+ return true;
207
+ }
208
+ /**
209
+ * One cell as a {@link ContactMode}, refusing a word this package cannot read.
210
+ *
211
+ * A `VendorResponseError` and not an incomplete part: a *blank* contact is the
212
+ * caller's `published` call, and a contact the vendor states in a word nobody
213
+ * has mapped is the vocabulary having moved.
214
+ */
215
+ export function contactMode(value, what) {
216
+ if (CONTACT_MODES.includes(value))
217
+ return value;
218
+ throw new VendorResponseError(what, `contact is ${JSON.stringify(value)} — it must be one of ` + `${CONTACT_MODES.join(', ')}`);
219
+ }
220
+ /** One cell as a {@link ClampingMode}, on the same terms as {@link contactMode}. */
221
+ export function clampingMode(value, what) {
222
+ if (CLAMPING_MODES.includes(value))
223
+ return value;
224
+ throw new VendorResponseError(what, `clamping is ${JSON.stringify(value)} — it must be one of ` + `${CLAMPING_MODES.join(', ')}`);
225
+ }
226
+ /**
227
+ * One cell as a {@link UnitSystem}, on the same terms as {@link contactMode}.
228
+ *
229
+ * For the vendor that states the unit per row rather than per family: REGO-FIX
230
+ * publishes `PG 25 Ø 6.0 mm` and `PG 25 Ø 1/4"` in one product group, so its
231
+ * collet scrape writes the system it read off each designation into a column.
232
+ */
233
+ export function unitSystem(value, what) {
234
+ if (Object.hasOwn(UNIT_SUFFIX, value))
235
+ return value;
236
+ throw new VendorResponseError(what, `unit is ${JSON.stringify(value)} — it must be one of ` +
237
+ `${Object.keys(UNIT_SUFFIX).sort().join(', ')}`);
238
+ }
239
+ /** How a record names itself in a warning or a refusal. */
240
+ function subject(fields) {
241
+ return `${fields.catalogNumber} (${fields.materialNumber})`;
242
+ }
243
+ /**
244
+ * Vendor HTML is a system boundary, so this validates rather than guarding
245
+ * against a caller mistake.
246
+ *
247
+ * The `clamping` discriminant and the fields it implies must agree. **That is
248
+ * what turns Kennametal's missing-bore case into a failed conversion instead of
249
+ * a holder that quietly fits nothing** — a bore-clamping holder with no bore is
250
+ * the exact shape of that bug, and it raises nothing anywhere else.
251
+ */
252
+ export function checkHolder(record) {
253
+ const what = subject(record);
254
+ const bore = BORE_CLAMPINGS.includes(record.clamping);
255
+ if (bore) {
256
+ if (record.bore === null) {
257
+ throw new IncompletePartError(what, `publishes no bore, and a ${record.clamping}-clamping holder grips a shank directly`);
258
+ }
259
+ if (record.colletSeries !== null) {
260
+ throw new VendorResponseError(what, `is ${record.clamping}-clamping and also names collet series ` +
261
+ `${JSON.stringify(record.colletSeries)} — a holder grips one way or the other`);
262
+ }
263
+ }
264
+ else {
265
+ if (record.colletSeries === null) {
266
+ throw new IncompletePartError(what, 'is collet-clamping and names no collet series');
267
+ }
268
+ if (record.bore !== null) {
269
+ throw new VendorResponseError(what, `is collet-clamping and also publishes a bore of ${record.bore} — ` +
270
+ `a holder grips one way or the other`);
271
+ }
272
+ }
273
+ // Optional is fine; *malformed* is refused. A consumer renders this as a
274
+ // download button, and a truncated or redirected link is the one failure that
275
+ // looks like a working feature until somebody clicks it. Widened from the
276
+ // reference implementation's `.stp`-only test because MariTool publishes both
277
+ // spellings.
278
+ const url = record.cadModelUrl;
279
+ if (url !== null && !/^https:\/\/.+\.ste?p$/i.test(url)) {
280
+ throw new VendorResponseError(what, `CAD model URL is not an https .stp or .step: ${JSON.stringify(url)}`);
281
+ }
282
+ }
283
+ /**
284
+ * The same boundary rule for a collet.
285
+ *
286
+ * A collet with no capacity would match every shank or none depending on which
287
+ * way a comparison read a null, which is why {@link ColletRecord.clampMin} and
288
+ * {@link ColletRecord.clampMax} are not nullable and the mapper refuses the row
289
+ * before it gets here.
290
+ */
291
+ export function checkCollet(record) {
292
+ const what = subject(record);
293
+ // Equality is a sealed coolant-through collet clamping one exact size — real,
294
+ // and not a bug. Only an inverted range is impossible.
295
+ if (record.clampMin > record.clampMax) {
296
+ throw new VendorResponseError(what, `capacity is inverted: ${record.clampMin} > ${record.clampMax}`);
297
+ }
298
+ // In the native unit, which is the gate with teeth: these are the values a
299
+ // consumer compares, and the contradictory cells this catalog knows about all
300
+ // sit in the column `dim` ignores.
301
+ if (record.nominal !== null &&
302
+ (record.nominal < record.clampMin || record.nominal > record.clampMax)) {
303
+ throw new VendorResponseError(what, `nominal ${record.nominal} is outside its own capacity ` +
304
+ `${record.clampMin}-${record.clampMax}`);
305
+ }
306
+ }
307
+ /**
308
+ * Build a {@link HolderRecord}: mint its guid, derive its millimetre twins, and
309
+ * refuse the states that cannot be true.
310
+ *
311
+ * `guid`, `vendor` and `productLink` are not inputs at all — every adapter
312
+ * minting them would be three copies of `recordGuid(brand, materialNumber)` to
313
+ * drift, on the value that is the join key for every downstream consumer. The
314
+ * nullable dimensions stay **required on the type** so a consumer reading a
315
+ * record never handles `undefined`; only the construction is optional, which is
316
+ * the shape `records.toolRecord` already has.
317
+ *
318
+ * The result is frozen: a record is an interchange value, and a mapper that
319
+ * mutated one would be reaching back across the seam this type exists to draw.
320
+ */
321
+ export function holderRecord(fields) {
322
+ const record = Object.freeze({
323
+ ...fields,
324
+ kind: 'holder',
325
+ guid: recordGuid(fields.brand, fields.materialNumber),
326
+ vendor: BRANDS[fields.brand].vendor,
327
+ productLink: productLink(fields.brand, fields.materialNumber),
328
+ colletSeries: fields.colletSeries ?? null,
329
+ bore: fields.bore ?? null,
330
+ boreMm: millimeters(fields.bore ?? null, fields.unit),
331
+ gaugeLengthMm: millimeters(fields.gaugeLength, fields.unit),
332
+ usableLength: fields.usableLength ?? null,
333
+ clampingLength: fields.clampingLength ?? null,
334
+ adjustmentRange: fields.adjustmentRange ?? null,
335
+ bodyDiameter: fields.bodyDiameter ?? null,
336
+ lockNutDiameter: fields.lockNutDiameter ?? null,
337
+ cadModelUrl: fields.cadModelUrl ?? null,
338
+ cadDxfUrl: fields.cadDxfUrl ?? null,
339
+ });
340
+ checkHolder(record);
341
+ return record;
342
+ }
343
+ /** Build a {@link ColletRecord}, on the same terms as {@link holderRecord}. */
344
+ export function colletRecord(fields) {
345
+ const record = Object.freeze({
346
+ ...fields,
347
+ kind: 'collet',
348
+ guid: recordGuid(fields.brand, fields.materialNumber),
349
+ vendor: BRANDS[fields.brand].vendor,
350
+ productLink: productLink(fields.brand, fields.materialNumber),
351
+ nominal: fields.nominal ?? null,
352
+ clampMinMm: millimeters(fields.clampMin, fields.unit),
353
+ clampMaxMm: millimeters(fields.clampMax, fields.unit),
354
+ bodyDiameter: fields.bodyDiameter ?? null,
355
+ functionalLength: fields.functionalLength ?? null,
356
+ overallLength: fields.overallLength ?? null,
357
+ });
358
+ checkCollet(record);
359
+ return record;
360
+ }
package/dist/index.d.ts CHANGED
@@ -20,27 +20,37 @@
20
20
  * point, because a backend embedding this wants the data and a maintainer
21
21
  * running the CLI wants the file, and only one of those two needs `fs`.
22
22
  *
23
- * ## Cutting tools become records; toolholding is rows
24
- *
25
- * **`ToolRecord` is the uniform output for cutting tools, and only for those.**
26
- * `registry.toRecords` maps one cutting-tool family's scrape onto it, through
27
- * the adapter its brand binds. Holders and collets have no record type and no
28
- * mapper: REGO-FIX and MariTool ship toolholding and nothing else, so a scrape
29
- * of either ends at rows and a receipt, and a consumer that wants a holder
30
- * reads the CSV's own columns the shared ones are named in `conventions`.
31
- *
32
- * That is a real gap rather than a design: `identity.recordGuid` already exists
33
- * so that a holder and a tool can be minted into one guid space, and nothing
34
- * mints a holder yet. Until something does, `toRecords` is the tool half and
35
- * the receipt is the whole of the toolholding half.
23
+ * ## Three record types, one guid space
24
+ *
25
+ * `ToolRecord` is the uniform output for **cutting tools**, and `HolderRecord`
26
+ * and `ColletRecord` are the toolholding half `registry.toRecords` and
27
+ * `registry.toHolding` map one family's scrape onto them through the adapter
28
+ * its brand binds. All three are minted by `identity.recordGuid` in one
29
+ * namespace per brand, which is what lets a consumer build a catalog from
30
+ * holders and tools together and refuse a collision between them.
31
+ *
32
+ * They are three types rather than one because the vocabularies do not overlap:
33
+ * a tool answers `DC`, a flute count and a workpiece material group; a holder
34
+ * answers a taper, a clamping mode and a gage length; a collet answers a series
35
+ * and a capacity band. `holding.ts` states that at length, and `families/index.ts`
36
+ * already drew the same line for the config tables.
37
+ *
38
+ * **A vendor may publish holders, collets, both or neither, and none of it is
39
+ * required.** REGO-FIX ships toolholding and no cutting tools; MariTool ships
40
+ * holders and no collets; Harvey, EMUGE-FRANKEN and Destiny Tool ship neither.
41
+ * A brand with no mapper for a kind still scrapes, still writes a CSV and still
42
+ * checks a receipt — `registry.toHolding` is the only call that refuses, and it
43
+ * names the brand and what that brand does map.
36
44
  */
37
45
  export * from './columns.js';
38
46
  export * from './conventions.js';
39
47
  export * from './errors.js';
40
48
  export * from './family.js';
41
49
  export * from './fetch.js';
50
+ export * from './holding.js';
42
51
  export * from './identity.js';
43
52
  export * from './measure.js';
53
+ export * from './profiles.js';
44
54
  export * from './provenance.js';
45
55
  export * from './records.js';
46
56
  export * from './scrape.js';
package/dist/index.js CHANGED
@@ -20,27 +20,37 @@
20
20
  * point, because a backend embedding this wants the data and a maintainer
21
21
  * running the CLI wants the file, and only one of those two needs `fs`.
22
22
  *
23
- * ## Cutting tools become records; toolholding is rows
24
- *
25
- * **`ToolRecord` is the uniform output for cutting tools, and only for those.**
26
- * `registry.toRecords` maps one cutting-tool family's scrape onto it, through
27
- * the adapter its brand binds. Holders and collets have no record type and no
28
- * mapper: REGO-FIX and MariTool ship toolholding and nothing else, so a scrape
29
- * of either ends at rows and a receipt, and a consumer that wants a holder
30
- * reads the CSV's own columns the shared ones are named in `conventions`.
31
- *
32
- * That is a real gap rather than a design: `identity.recordGuid` already exists
33
- * so that a holder and a tool can be minted into one guid space, and nothing
34
- * mints a holder yet. Until something does, `toRecords` is the tool half and
35
- * the receipt is the whole of the toolholding half.
23
+ * ## Three record types, one guid space
24
+ *
25
+ * `ToolRecord` is the uniform output for **cutting tools**, and `HolderRecord`
26
+ * and `ColletRecord` are the toolholding half `registry.toRecords` and
27
+ * `registry.toHolding` map one family's scrape onto them through the adapter
28
+ * its brand binds. All three are minted by `identity.recordGuid` in one
29
+ * namespace per brand, which is what lets a consumer build a catalog from
30
+ * holders and tools together and refuse a collision between them.
31
+ *
32
+ * They are three types rather than one because the vocabularies do not overlap:
33
+ * a tool answers `DC`, a flute count and a workpiece material group; a holder
34
+ * answers a taper, a clamping mode and a gage length; a collet answers a series
35
+ * and a capacity band. `holding.ts` states that at length, and `families/index.ts`
36
+ * already drew the same line for the config tables.
37
+ *
38
+ * **A vendor may publish holders, collets, both or neither, and none of it is
39
+ * required.** REGO-FIX ships toolholding and no cutting tools; MariTool ships
40
+ * holders and no collets; Harvey, EMUGE-FRANKEN and Destiny Tool ship neither.
41
+ * A brand with no mapper for a kind still scrapes, still writes a CSV and still
42
+ * checks a receipt — `registry.toHolding` is the only call that refuses, and it
43
+ * names the brand and what that brand does map.
36
44
  */
37
45
  export * from './columns.js';
38
46
  export * from './conventions.js';
39
47
  export * from './errors.js';
40
48
  export * from './family.js';
41
49
  export * from './fetch.js';
50
+ export * from './holding.js';
42
51
  export * from './identity.js';
43
52
  export * from './measure.js';
53
+ export * from './profiles.js';
44
54
  export * from './provenance.js';
45
55
  export * from './records.js';
46
56
  export * from './scrape.js';
package/dist/measure.d.ts CHANGED
@@ -41,16 +41,22 @@
41
41
  * writes `1 1/2` and admits no hyphen at all. What it hands here is
42
42
  * {@link Measured}, which is the part they have in common.
43
43
  *
44
- * **`MM_PER_INCH` lives here and not in a vendor.** It was declared and
45
- * exported twice `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts`
46
- * — so `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
47
- * published their own copy of 25.4. That is exactly what `conventions.CAD_COLUMN`
48
- * was moved up for, and what `tests/vendor-boundary.test.ts` now refuses by name.
44
+ * **`MM_PER_INCH` and `convertLength` are `@toolpath/tool-support`'s**, and are
45
+ * re-exported here under the names this package has always published.
46
+ *
47
+ * They were declared and exported twice inside this package alone
48
+ * `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts` so
49
+ * `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
50
+ * published their own copy of 25.4. `tests/vendor-boundary.test.ts` refuses that
51
+ * by name and moving the constant up here fixed it *within* this package, while
52
+ * a third copy went on standing in the application downstream. The domain
53
+ * package is where a constant every consumer shares can only be declared once,
54
+ * and `@toolpath/tool-support`'s own boundary test now holds the whole tree to
55
+ * one `25.4`.
49
56
  */
50
- import type { UnitSystem } from './conventions.js';
57
+ import { convertLength, MM_PER_INCH, type UnitSystem } from '@toolpath/tool-support';
51
58
  import { type Warn } from './scrape.js';
52
- /** Exact by definition: the inch has been 25.4 mm since 1959. */
53
- export declare const MM_PER_INCH = 25.4;
59
+ export { convertLength, MM_PER_INCH };
54
60
  /**
55
61
  * `.250` -> 0.25, `3/4` -> 0.75, `1-1/2` -> 1.5, `2` -> 2.
56
62
  *
@@ -63,8 +69,6 @@ export declare const MM_PER_INCH = 25.4;
63
69
  * that guessed would be the mistake `conventions.UNIT_SUFFIX` exists to prevent.
64
70
  */
65
71
  export declare function fractionValue(token: string): number | null;
66
- /** `value`, converted from `from` to `to`. A no-op when they agree. */
67
- export declare function convertLength(value: number, from: UnitSystem, to: UnitSystem): number;
68
72
  /**
69
73
  * What a value states about itself: a length system, or degrees.
70
74
  *
package/dist/measure.js CHANGED
@@ -41,15 +41,22 @@
41
41
  * writes `1 1/2` and admits no hyphen at all. What it hands here is
42
42
  * {@link Measured}, which is the part they have in common.
43
43
  *
44
- * **`MM_PER_INCH` lives here and not in a vendor.** It was declared and
45
- * exported twice `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts`
46
- * — so `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
47
- * published their own copy of 25.4. That is exactly what `conventions.CAD_COLUMN`
48
- * was moved up for, and what `tests/vendor-boundary.test.ts` now refuses by name.
44
+ * **`MM_PER_INCH` and `convertLength` are `@toolpath/tool-support`'s**, and are
45
+ * re-exported here under the names this package has always published.
46
+ *
47
+ * They were declared and exported twice inside this package alone
48
+ * `vendors/harvey/value.ts` and `vendors/regofix/scrape.ts` so
49
+ * `@toolpath/tool-scraper/vendors/harvey` and `.../vendors/regofix` each
50
+ * published their own copy of 25.4. `tests/vendor-boundary.test.ts` refuses that
51
+ * by name and moving the constant up here fixed it *within* this package, while
52
+ * a third copy went on standing in the application downstream. The domain
53
+ * package is where a constant every consumer shares can only be declared once,
54
+ * and `@toolpath/tool-support`'s own boundary test now holds the whole tree to
55
+ * one `25.4`.
49
56
  */
57
+ import { convertLength, MM_PER_INCH } from '@toolpath/tool-support';
50
58
  import { consoleWarn } from './scrape.js';
51
- /** Exact by definition: the inch has been 25.4 mm since 1959. */
52
- export const MM_PER_INCH = 25.4;
59
+ export { convertLength, MM_PER_INCH };
53
60
  /**
54
61
  * A decimal, a simple fraction or a mixed number — and nothing else.
55
62
  *
@@ -80,12 +87,6 @@ export function fractionValue(token) {
80
87
  const value = (whole === undefined ? 0 : Number(whole)) + part;
81
88
  return Number.isFinite(value) ? value : null;
82
89
  }
83
- /** `value`, converted from `from` to `to`. A no-op when they agree. */
84
- export function convertLength(value, from, to) {
85
- if (from === to)
86
- return value;
87
- return to === 'inches' ? value / MM_PER_INCH : value * MM_PER_INCH;
88
- }
89
90
  /**
90
91
  * A read cell as a length in `unit`, or null where it publishes none.
91
92
  *
@@ -27,7 +27,24 @@
27
27
  * later, optional step.
28
28
  */
29
29
  import type { Fetcher } from '../fetch.js';
30
+ import type { BrandName } from '../identity.js';
30
31
  import { type ScrapedRow, type Warn } from '../scrape.js';
32
+ /**
33
+ * What one part's mirrored STEP model is called.
34
+ *
35
+ * REGO-FIX's catalog number is the vendor's own title — `BT 30 / PG 25 x 075`
36
+ * — and a separator in it was being honoured as one: `downloadStep` creates
37
+ * the parent directory, so the file landed in a `BT 30 ` subdirectory instead
38
+ * of flat in `outDir`, against what this module promises. The number stays
39
+ * readable, which is the whole reason the file is named for it.
40
+ *
41
+ * **Exported because two things now resolve it**, and two answers to "what is
42
+ * this part's file called" is one too many — the same argument
43
+ * {@link cadCoverage} makes for counting the column here. `node/holder-import.ts`
44
+ * reads back what this writes, and a mirror that flattened a separator while a
45
+ * reader did not would report every REGO-FIX holder as unmirrored.
46
+ */
47
+ export declare function stepFileName(catalogNumber: string): string;
31
48
  /** One STEP file onto disk. Returns the bytes written. */
32
49
  export declare function downloadStep(fetcher: Fetcher, url: string, dest: string): Promise<number>;
33
50
  /** One mirrored file. */
@@ -35,6 +52,35 @@ export interface MirroredStep {
35
52
  catalogNumber: string;
36
53
  bytes: number;
37
54
  }
55
+ /** How much of one family the vendor publishes a downloadable model for. */
56
+ export interface CadCoverage {
57
+ /** Rows in the CSV. */
58
+ rows: number;
59
+ /** Rows carrying a {@link CAD_COLUMN} a mirror could download. */
60
+ step: number;
61
+ /** Rows carrying a {@link CAD_DXF_COLUMN}. */
62
+ dxf: number;
63
+ }
64
+ /**
65
+ * What a mirror of these rows would and would not get, without asking for any
66
+ * of it.
67
+ *
68
+ * **This is the number that bounds anything built on measured geometry**, and
69
+ * it is worth knowing before the download rather than after: two of the three
70
+ * toolholding vendors publish a STEP model for every part, and MariTool — which
71
+ * is 527 of the 601 holder rows — publishes one for about two thirds. A
72
+ * pipeline scoped against 601 and run against 431 is a surprise at the end.
73
+ *
74
+ * Counted here rather than in the CLI because it is the same column read the
75
+ * same way {@link mirrorFamilySteps} reads it, and two answers to "does this row
76
+ * have a model" is one too many. Pure, and it makes no requests.
77
+ *
78
+ * **The DXF is counted and is not a fallback.** `conventions.CAD_DXF_COLUMN`
79
+ * says why the two are different columns: a DXF is a drawing whose datum,
80
+ * projection and layer semantics are the vendor's business, and deriving a
81
+ * profile from one is a separate project rather than a way to cover the gap.
82
+ */
83
+ export declare function cadCoverage(rows: readonly ScrapedRow[]): CadCoverage;
38
84
  /**
39
85
  * Every STEP model a holder scrape names, into `outDir`, one file per row.
40
86
  *
@@ -42,6 +88,16 @@ export interface MirroredStep {
42
88
  * filename is what a human reads and `BT30ER16060M` says what the part is
43
89
  * where `1258023` does not.
44
90
  *
91
+ * **`brand` is what says which column that number is in, and it is an argument
92
+ * rather than a guess.** This read `row['ISO Catalog Number']` until 2026-09-02,
93
+ * which is Kennametal's pair and REGO-FIX's adopted copy of it. MariTool
94
+ * publishes one number per part, under `Material Number`, and no catalog
95
+ * designation at all — `conventions.IDENTITY_DEVIATIONS` records why — so every
96
+ * one of its 357 published models was skipped with a warning saying the row had
97
+ * no catalog number to name it. `conventions.catalogColumn` is the lookup, and
98
+ * it is a lookup rather than a fallback down a list of candidate columns for the
99
+ * reason stated there.
100
+ *
45
101
  * **`outDir` is a required argument and never inferred.** These files are a
46
102
  * local working copy, they are gitignored, and a default that pointed into a
47
103
  * tracked directory would be the one mistake that silently commits ~3 MB of
@@ -51,5 +107,6 @@ export interface MirroredStep {
51
107
  * `lightweightStepUrl`'s documented null case arriving here — and a skipped
52
108
  * row spends no delay, because the count that matters is downloads and a
53
109
  * family that is mostly blank should not sleep its way through the gaps.
110
+ * {@link cadCoverage} is how many of them there will be, before any of it runs.
54
111
  */
55
- export declare function mirrorFamilySteps(fetcher: Fetcher, rows: readonly ScrapedRow[], outDir: string, delayMs?: number, warn?: Warn): Promise<MirroredStep[]>;
112
+ export declare function mirrorFamilySteps(fetcher: Fetcher, rows: readonly ScrapedRow[], brand: BrandName, outDir: string, delayMs?: number, warn?: Warn): Promise<MirroredStep[]>;