@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
package/dist/registry.js CHANGED
@@ -36,7 +36,10 @@ import { consoleWarn } from './scrape.js';
36
36
  import { RECORD_MAPPERS as DESTINYTOOL } from './vendors/destinytool/records.js';
37
37
  import { RECORD_MAPPERS as EMUGE } from './vendors/emuge/records.js';
38
38
  import { RECORD_MAPPERS as HARVEY } from './vendors/harvey/records.js';
39
+ import { HOLDING_MAPPERS as KM_HOLDING } from './vendors/kennametal/holding.js';
39
40
  import { RECORD_MAPPERS as KENNAMETAL } from './vendors/kennametal/records.js';
41
+ import { HOLDING_MAPPERS as MARITOOL_HOLDING } from './vendors/maritool/holding.js';
42
+ import { HOLDING_MAPPERS as REGOFIX_HOLDING } from './vendors/regofix/holding.js';
40
43
  /**
41
44
  * Brand -> its row-to-record mappers, by tool kind.
42
45
  *
@@ -53,6 +56,31 @@ export const ADAPTERS = {
53
56
  harvey: HARVEY,
54
57
  emuge: EMUGE,
55
58
  };
59
+ /**
60
+ * Brand -> its toolholding mappers, by the kind of thing they build.
61
+ *
62
+ * The toolholding counterpart of {@link ADAPTERS}, and **partial in both
63
+ * directions on purpose**. A brand absent from here can still be scraped: its
64
+ * families bind, its CSVs are written, and its receipt is checked, exactly as
65
+ * before — what it cannot do is mint records. A brand present with a mapper for
66
+ * only one kind is the same statement one level down; MariTool publishes ER
67
+ * collets that this package does not scrape, so it maps holders and nothing
68
+ * else.
69
+ *
70
+ * That is what makes minting records additive rather than a break. Nothing here
71
+ * changes what a vendor with no entry does today, and the refusal only happens
72
+ * where a caller explicitly asks for records from a family whose brand maps
73
+ * none — {@link toHolding}, which names the brand and what it does map.
74
+ *
75
+ * One entry serves two brands for the reason {@link ADAPTERS} states: Kennametal
76
+ * and WIDIA are the same platform and the same table vocabulary.
77
+ */
78
+ export const HOLDING_ADAPTERS = {
79
+ kennametal: KM_HOLDING,
80
+ widia: KM_HOLDING,
81
+ regofix: REGOFIX_HOLDING,
82
+ maritool: MARITOOL_HOLDING,
83
+ };
56
84
  /**
57
85
  * Check every fact of one family and project its value onto the config under
58
86
  * its own key.
@@ -110,27 +138,50 @@ export function boundFamilies() {
110
138
  return bound;
111
139
  }
112
140
  /**
113
- * Every holder and collet family, with its facts checked and projected.
141
+ * Every holder and collet family, with its facts checked and projected, and
142
+ * bound to the mapper its brand supplies for its kind.
114
143
  *
115
- * They bind no adapter only cutting tools go through a column map — but
116
- * their facts pass the same gate: a taper or a clamping mode is a per-family
117
- * constant no variant table states, exactly like a drill's flute count.
144
+ * Their facts pass the same gate cutting-tool families' do: a taper or a
145
+ * clamping mode is a per-family constant no variant table states, exactly like
146
+ * a drill's flute count.
147
+ *
148
+ * **A family whose brand maps nothing binds `undefined` rather than throwing**,
149
+ * which is where this differs from {@link boundFamilies}. A cutting-tool family
150
+ * with no mapper is a catalog fault — nothing can be done with it — but a
151
+ * toolholding family with no mapper is the state every one of them was in until
152
+ * records existed, and it still scrapes, writes a CSV and checks a receipt.
153
+ * Refusing at bind time would take that away from every consumer that never
154
+ * asked for a record. {@link toHolding} is where the absence is reported, at
155
+ * the one call that cannot proceed without it.
118
156
  */
