From 9297409aefa4738a581046b53476e9e54b0f1ad6 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Sat, 12 Sep 2026 13:42:58 -0400 Subject: [PATCH] fix(tool-support): measure a Fusion holder by the shape it exported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autodesk defines a holder's `gaugeLength` as the height below the gauge line, so it is a reading of the segment stack rather than a fact standing beside it. The measured arm already worked that way — `belowGageLine` makes the cut and the last vertex is the answer — but a published holder was written with the vendor's own figure against a stack built from the vendor's dimensions, and on a V-flange holder those are two different lengths. REGO-FIX publishes `B4`, nose to gauge line, beside `B3`, nose to the flange face, and `B4 - B3` is 48.4 mm on every BT 30: the gauge-line-to-flange distance from the vendor's own standards table. `B3` is what `fromPublished` can draw, because no vendor publishes the shape of the flange above it. So a BT 30 collet chuck went out declared 98.4 mm below the gauge line and drawn 50 mm long, and `assemblyGaugeLength` — the stickout plus the holder's gauge length — put the tool 48.4 mm from the gauge line it was drawn against. The stack's own height is now what is exported, and the vendor's figure is reported instead: a `dropped` note naming both numbers where they disagree. `fromPublished` accumulates that height as it places the steps rather than summing it back off them, because a segment's height has already been converted into the export's unit and rounded. Where a vendor publishes no gauge length the holder no longer goes out without the key — its shape is fully drawn and its height therefore known — and a `filled` note says the number was read off the geometry. A `nose`-datumed profile still omits `gaugeLength`, because its silhouette is the whole holder rather than the part below the gauge line. Both fixtures covering this published a `projection` and a `gaugeLength` that were equal, so no test could tell the two readings apart, and the existing test asserted the identity as though the stack had proved it. One that does prove it is added: four of its five cases fail against the previous behaviour. --- .../tool-support-holder-gauge-length.md | 28 +++++ .../tool-support/src/export/fusion/holder.ts | 98 +++++++++++++---- .../tool-support/tests/export-fusion.test.ts | 100 +++++++++++++++++- 3 files changed, 201 insertions(+), 25 deletions(-) create mode 100644 .changeset/tool-support-holder-gauge-length.md diff --git a/.changeset/tool-support-holder-gauge-length.md b/.changeset/tool-support-holder-gauge-length.md new file mode 100644 index 0000000..bf45a43 --- /dev/null +++ b/.changeset/tool-support-holder-gauge-length.md @@ -0,0 +1,28 @@ +--- +'@toolpath/tool-support': minor +--- + +Derive a Fusion holder's `gaugeLength` from the shape that was exported, on the published arm as +well as the measured one. + +Autodesk defines the holder's gauge length as the height _below the gauge line_, so it is a +reading of the segment stack rather than a fact standing beside it. The measured arm already +worked that way — `belowGageLine` makes the cut and the last vertex is the answer — but a +published holder was written with the vendor's own figure against a stack built from the vendor's +dimensions, and on a V-flange holder those are two different lengths. REGO-FIX publishes `B4`, +nose to gauge line, beside `B3`, nose to the flange face, and `B4 - B3` is 48.4 mm on every BT 30: +the gauge-line-to-flange distance from the vendor's own standards table. `B3` is what +`fromPublished` can draw, because no vendor publishes the shape of the flange above it. So a +BT 30 collet chuck went out declared 98.4 mm below the gauge line and drawn 50 mm long, and +`assemblyGaugeLength` — the stickout plus the holder's gauge length — put the tool 48.4 mm from +the gauge line it was drawn against. + +The stack's own height is now the exported figure, and the vendor's is reported instead: a +`dropped` note on `holder.gaugeLength` naming both numbers where they disagree. Where a vendor +publishes no gauge length the holder no longer goes out without the key — its shape is fully +drawn and its height therefore known — and a `filled` note says the number was read off the +geometry. A `nose`-datumed profile still omits `gaugeLength` entirely, because its silhouette is +the whole holder rather than the part below the gauge line. + +Both fixtures that covered this published a `projection` and a `gaugeLength` that were equal, so +no test could tell the two readings apart. One that does is added. diff --git a/packages/tool-support/src/export/fusion/holder.ts b/packages/tool-support/src/export/fusion/holder.ts index 225c65c..ed57730 100644 --- a/packages/tool-support/src/export/fusion/holder.ts +++ b/packages/tool-support/src/export/fusion/holder.ts @@ -15,6 +15,21 @@ * the whole format with `additionalProperties: false`, so there is nowhere to * annotate one. * + * ## The gauge length is the stack's own height + * + * Because it is defined as the height *below the gauge line*, it is a reading of + * the geometry rather than a fact standing beside it. So both arms derive it + * from what they actually exported, and neither states a figure it did not draw: + * a document claiming one number while drawing another leaves Fusion to + * reconcile the two silently, and a machinist to find out at the spindle. + * + * A vendor's own figure can disagree, and on a V-flange holder it usually does. + * REGO-FIX publishes `B4`, nose to gauge line, beside `B3`, nose to the flange + * face, and `B4 - B3` is 48.4 mm on every BT 30 — the gauge-line-to-flange + * distance in the vendor's own standards table. `B3` is the one + * {@link fromPublished} can draw, because no vendor publishes the shape of the + * flange above it. That difference is reported rather than exported. + * * ## Why the measured arm is the easy one * * A CAT40 model is measured whole, and half of what comes back is the 7:24 cone @@ -137,49 +152,71 @@ const fromProfile = (profile: HolderProfile, unit: UnitSystem): FusionSegment[] return segments } +/** A published holder's stack, and how tall it stands in millimetres. */ +interface PublishedStack { + readonly segments: FusionSegment[] + readonly reach: number +} + /** - * A published holder as segments, nose first. + * A published holder as segments, nose first, and how far they reach. * * The layer model `holderSilhouette` already states — the nose, the body behind * it where the vendor states one, the flange at its projection — read from the * nose rather than from the tool tip. A vendor that publishes no nose diameter * has published no shape, and nothing is drawn for it. + * + * `reach` is accumulated as the steps are placed rather than summed back off + * them: a segment's height has already been converted into the export's unit and + * rounded, so adding those up would put a conversion and six decimal places + * between the stack and the number that is supposed to measure it. */ -const fromPublished = (holder: Holder, unit: UnitSystem): FusionSegment[] => { +const fromPublished = (holder: Holder, unit: UnitSystem): PublishedStack => { const { noseDiameter, noseLength, bodyDiameter, bodyLength, flangeDiameter, projection } = holder - if (noseDiameter === null) return [] + if (noseDiameter === null) return { segments: [], reach: 0 } const mm = (value: number) => exported(convertLength(value, 'millimeters', unit)) - const step = (height: number, diameter: number): FusionSegment | null => - height > 0 - ? { height: mm(height), 'lower-diameter': mm(diameter), 'upper-diameter': mm(diameter) } - : null - const segments: FusionSegment[] = [] - const nose = step(noseLength ?? 0, noseDiameter) - if (nose !== null) segments.push(nose) + let reach = 0 + const step = (height: number, diameter: number): void => { + if (!(height > 0)) return + segments.push({ + height: mm(height), + 'lower-diameter': mm(diameter), + 'upper-diameter': mm(diameter), + }) + reach += height + } + step(noseLength ?? 0, noseDiameter) if (bodyDiameter !== null) { - const body = step(bodyLength ?? 0, bodyDiameter) - if (body !== null) segments.push(body) + step(bodyLength ?? 0, bodyDiameter) } // The flange stands at the holder's projection, so what is left between the // steps already placed and that plane is where it starts. if (flangeDiameter !== null && projection !== null) { const placed = (noseLength ?? 0) + (bodyDiameter !== null ? (bodyLength ?? 0) : 0) - const flange = step(projection - placed, flangeDiameter) - if (flange !== null) segments.push(flange) + step(projection - placed, flangeDiameter) } - return segments + return { segments, reach } } +/** + * Finer than the six places {@link exported} keeps, so only a disagreement a + * vendor actually published reads as one. + */ +const GAUGE_EPSILON = 1e-6 + /** * A holder as Fusion holds one, and an account of what did not travel. * - * `gaugeLength` is written only where it is known: the last vertex's `z` on a - * `gage-line` profile, the vendor's own figure on a published holder, and - * nothing at all on a `nose`-datumed profile. + * `gaugeLength` is written only where the exported shape measures it: the last + * vertex's `z` on a `gage-line` profile, the height of the stack on a published + * holder, and nothing at all on a `nose`-datumed profile, whose silhouette is + * the whole holder — taper, retention knob and all — rather than the part below + * the gauge line. Omitting the key there puts Fusion into manual mode, which is + * the honest answer; a zero would read as a measurement. */ export const fusionHolder = (entry: CatalogHolder): HolderResult => { const { holder, unit, guid } = entry @@ -189,7 +226,9 @@ export const fusionHolder = (entry: CatalogHolder): HolderResult => { } const measured = isHolderProfile(holder) - const segments = measured ? fromProfile(holder, unit) : fromPublished(holder, unit) + const profile = measured ? fromProfile(holder, unit) : null + const published = measured ? null : fromPublished(holder, unit) + const segments = profile ?? published?.segments ?? [] if (segments.length === 0) { note( @@ -215,10 +254,23 @@ export const fusionHolder = (entry: CatalogHolder): HolderResult => { 'Fusion is left to ask for the gauge length rather than shown a guess', ) } - } else { - gaugeLength = holder.gaugeLength - if (gaugeLength === null) { - note('dropped', 'holder.gaugeLength', 'the vendor publishes no gauge length') + } else if (published !== null) { + gaugeLength = published.reach + const stated = holder.gaugeLength + if (stated === null) { + note( + 'filled', + 'holder.gaugeLength', + 'the vendor publishes no gauge length, so this is the height of the shape exported', + ) + } else if (Math.abs(stated - published.reach) > GAUGE_EPSILON) { + note( + 'dropped', + 'holder.gaugeLength', + `the vendor states ${exported(stated)} mm below the gauge line and its published ` + + `dimensions draw ${exported(published.reach)} mm of holder — Fusion is given the ` + + 'height it can measure rather than one it cannot draw', + ) } } diff --git a/packages/tool-support/tests/export-fusion.test.ts b/packages/tool-support/tests/export-fusion.test.ts index 576922d..9d518b0 100644 --- a/packages/tool-support/tests/export-fusion.test.ts +++ b/packages/tool-support/tests/export-fusion.test.ts @@ -121,8 +121,10 @@ describe('a tool with a holder', () => { it('runs the holder from the nose toward the machine', () => { // Autodesk states it on the segment array: ordered from the cutter end to - // the machine tool. So the nose is first, and the heights sum to the gauge - // length because the stack stops at the gauge line. + // the machine tool, so the nose is first. The heights sum to the gauge + // length because the gauge length *is* that sum — this fixture's vendor + // happens to publish the same figure, and `a published holder that does not + // reach its own gauge line` is the case where one does not. const segments = (written.tool as FusionTool).holder?.segments ?? [] expect(segments[0]?.['lower-diameter']).toBe(33) expect(segments.reduce((total, segment) => total + segment.height, 0)).toBe(50) @@ -273,6 +275,100 @@ describe('a measured holder', () => { }) }) +describe('a published holder that does not reach its own gauge line', () => { + /** + * A BT 30 collet chuck, published the way DIN 4000 publishes one. + * + * `projection` is nose to the flange face and `gaugeLength` is nose to the + * gauge line, and on a BT 30 those differ by 48.4 mm — the gauge-line-to- + * flange distance, which is a property of the taper rather than of the part. + * The 48.4 mm above the flange face is real holder, and no vendor publishes + * its shape, so the stack this exporter can draw stops short of the figure + * the vendor states. + */ + const chuck: Holder = { + noseDiameter: 10, + noseLength: 10.55, + bodyDiameter: 12.02, + bodyLength: 9.6, + projection: 50, + flangeDiameter: 46, + gaugeLength: 98.4, + colletSeries: 'PG 6', + colletProtrusion: null, + } + + const held = (holder: Holder): FusionTool => { + const written = fusionTool({ + tool: endMill, + assembly: { + stickout: 24, + holder: { guid: catalogHolder.guid, holder, unit: 'millimeters' }, + }, + }) + return written.tool as FusionTool + } + + it('states the height it drew, not the length it could not draw', () => { + const tool = held(chuck) + const segments = tool.holder?.segments ?? [] + expect(segments.reduce((total, segment) => total + segment.height, 0)).toBe(50) + expect(tool.holder?.gaugeLength).toBe(50) + }) + + it('measures the assembly from the same place it measured the holder', () => { + // A document whose assembly gauge length was built from the vendor's figure + // and whose holder was built from the vendor's dimensions would put the tool + // 48.4 mm from the gauge line it is drawn against. + expect(held(chuck).geometry.assemblyGaugeLength).toBe(74) + }) + + it('reports the difference rather than exporting it', () => { + const written = fusionTool({ + tool: endMill, + assembly: { + stickout: 24, + holder: { guid: catalogHolder.guid, holder: chuck, unit: 'millimeters' }, + }, + }) + const dropped = written.notes.find( + (note) => note.kind === 'dropped' && note.field === 'holder.gaugeLength', + ) + expect(dropped?.message).toContain('98.4') + expect(dropped?.message).toContain('50') + }) + + it('says nothing where the vendor figure and the stack agree', () => { + const reaching: Holder = { ...chuck, gaugeLength: 50 } + const written = fusionTool({ + tool: endMill, + assembly: { + stickout: 24, + holder: { guid: catalogHolder.guid, holder: reaching, unit: 'millimeters' }, + }, + }) + expect(written.notes.filter((note) => note.field === 'holder.gaugeLength')).toHaveLength(0) + }) + + it('still gives Fusion a gauge length where the vendor publishes none', () => { + // This used to leave the key off, which puts Fusion into manual mode for a + // holder whose shape is fully drawn and whose height is therefore known. + const unstated: Holder = { ...chuck, gaugeLength: null } + const written = fusionTool({ + tool: endMill, + assembly: { + stickout: 24, + holder: { guid: catalogHolder.guid, holder: unstated, unit: 'millimeters' }, + }, + }) + expect((written.tool as FusionTool).holder?.gaugeLength).toBe(50) + const filled = written.notes.find( + (note) => note.kind === 'filled' && note.field === 'holder.gaugeLength', + ) + expect(filled).toBeDefined() + }) +}) + describe('the document and its text', () => { const { document, notes } = fusionLibrary({ tools: [{ tool: endMill, assembly: { stickout: 24, holder: catalogHolder } }],