119
157
  export function boundToolholding() {
120
158
  if (toolholding !== null)
121
159
  return toolholding;
122
160
  const bound = new Map();
123
- for (const [table, families_] of [
161
+ for (const [kind, families_] of [
124
162
  ['holder', HOLDER_FAMILIES],
125
163
  ['collet', COLLET_FAMILIES],
126
164
  ]) {
127
165
  for (const [name, cfg] of Object.entries(families_)) {
128
- bound.set(name, project(table, name, cfg));
166
+ const mappers = HOLDING_ADAPTERS[familyBrand(cfg)];
167
+ bound.set(name, {
168
+ ...project(kind, name, cfg),
169
+ kind,
170
+ records: mappers?.[kind],
171
+ });
129
172
  }
130
173
  }
131
174
  toolholding = bound;
132
175
  return bound;
133
176
  }
177
+ /** One bound toolholding family by CSV name. */
178
+ export function boundHolding(name) {
179
+ const cfg = boundToolholding().get(name);
180
+ if (cfg === undefined) {
181
+ throw new ScraperConfigError(name, `unknown toolholding family (known: ${[...boundToolholding().keys()].sort().join(', ')})`);
182
+ }
183
+ return cfg;
184
+ }
134
185
  /** One bound cutting-tool family by CSV name. */
135
186
  export function boundFamily(name) {
136
187
  const cfg = boundFamilies().get(name);
@@ -209,6 +260,58 @@ export function toRecords(familyName, scrape, options) {
209
260
  }
210
261
  return records;
211
262
  }
263
+ /**
264
+ * One toolholding family's scrape, as {@link HoldingRecord}s.
265
+ *
266
+ * {@link toRecords}'s counterpart, and deliberately the same shape: the two
267
+ * checks run before the first row, one incomplete part does not end the family,
268
+ * and the count of what was dropped is not returned because the caller has the
269
+ * row count it passed in and the length it got back.
270
+ *
271
+ * **It refuses only where a caller asked for something this package cannot
272
+ * give.** A toolholding family whose brand maps no mapper binds one anyway
273
+ * (see {@link boundToolholding}) and scrapes exactly as it did before; this is
274
+ * the one call that cannot proceed without one, so this is where the absence is
275
+ * named — with the brand and with what that brand does map, the way
276
+ * {@link boundFamilies} names a missing tool mapper.
277
+ *
278
+ * `checkColumnsExist` has no counterpart here: a holder family carries no
279
+ * `ColumnMap`, because the columns a holder publishes are the vendor's own and
280
+ * are read by that vendor's mapper rather than through a canonical name. What
281
+ * does still run is {@link checkIdentityColumns}, which catches the failure that
282
+ * matters most — a re-scrape whose part-number column was renamed still parses,
283
+ * still has the right row count, and mints every guid off an empty string.
284
+ */
285
+ export function toHolding(familyName, scrape, options) {
286
+ const cfg = boundHolding(familyName);
287
+ const warn = options?.warn ?? consoleWarn;
288
+ const brand = familyBrand(cfg);
289
+ const mapper = cfg.records;
290
+ if (mapper === undefined) {
291
+ const mappers = HOLDING_ADAPTERS[brand];
292
+ throw new ScraperConfigError(familyName, `brand ${JSON.stringify(brand)} has no ${cfg.kind} mapper ` +
293
+ (mappers === undefined
294
+ ? `— it maps no toolholding at all, so this family ends at rows and ` +
295
+ `a receipt (brands that map some: ` +
296
+ `${Object.keys(HOLDING_ADAPTERS).sort().join(', ')})`
297
+ : `(it maps: ${Object.keys(mappers).sort().join(', ')})`));
298
+ }
299
+ checkIdentityColumns(brand, scrape.header);
300
+ const records = [];
301
+ for (const row of scrape.rows) {
302
+ try {
303
+ records.push(cfg.kind === 'holder'
304
+ ? mapper(row, cfg, options)
305
+ : mapper(row, cfg, options));
306
+ }
307
+ catch (error) {
308
+ if (!(error instanceof IncompletePartError))
309
+ throw error;
310
+ warn(` WARNING: ${error.message} — no record written for it`);
311
+ }
312
+ }
313
+ return records;
314
+ }
212
315
  /**
213
316
  * Forget what has been bound.
214
317
  *
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Kennametal's and WIDIA's toolholding column vocabulary, and nothing else.
3
+ *
4
+ * The counterpart of `records.ts` for a holder and a collet: every decision
5
+ * about *what a record is* lives in `holding.ts`, and what lives here is which
6
+ * of this vendor's columns answers each question. That is the same line
7
+ * `vendors/kennametal/records.ts` draws for cutting tools.
8
+ *
9
+ * ## What this platform states as a fact rather than a column
10
+ *
11
+ * Almost everything. `taper`, `contact`, `clamping`, `style` and `unit` are all
12
+ * per-family constants here, declared in `families/kennametal.ts` with a
13
+ * citation each, because Kennametal sells one interface and one clamping mode
14
+ * per family — its dual-contact BT30 is a separate line (BTKV\*) with its own
15
+ * family code. The two vendors whose holders vary row by row are the ones whose
16
+ * mappers read a column instead.
17
+ *
18
+ * ## Two published columns this deliberately does not carry
19
+ *
20
+ * - **`L1FC`**, the BTKV30 line's "Gage Length Face Contact", 0.998 mm shorter
21
+ * than `L1` on every row. It is not a second measurement of one thing: it is
22
+ * the gage length *in a face-contact spindle*, so which of the two is true is
23
+ * a fact about the machine rather than about the holder. Carrying both would
24
+ * put two numbers named "gage" on one record with nothing to say which one
25
+ * stickout arithmetic should use. `contact` records that the holder has the
26
+ * geometry; `gaugeLength` stays `L1`. Promote it the day something reads a
27
+ * spindle's contact mode, and change `gaugeLength` with it rather than
28
+ * showing both.
29
+ * - **Torque figures, actuation-screw drive size, weight and `D21`.** Published,
30
+ * and dropped under `records.ToolRecord`'s standing rule: add a field when
31
+ * something displays it, not before.
32
+ */
33
+ import { type HoldingMappers } from '../../holding.js';
34
+ /** The toolholding half of the adapter contract `registry` looks up by brand. */
35
+ export declare const HOLDING_MAPPERS: HoldingMappers;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Kennametal's and WIDIA's toolholding column vocabulary, and nothing else.
3
+ *
4
+ * The counterpart of `records.ts` for a holder and a collet: every decision
5
+ * about *what a record is* lives in `holding.ts`, and what lives here is which
6
+ * of this vendor's columns answers each question. That is the same line
7
+ * `vendors/kennametal/records.ts` draws for cutting tools.
8
+ *
9
+ * ## What this platform states as a fact rather than a column
10
+ *
11
+ * Almost everything. `taper`, `contact`, `clamping`, `style` and `unit` are all
12
+ * per-family constants here, declared in `families/kennametal.ts` with a
13
+ * citation each, because Kennametal sells one interface and one clamping mode
14
+ * per family — its dual-contact BT30 is a separate line (BTKV\*) with its own
15
+ * family code. The two vendors whose holders vary row by row are the ones whose
16
+ * mappers read a column instead.
17
+ *
18
+ * ## Two published columns this deliberately does not carry
19
+ *
20
+ * - **`L1FC`**, the BTKV30 line's "Gage Length Face Contact", 0.998 mm shorter
21
+ * than `L1` on every row. It is not a second measurement of one thing: it is
22
+ * the gage length *in a face-contact spindle*, so which of the two is true is
23
+ * a fact about the machine rather than about the holder. Carrying both would
24
+ * put two numbers named "gage" on one record with nothing to say which one
25
+ * stickout arithmetic should use. `contact` records that the holder has the
26
+ * geometry; `gaugeLength` stays `L1`. Promote it the day something reads a
27
+ * spindle's contact mode, and change `gaugeLength` with it rather than
28
+ * showing both.
29
+ * - **Torque figures, actuation-screw drive size, weight and `D21`.** Published,
30
+ * and dropped under `records.ToolRecord`'s standing rule: add a field when
31
+ * something displays it, not before.
32
+ */
33
+ import { CAD_COLUMN, COLLET_DESIGNATION_COLUMN, COLLET_SERIES_COLUMN } from '../../conventions.js';
34
+ import { familyBrand } from '../../family.js';
35
+ import { checkUnitAgreement, clampingMode, colletRecord, contactMode, dim, holderRecord, holdingFact, published, } from '../../holding.js';
36
+ import { consoleWarn } from '../../scrape.js';
37
+ import { CATALOG_NUMBER, MATERIAL_NUMBER } from './records.js';
38
+ /**
39
+ * The dimensional labels whose two unit columns are worth cross-checking.
40
+ *
41
+ * Every dimension either record carries, and no more: a label nothing reads
42
+ * cannot produce a wrong number, so warning about it is noise. `holding.dim`
43
+ * reads the native column and never the other one, which is what makes this a
44
+ * report rather than a gate.
45
+ */
46
+ const HOLDER_LABELS = ['D1', 'L1', 'L2', 'L9', 'V', 'D2', 'D11'];
47
+ const COLLET_LABELS = ['CCCN', 'CCCX', 'D1', 'BDX', 'LF', 'L'];
48
+ /** How a part names itself in a warning or a refusal. */
49
+ function subject(row) {
50
+ return `${row[CATALOG_NUMBER] ?? ''} (${row[MATERIAL_NUMBER] ?? ''})`;
51
+ }
52
+ /** One Kennametal or WIDIA holder row -> one {@link HolderRecord}. */
53
+ function holder(row, family, options = {}) {
54
+ const warn = options.warn ?? consoleWarn;
55
+ const what = subject(row);
56
+ const unit = holdingFact(family, 'unit', family.unit);
57
+ for (const label of HOLDER_LABELS)
58
+ checkUnitAgreement(row, label, what, warn);
59
+ // Kennametal publishes no description column for toolholding, and `''` is the
60
+ // honest answer where a vendor publishes none — `records.ToolRecord.description`
61
+ // states the rule and the reason: a description that restates the catalog
62
+ // number puts one string in two fields.
63
+ return holderRecord({
64
+ brand: familyBrand(family),
65
+ materialNumber: published(row[MATERIAL_NUMBER], what, 'material number'),
66
+ catalogNumber: published(row[CATALOG_NUMBER], what, 'catalog number'),
67
+ description: '',
68
+ unit,
69
+ taper: holdingFact(family, 'taper', family.taper),
70
+ contact: contactMode(holdingFact(family, 'contact', family.contact), what),
71
+ clamping: clampingMode(holdingFact(family, 'clamping', family.clamping), what),
72
+ style: holdingFact(family, 'style', family.style),
73
+ colletSeries: row[COLLET_SERIES_COLUMN] || null,
74
+ bore: dim(row, 'D1', unit),
75
+ gaugeLength: published(dim(row, 'L1', unit), what, 'L1 gage length'),
76
+ usableLength: dim(row, 'L2', unit),
77
+ clampingLength: dim(row, 'L9', unit),
78
+ adjustmentRange: dim(row, 'V', unit),
79
+ bodyDiameter: dim(row, 'D2', unit),
80
+ lockNutDiameter: dim(row, 'D11', unit),
81
+ cadModelUrl: row[CAD_COLUMN] || null,
82
+ // This platform publishes a STEP model and no 2D profile. A DXF column would
83
+ // be a claim about the data, and false — the call `conventions.CAD_DXF_COLUMN`
84
+ // records.
85
+ cadDxfUrl: null,
86
+ });
87
+ }
88
+ /** One Kennametal or WIDIA collet row -> one {@link ColletRecord}. */
89
+ function collet(row, family, options = {}) {
90
+ const warn = options.warn ?? consoleWarn;
91
+ const what = subject(row);
92
+ const unit = holdingFact(family, 'unit', family.unit);
93
+ for (const label of COLLET_LABELS)
94
+ checkUnitAgreement(row, label, what, warn);
95
+ return colletRecord({
96
+ brand: familyBrand(family),
97
+ materialNumber: published(row[MATERIAL_NUMBER], what, 'material number'),
98
+ catalogNumber: published(row[CATALOG_NUMBER], what, 'catalog number'),
99
+ description: '',
100
+ unit,
101
+ series: published(row[COLLET_DESIGNATION_COLUMN], what, 'collet series'),
102
+ style: holdingFact(family, 'style', family.style),
103
+ nominal: dim(row, 'D1', unit),
104
+ clampMin: published(dim(row, 'CCCN', unit), what, 'CCCN clamping minimum'),
105
+ clampMax: published(dim(row, 'CCCX', unit), what, 'CCCX clamping maximum'),
106
+ bodyDiameter: dim(row, 'BDX', unit),
107
+ functionalLength: dim(row, 'LF', unit),
108
+ overallLength: dim(row, 'L', unit),
109
+ });
110
+ }
111
+ /** The toolholding half of the adapter contract `registry` looks up by brand. */
112
+ export const HOLDING_MAPPERS = { holder, collet };
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export * from './cad.js';
18
18
  export * from './family.js';
19
+ export * from './holding.js';
19
20
  export * from './materials.js';
20
21
  export * from './records.js';
21
22
  export * from './scrape.js';
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export * from './cad.js';
18
18
  export * from './family.js';
19
+ export * from './holding.js';
19
20
  export * from './materials.js';
20
21
  export * from './records.js';
21
22
  export * from './scrape.js';
@@ -0,0 +1,79 @@
1
+ /**
2
+ * MariTool's toolholding column vocabulary, and nothing else.
3
+ *
4
+ * The vendor at the far end of the range this package covers: where Kennametal
5
+ * states a holder's taper, contact, clamping mode, style and unit as five
6
+ * per-family constants, MariTool states every one of them per part.
7
+ * `families/maritool.ts` declares **no facts at all** for exactly that reason —
8
+ * each CSV is one spindle taper holding three clamping styles, the HSK file
9
+ * holds nine sizes, and `Gage Length` is metric on some parts and imperial on
10
+ * others inside one category page. So this mapper reads columns where the
11
+ * Kennametal one reads facts, and the record shape is the same either way.
12
+ *
13
+ * ## The record's unit comes from the gage length
14
+ *
15
+ * `scrape.holderRow` promotes `Gage Length` into the `conventions.GAGE_COLUMNS`
16
+ * pair with **exactly one cell filled**, and which one is filled is the only
17
+ * statement about unit system this vendor makes per part. That is what
18
+ * {@link HOLDING_MAPPERS} reads, and a row with both cells filled is refused
19
+ * rather than resolved: it would mean the scraper had changed shape, and
20
+ * picking one would hide that.
21
+ *
22
+ * ## `Shank Size` is the bore, and a bare cell is inches
23
+ *
24
+ * MariTool publishes no `D1`. Its shrink-fit and hydraulic holders state the
25
+ * shank they take under the vendor's own `Shank Size` label, which
26
+ * `scrape.holderRow` carries verbatim and unsuffixed — the adapter deliberately
27
+ * does not promote it, because a promoted column carries a unit suffix and
28
+ * these families declare no unit for one to be taken from.
29
+ *
30
+ * The cell takes three shapes across the 293 non-collet holders in the catalog,
31
+ * and {@link parseShankSize} reads all three: `.500`, `10mm`, and
32
+ * `.1181 (3mm)` — a decimal inch with the vendor's own metric annotation beside
33
+ * it. **A cell that names no unit is inches**, which is a claim about this
34
+ * vendor and worth the evidence: every bare value published is a fractional
35
+ * inch size (`.125`, `.1875`, `.250`, `.3125`, `.375`, `.4375`, `.500`, `.625`,
36
+ * `.750`, `1.0`, `1.25`, `1.5`), and the annotated form proves the reading —
37
+ * `.1181` is 3 mm in inches, to four places. One part in the catalog states a
38
+ * bare inch shank on a holder gaged in millimetres (`HSK40E-SF.125-45`), which
39
+ * is converted rather than warned about: the record's unit is itself promoted
40
+ * off another cell here, so the two disagreeing is this vendor's shape rather
41
+ * than a fault.
42
+ *
43
+ * ## Two published columns this does not carry
44
+ *
45
+ * - **`Nose Diameter`**, on 200-odd parts. It measures the holder's nose and
46
+ * `bodyDiameter` is `D2`, the body — promoting one into a field named for the
47
+ * other is precisely the collision `conventions` warns about at length. It
48
+ * also mixes `.870` with `.870 inches` in one column, which is a second
49
+ * reason to leave it as the receipt of what the vendor said.
50
+ * - **`Collet Grip Range`.** `scrape.holderRow` says why: it is the ER series'
51
+ * range restated on the holder's page, a pure function of `Collet Size`, and
52
+ * a real capacity comes from a collet family joined on `CST`.
53
+ */
54
+ import { type UnitSystem } from '../../conventions.js';
55
+ import { type HoldingMappers } from '../../holding.js';
56
+ /** MariTool's own label for the cell {@link parseShankSize} reads a bore from. */
57
+ export declare const SHANK_SIZE_LABEL = "Shank Size";
58
+ /**
59
+ * MariTool's own label for the lock-nut diameter — `D11`, on five parts.
60
+ *
61
+ * Carried where `Nose Diameter` is not, because this one names exactly what the
62
+ * record's field names: the outside diameter of the collet nut, which is what
63
+ * decides whether a holder clears a fixture.
64
+ */
65
+ export declare const COLLET_NUT_DIAMETER_LABEL = "Collet Nut Outside Diameter";
66
+ /** One cell as a diameter and the system it is stated in. Null where unreadable. */
67
+ export declare function parseShankSize(cell: string): {
68
+ value: number;
69
+ stated: UnitSystem;
70
+ } | null;
71
+ /**
72
+ * The toolholding half of the adapter contract `registry` looks up by brand.
73
+ *
74
+ * Holders only: MariTool sells collets and this package does not scrape them,
75
+ * so there is no `collet` mapper and `registry.toHolding` refuses a request for
76
+ * one naming what this brand does map. A partial table is the honest state for
77
+ * a kind nobody has read the columns of.
78
+ */
79
+ export declare const HOLDING_MAPPERS: HoldingMappers;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * MariTool's toolholding column vocabulary, and nothing else.
3
+ *
4
+ * The vendor at the far end of the range this package covers: where Kennametal
5
+ * states a holder's taper, contact, clamping mode, style and unit as five
6
+ * per-family constants, MariTool states every one of them per part.
7
+ * `families/maritool.ts` declares **no facts at all** for exactly that reason —
8
+ * each CSV is one spindle taper holding three clamping styles, the HSK file
9
+ * holds nine sizes, and `Gage Length` is metric on some parts and imperial on
10
+ * others inside one category page. So this mapper reads columns where the
11
+ * Kennametal one reads facts, and the record shape is the same either way.
12
+ *
13
+ * ## The record's unit comes from the gage length
14
+ *
15
+ * `scrape.holderRow` promotes `Gage Length` into the `conventions.GAGE_COLUMNS`
16
+ * pair with **exactly one cell filled**, and which one is filled is the only
17
+ * statement about unit system this vendor makes per part. That is what
18
+ * {@link HOLDING_MAPPERS} reads, and a row with both cells filled is refused
19
+ * rather than resolved: it would mean the scraper had changed shape, and
20
+ * picking one would hide that.
21
+ *
22
+ * ## `Shank Size` is the bore, and a bare cell is inches
23
+ *
24
+ * MariTool publishes no `D1`. Its shrink-fit and hydraulic holders state the
25
+ * shank they take under the vendor's own `Shank Size` label, which
26
+ * `scrape.holderRow` carries verbatim and unsuffixed — the adapter deliberately
27
+ * does not promote it, because a promoted column carries a unit suffix and
28
+ * these families declare no unit for one to be taken from.
29
+ *
30
+ * The cell takes three shapes across the 293 non-collet holders in the catalog,
31
+ * and {@link parseShankSize} reads all three: `.500`, `10mm`, and
32
+ * `.1181 (3mm)` — a decimal inch with the vendor's own metric annotation beside
33
+ * it. **A cell that names no unit is inches**, which is a claim about this
34
+ * vendor and worth the evidence: every bare value published is a fractional
35
+ * inch size (`.125`, `.1875`, `.250`, `.3125`, `.375`, `.4375`, `.500`, `.625`,
36
+ * `.750`, `1.0`, `1.25`, `1.5`), and the annotated form proves the reading —
37
+ * `.1181` is 3 mm in inches, to four places. One part in the catalog states a
38
+ * bare inch shank on a holder gaged in millimetres (`HSK40E-SF.125-45`), which
39
+ * is converted rather than warned about: the record's unit is itself promoted
40
+ * off another cell here, so the two disagreeing is this vendor's shape rather
41
+ * than a fault.
42
+ *
43
+ * ## Two published columns this does not carry
44
+ *
45
+ * - **`Nose Diameter`**, on 200-odd parts. It measures the holder's nose and
46
+ * `bodyDiameter` is `D2`, the body — promoting one into a field named for the
47
+ * other is precisely the collision `conventions` warns about at length. It
48
+ * also mixes `.870` with `.870 inches` in one column, which is a second
49
+ * reason to leave it as the receipt of what the vendor said.
50
+ * - **`Collet Grip Range`.** `scrape.holderRow` says why: it is the ER series'
51
+ * range restated on the holder's page, a pure function of `Collet Size`, and
52
+ * a real capacity comes from a collet family joined on `CST`.
53
+ */
54
+ import { CAD_COLUMN, CAD_DXF_COLUMN, COLLET_SERIES_COLUMN, CONTACT_COLUMN, DESCRIPTION_COLUMN, GAGE_COLUMNS, } from '../../conventions.js';
55
+ import { IncompletePartError, VendorResponseError } from '../../errors.js';
56
+ import { familyBrand } from '../../family.js';
57
+ import { asUnit, clampingMode, contactMode, holderRecord, published, } from '../../holding.js';
58
+ import { fractionValue } from '../../measure.js';
59
+ import { CLAMPING_COLUMN, MATERIAL_COLUMN, STYLE_COLUMN, TAPER_COLUMN } from './scrape.js';
60
+ /** MariTool's own label for the cell {@link parseShankSize} reads a bore from. */
61
+ export const SHANK_SIZE_LABEL = 'Shank Size';
62
+ /**
63
+ * MariTool's own label for the lock-nut diameter — `D11`, on five parts.
64
+ *
65
+ * Carried where `Nose Diameter` is not, because this one names exactly what the
66
+ * record's field names: the outside diameter of the collet nut, which is what
67
+ * decides whether a holder clears a fixture.
68
+ */
69
+ export const COLLET_NUT_DIAMETER_LABEL = 'Collet Nut Outside Diameter';
70
+ /**
71
+ * A `Shank Size` cell: a decimal, an optional unit, an optional annotation.
72
+ *
73
+ * The annotation is captured only so it can be dropped — it is the vendor's own
74
+ * conversion of the same dimension (`.1181 (3mm)`), not a second measurement,
75
+ * and reading it would put two sizes on one holder.
76
+ *
77
+ * `inches` leads the unit alternation because a regex alternation is ordered and
78
+ * `in` would otherwise match the first two letters of `inches` and leave `ches`
79
+ * unmatched — the same trap `parseGageLength` documents.
80
+ */
81
+ const SHANK_SIZE = /^(?<value>\d*\.?\d+)\s*(?<unit>mm|inches|inch|in)?\s*(?:\([^)]*\))?$/i;
82
+ /** One cell as a diameter and the system it is stated in. Null where unreadable. */
83
+ export function parseShankSize(cell) {
84
+ const parsed = SHANK_SIZE.exec(cell.trim())?.groups;
85
+ if (parsed === undefined)
86
+ return null;
87
+ const value = fractionValue(parsed['value'] ?? '');
88
+ if (value === null || value <= 0)
89
+ return null;
90
+ const unit = (parsed['unit'] ?? '').toLowerCase();
91
+ return { value, stated: unit === 'mm' ? 'millimeters' : 'inches' };
92
+ }
93
+ /** How a part names itself in a warning or a refusal. */
94
+ function subject(row) {
95
+ return `${row[MATERIAL_COLUMN] ?? ''} (${row[DESCRIPTION_COLUMN] ?? ''})`;
96
+ }
97
+ /** The gage length and, with it, the unit system this record is in. */
98
+ function gage(row, what) {
99
+ const inches = fractionValue(row[GAGE_COLUMNS.inches] ?? '');
100
+ const millimeters = fractionValue(row[GAGE_COLUMNS.millimeters] ?? '');
101
+ if (inches !== null && millimeters !== null) {
102
+ throw new VendorResponseError(what, `publishes a gage length in both unit columns (${inches} in and ` +
103
+ `${millimeters} mm) — the scrape fills exactly one, and which one is ` +
104
+ `filled is the only unit system this vendor states per part`);
105
+ }
106
+ if (inches !== null)
107
+ return { unit: 'inches', gaugeLength: inches };
108
+ if (millimeters !== null)
109
+ return { unit: 'millimeters', gaugeLength: millimeters };
110
+ throw new IncompletePartError(what, 'publishes no gage length in either unit column');
111
+ }
112
+ /** One of MariTool's own unsuffixed diameter cells, in `unit`. */
113
+ function diameter(row, label, unit, what) {
114
+ const cell = (row[label] ?? '').trim();
115
+ if (cell === '')
116
+ return null;
117
+ const measured = parseShankSize(cell);
118
+ if (measured === null) {
119
+ throw new VendorResponseError(what, `${label} is ${JSON.stringify(cell)}, which is not a diameter this ` +
120
+ `package can read — add its shape to SHANK_SIZE once it is clear what ` +
121
+ `the vendor means by it`);
122
+ }
123
+ return asUnit(measured.value, measured.stated, unit);
124
+ }
125
+ /** One MariTool holder row -> one {@link HolderRecord}. */
126
+ function holder(row, family) {
127
+ const what = subject(row);
128
+ const { unit, gaugeLength } = gage(row, what);
129
+ const material = published(row[MATERIAL_COLUMN], what, 'part number');
130
+ return holderRecord({
131
+ brand: familyBrand(family),
132
+ materialNumber: material,
133
+ // MariTool publishes one number per part and no second catalog designation
134
+ // — `conventions.IDENTITY_DEVIATIONS` records why, and inventing one here
135
+ // would put a column in the record the vendor does not publish. The part
136
+ // number is what a human orders by, so it is both.
137
+ catalogNumber: material,
138
+ description: row[DESCRIPTION_COLUMN] ?? '',
139
+ unit,
140
+ // One part in the catalog, `BT40-ER32-60`, publishes no `Taper` cell at all,
141
+ // and `scrape.holderRow` leaves both columns empty rather than inferring one
142
+ // from the part number. It becomes no record and a warning, which is the
143
+ // same call a cutting tool with no overall length gets.
144
+ taper: published(row[TAPER_COLUMN], what, 'taper'),
145
+ contact: contactMode(published(row[CONTACT_COLUMN], what, 'contact mode'), what),
146
+ clamping: clampingMode(published(row[CLAMPING_COLUMN], what, 'clamping mode'), what),
147
+ style: published(row[STYLE_COLUMN], what, 'style'),
148
+ colletSeries: row[COLLET_SERIES_COLUMN] || null,
149
+ bore: diameter(row, SHANK_SIZE_LABEL, unit, what),
150
+ gaugeLength,
151
+ lockNutDiameter: diameter(row, COLLET_NUT_DIAMETER_LABEL, unit, what),
152
+ cadModelUrl: row[CAD_COLUMN] || null,
153
+ cadDxfUrl: row[CAD_DXF_COLUMN] || null,
154
+ });
155
+ }
156
+ /**
157
+ * The toolholding half of the adapter contract `registry` looks up by brand.
158
+ *
159
+ * Holders only: MariTool sells collets and this package does not scrape them,
160
+ * so there is no `collet` mapper and `registry.toHolding` refuses a request for
161
+ * one naming what this brand does map. A partial table is the honest state for
162
+ * a kind nobody has read the columns of.
163
+ */
164
+ export const HOLDING_MAPPERS = { holder };
@@ -7,4 +7,5 @@
7
7
  * `docs/MARITOOL_CATALOG.md`.
8
8
  */
9
9
  export * from './catalog.js';
10
+ export * from './holding.js';
10
11
  export * from './scrape.js';
@@ -7,4 +7,5 @@
7
7
  * `docs/MARITOOL_CATALOG.md`.
8
8
  */
9
9
  export * from './catalog.js';
10
+ export * from './holding.js';
10
11
  export * from './scrape.js';
@@ -26,11 +26,16 @@
26
26
  *
27
27
  * ## What the vendor gets wrong
28
28
  *
29
- * Four faults found on 2026-08-29, all reported as warnings rather than
29
+ * Four faults found on 2026-08-29. Three are reported as warnings rather than
30
30
  * fixed — two disagreeing vendor cells cannot say which one is wrong, and a
31
31
  * scraper that corrects one becomes a place tool data is authored by hand.
32
32
  * This is the same call `vendors/regofix/scrape.ts` made on its three.
33
33
  *
34
+ * The fourth is resolved rather than reported, and the difference is where the
35
+ * answer came from: `docs/ADDING-A-VENDOR.md` says that when a vendor label is
36
+ * unclear you **ask**, and record the answer and its date. That is what
37
+ * happened — see fault 2.
38
+ *
34
39
  * 1. **`BT40-ER32-60` publishes no `Taper` row at all**, alone among the 529
35
40
  * parts in scope. Its row is kept with `taper` and `contact` empty rather
36
41
  * than dropped or filled in from its part number: the CSV is a receipt, and
@@ -38,10 +43,21 @@
38
43
  * 2. **`Collet Size` carries a collet *nut* designation on two parts.**
39
44
  * `CAT40-ER25-3.0MD` and `BT30-ER25-60M` both state `ER25M`, and `ER25M` is
40
45
  * not a collet series — `HSK40E-ER16-3.0M` puts exactly that shape of value
41
- * in its own `Collet Nut` cell, which is the column it belongs in. The
42
- * string is written into `CST` as designated, so it joins to no collet
43
- * family, and it is warned about. Widening it to `ER25` would offer a
44
- * machinist a collet that may not seat.
46
+ * in its own `Collet Nut` cell, which is the column it belongs in.
47
+ *
48
+ * **`ER25M` is the mini collet nut series, and the collet it closes is a
49
+ * plain ER25** (JG 2026-09-02). So {@link colletSeries} resolves the cell to
50
+ * the collet the holder actually takes, and the two parts join to the ER25
51
+ * collets they fit. Until that answer existed the string was written through
52
+ * as designated and warned about, because widening a series on a guess is
53
+ * how a machinist is offered a collet that does not seat — and the cost of
54
+ * being wrong the other way was one option, not a purchase.
55
+ *
56
+ * **Nothing is lost by resolving it.** `Collet Size` is one of the vendor's
57
+ * own labels and is carried into the CSV verbatim, so the receipt still says
58
+ * `ER25M` and still says which of the two parts has a mini nut. What changes
59
+ * is only `CST`, which is a derived join key rather than a record of what the
60
+ * vendor published — the same thing the spacing rule below does to it.
45
61
  * 3. **`Collet Size` is spaced inconsistently within one style** — `ER 11`
46
62
  * and `ER11` are both published. {@link colletSeries} closes the space,
47
63
  * because `CST` is a join key and two spellings of one series join to
@@ -254,15 +270,23 @@ export declare function parseGageLength(cell: string): GageLength;
254
270
  /**
255
271
  * A `Collet Size` cell as the series `CST` joins a collet family on.
256
272
  *
257
- * Only the spacing is closed. MariTool publishes `ER 11` and `ER11` within one
258
- * style, and two spellings of one series join to nothing `CST` is the key
259
- * `families/kennametal.ts` states the holder-to-collet join against, and the
260
- * collet side of it spells the series without a space.
273
+ * **Derived, not a receipt.** `CST` is the key `families/kennametal.ts` states
274
+ * the holder-to-collet join against, so what belongs in it is the series of the
275
+ * collet the holder takes. The vendor's own cell is carried into the CSV under
276
+ * its own label and untouched, which is where the two normalisations below are
277
+ * still visible.
278
+ *
279
+ * Two things are closed, and both exist because two spellings of one series
280
+ * join to nothing:
281
+ *
282
+ * - **Spacing.** MariTool publishes `ER 11` and `ER11` within one style, and
283
+ * the collet side of the join spells the series without a space.
284
+ * - **A nut designation.** `ER25M` names a mini nut, not a collet series — see
285
+ * {@link NUT_DESIGNATION} and this module's docstring.
261
286
  *
262
- * Nothing else is normalised. `ER25M` is written through as designated even
263
- * though no such collet series exists, because the alternative is to decide on
264
- * the vendor's behalf that its `M` is the mini nut its `Collet Nut` column
265
- * carries elsewhere — see this module's docstring. {@link holderRow} warns.
287
+ * Nothing else is. A cell this leaves as neither `ER<n>` nor a nut designation
288
+ * is written through as the vendor designated it, and {@link holderRow} warns
289
+ * that it joins to no collet.
266
290
  */
267
291
  export declare function colletSeries(cell: string): string;
268
292
  /**