@toolpath/tool-support 0.1.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * What holds a tool, and the stack the two make together.
3
+ *
4
+ * ## The holder is a union, not one shape with optional extras
5
+ *
6
+ * A holder arrives in one of two forms and they are alternatives rather than a
7
+ * refinement of one by the other: {@link Holder} is what a vendor *publishes*
8
+ * about it, and {@link HolderProfile} is what its own CAD model *measures*.
9
+ * {@link isHolderProfile} tells them apart. A consumer that has both picks one;
10
+ * a consumer that has neither passes `null` and the tool stands alone.
11
+ *
12
+ * ## What is here and what is not
13
+ *
14
+ * Three shapes in this tree called themselves a holder and no two agreed on
15
+ * which fields exist — sixteen, nine and nineteen. What they *did* agree on is
16
+ * the geometry below, which is also exactly what a drawing and a clearance
17
+ * sweep read. Identity and commerce — a guid, a brand, a catalog number, the
18
+ * vendor's own CAD download — belong to a catalog's record, which extends this.
19
+ *
20
+ * ## How a holder grips is optional, and the absence is load bearing
21
+ *
22
+ * {@link Holder.clamping}, {@link Holder.boreDiameter} and {@link Holder.taper}
23
+ * arrived with the functions that read them, and they are optional because a
24
+ * drawing has never needed them: a consumer that hands over nine numbers to get
25
+ * a picture must not have to invent a clamping mode to do it.
26
+ *
27
+ * **Absent means nobody has said, and nobody-has-said refuses.** A holder that
28
+ * does not state how it clamps takes no tool, offers no grip range and matches
29
+ * no taper. That is the same rule {@link holderTakesTool} applies to a tool
30
+ * with no stated shank, and for the same reason: the unchecked case here is a
31
+ * cutter falling out of a spindle.
32
+ */
33
+ import { DEFAULT_STICKOUT_POLICY, stickoutRange, } from './stickout.js';
34
+ import { DEFAULT_CLAMPING } from './clamping.js';
35
+ /**
36
+ * Which of the two holder forms this is.
37
+ *
38
+ * On the presence of `points` rather than on a `kind` tag, because a tag would
39
+ * have to be added to {@link Holder} as well and every existing adapter would
40
+ * stop compiling to gain nothing a structural check does not already give.
41
+ */
42
+ export const isHolderProfile = (holder) => 'points' in holder;
43
+ /**
44
+ * A collet fits a holder when the holder takes collets of exactly its series.
45
+ *
46
+ * A series is a mechanical interface and not a size class: an `ER16` collet
47
+ * does not go in an `ER20` nose.
48
+ */
49
+ export const colletFitsHolder = (collet, holder) => holder.clamping === 'collet' && holder.colletSeries === collet.series;
50
+ /**
51
+ * A hair of tolerance, because 3/8" is 9.525 on the collet's sheet and
52
+ * 9.524999999999999 on the tool's after a conversion. Strict, 350 tools in the
53
+ * scraped catalog had no collet in the crib.
54
+ */
55
+ const GRIP_TOLERANCE = 1e-6;
56
+ /** Whether a collet grips a given shank diameter, in millimetres. */
57
+ export const gripsShank = (collet, shank) => shank >= collet.clampMin - GRIP_TOLERANCE && shank <= collet.clampMax + GRIP_TOLERANCE;
58
+ /**
59
+ * Whether a bore, shrink or hydraulic holder's one diameter is this shank.
60
+ *
61
+ * The same hair of tolerance {@link gripsShank} carries, for the same reason: a
62
+ * ½" bore is 12.7 on the holder's sheet and 12.699999999999999 on the tool's
63
+ * after a conversion. Exact, an inch holder matches an inch tool only by luck.
64
+ */
65
+ const boreTakesShank = (bore, shank) => Math.abs(bore - shank) <= GRIP_TOLERANCE;
66
+ /**
67
+ * Whether a holder takes this tool's shank, with the collet if it needs one.
68
+ *
69
+ * **A tool whose shank the vendor does not state is refused, not assumed to
70
+ * fit.** This is the one place the domain differs from "what is not stated is
71
+ * not checked", because here the unchecked case is a cutter falling out of a
72
+ * spindle.
73
+ */
74
+ export const holderTakesTool = (holder, collet, tool) => {
75
+ const shank = tool.geometry.SFDM;
76
+ if (shank === undefined) {
77
+ return false;
78
+ }
79
+ if (holder.clamping === 'collet') {
80
+ return collet !== null && colletFitsHolder(collet, holder) && gripsShank(collet, shank);
81
+ }
82
+ // A holder that states no clamping mode falls here and is refused: absent
83
+ // means nobody has said, and nobody-has-said takes no tool.
84
+ if (collet !== null || holder.boreDiameter === null || holder.boreDiameter === undefined) {
85
+ return false;
86
+ }
87
+ return boreTakesShank(holder.boreDiameter, shank);
88
+ };
89
+ /**
90
+ * The furthest a tool can stand out of its holder, in millimetres: overall
91
+ * length less the length that has to stay gripped.
92
+ *
93
+ * `null` when either is unstated. A maximum stickout is exactly the number
94
+ * somebody would use to decide a deep pocket is reachable, and a guessed one is
95
+ * worse than an absent one. A bore or shrink holder's grip length is the
96
+ * holder's rather than a collet's, and this package does not carry it — so
97
+ * those answer `null` too, honestly, until the contract gains it.
98
+ */
99
+ export const maxStickout = (tool, collet) => {
100
+ const overall = tool.geometry.OAL;
101
+ if (overall === undefined || collet === null || collet.clampLength === null) {
102
+ return null;
103
+ }
104
+ const stickout = overall - collet.clampLength;
105
+ return stickout > 0 ? stickout : null;
106
+ };
107
+ /**
108
+ * How well the holder has hold of the tool at this stickout, by the share of
109
+ * the overall length left in the holder.
110
+ *
111
+ * At or above `good` is good; between `least` and that is possible but bad;
112
+ * below `least` is not compatible. The thresholds are a shop's, handed in as
113
+ * fractions rather than named here, because they are the same knob
114
+ * {@link StickoutPolicy.heldShare} is and a package must not carry two.
115
+ */
116
+ export const holdBand = (tool, stickout, thresholds) => {
117
+ const { OAL } = tool.geometry;
118
+ if (OAL === undefined || OAL <= 0) {
119
+ return null;
120
+ }
121
+ const held = (OAL - stickout) / OAL;
122
+ return held >= thresholds.good - 1e-9
123
+ ? 'good'
124
+ : held >= thresholds.least - 1e-9
125
+ ? 'medium'
126
+ : 'bad';
127
+ };
128
+ /**
129
+ * How far this tool may stand out of this holder — the collet-shaped way into
130
+ * {@link stickoutRange}.
131
+ *
132
+ * **The arithmetic is not here.** This was one of the four places that worked
133
+ * out a stickout, and the one that capped at a share of the overall length
134
+ * while the clamping rule capped at a length of shank and neither knew about
135
+ * the other. `stickout.ts` owns the quantity and combines the two knobs in one
136
+ * place; this maps a collet onto the grip length that module asks for, which is
137
+ * all a collet was ever contributing.
138
+ */
139
+ export const stickoutLimits = (tool, collet,
140
+ /** What the holder needs to clear the part, from the sweep: the setup stands out at least this far. */
141
+ required = null, policy = DEFAULT_STICKOUT_POLICY, rule = DEFAULT_CLAMPING) => stickoutRange(tool, { grip: collet?.clampLength ?? null, required, policy, rule });
142
+ /**
143
+ * The stickout an assembly starts at: the setup length for this tool, held
144
+ * within what the grip allows.
145
+ *
146
+ * A tool whose setup outruns its grip is gripped as short as the grip lets it
147
+ * and no shorter — rather than refused, because the shop is the one who knows
148
+ * whether that is a problem.
149
+ */
150
+ export const defaultStickout = (tool, collet) => stickoutLimits(tool, collet)?.setup ?? maxStickout(tool, collet);
151
+ /** `taper` narrows to one spindle interface, `colletSeries` to one collet family; either left out means "any". */
152
+ export const gripRanges = (holders, collets, want = {}) => {
153
+ const spans = [];
154
+ const bores = [];
155
+ for (const holder of holders) {
156
+ if (want.taper && holder.taper !== want.taper) {
157
+ continue;
158
+ }
159
+ if (holder.clamping === 'collet') {
160
+ for (const collet of collets) {
161
+ if (want.colletSeries && collet.series !== want.colletSeries) {
162
+ continue;
163
+ }
164
+ if (!colletFitsHolder(collet, holder)) {
165
+ continue;
166
+ }
167
+ spans.push([collet.clampMin, collet.clampMax]);
168
+ }
169
+ continue;
170
+ }
171
+ // A bore or shrink holder takes one nominal diameter, so it can never
172
+ // satisfy a request for a particular collet series.
173
+ if (want.colletSeries) {
174
+ continue;
175
+ }
176
+ if (holder.boreDiameter !== null && holder.boreDiameter !== undefined) {
177
+ bores.push(holder.boreDiameter);
178
+ }
179
+ }
180
+ return { spans, bores };
181
+ };
182
+ /**
183
+ * Whether anything in {@link gripRanges} holds this shank, in millimetres.
184
+ *
185
+ * **Through {@link gripsShank} and {@link boreTakesShank}, not a second
186
+ * comparison.** This is the fast filter and `holderTakesTool` is the exact
187
+ * check, and the two must agree: asked strictly, a ⅜" shank that converts to
188
+ * 9.524999999999999 misses a collet whose sheet says 9.525, so the crib reports
189
+ * no holder for a tool the holder plainly takes. That is the failure the
190
+ * tolerance above was introduced for, and it belongs on both paths.
191
+ */
192
+ export const gripsAnyShank = (ranges, shank) => ranges.spans.some(([clampMin, clampMax]) => gripsShank({ clampMin, clampMax }, shank)) ||
193
+ ranges.bores.some((bore) => boreTakesShank(bore, shank));
194
+ /**
195
+ * Whether the crib can hold this tool at all.
196
+ *
197
+ * A tool whose shank the vendor does not state is refused, for the same reason
198
+ * {@link holderTakesTool} refuses it.
199
+ */
200
+ export const canHold = (ranges, tool) => {
201
+ const shank = tool.geometry.SFDM;
202
+ return shank !== undefined && gripsAnyShank(ranges, shank);
203
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The cutting-tool domain: what a tool, a holder, a collet and an assembly
3
+ * _are_, and the arithmetic that follows from them.
4
+ *
5
+ * This package takes no runtime dependencies and imports no React, no DOM, no
6
+ * `fs` and no Toolpath SDK. Everything else that speaks about cutting tools
7
+ * depends on it and it depends on nothing — which is what lets a Node ingest
8
+ * script, a server route and a React renderer share one answer instead of three.
9
+ *
10
+ * ## Why it exists
11
+ *
12
+ * A scraper produced tool data, a drawing consumed it, and every application in
13
+ * between re-derived what a tool assembly is. The same fact was declared three
14
+ * times over — two names for one unit constant, three for one unit vocabulary,
15
+ * three provenance types, two `PROFILES_VERSION`s compared against each other
16
+ * under an alias, and three shapes called "holder" of which no two agreed on
17
+ * which fields exist.
18
+ *
19
+ * That is not a tidiness complaint. How far a tool stands out of its holder was
20
+ * computed in four unconnected places and disagreed by a factor of two on an
21
+ * ordinary tool: a details table printed one number and the drawing beside it
22
+ * drew another, and the dimension line ran past the holder nose into the holder
23
+ * body. It was fixed inside one application, so the next consumer of the same
24
+ * two packages reproduces it from scratch. The quantity that went wrong is a
25
+ * pure function of the tool, the collet and a shop's policy, and it had no home.
26
+ *
27
+ * ## What is here
28
+ *
29
+ * The vocabulary and the contracts: units, provenance, the geometry dictionary,
30
+ * the form list, the holder union, the collet, the profile, the reach curve and
31
+ * what a feature demands of a tool. Everything is a readonly interface or a pure
32
+ * function over one — no classes, deliberately, because a class loses structural
33
+ * typing at a package boundary and `instanceof` breaks across duplicate installs.
34
+ *
35
+ * On them, the arithmetic that had been written more than once: {@link hasNeck}
36
+ * and {@link shankOf}, {@link heightAt} and {@link belowGageLine} each had two
37
+ * copies with a note beside each saying the two must agree and nothing watching
38
+ * whether they did — and {@link stickoutRange}, which had four.
39
+ *
40
+ * With those, the decisions that follow from them: what holds what
41
+ * ({@link holderTakesTool}, {@link gripRanges}), what a shop keeps clamped
42
+ * ({@link clampWanted}), whether a tool cuts a feature ({@link fitAgainst}),
43
+ * whether the whole stack clears the material around it ({@link clearance},
44
+ * {@link assemblyAgainst}), and the feature in section ({@link sectionOutline})
45
+ * that draws what the sweep checked.
46
+ */
47
+ export { MM_PER_INCH, UNIT_ABBREVIATION, UNIT_SYSTEMS, convertArea, convertLength, decimalsFor, formatArea, formatLength, type UnitSystem, } from './units.js';
48
+ export { PROVENANCE, type Provenance, type ProvenanceMap } from './provenance.js';
49
+ export { GEOMETRY_FIELDS, convertGeometry, geometryField, isLengthField, type Geometry, type GeometryCode, type GeometryField, type GeometryUnit, } from './geometry.js';
50
+ export { MILLING_FORMS, TOOL_FORMS, isToolForm, type ToolForm, type ToolFormEntry, } from './forms.js';
51
+ export { hasNeck, shankOf, type Shank, type Tool } from './tool.js';
52
+ export { PROFILES_VERSION, belowGageLine, type HolderProfile, type ProfileDatum, type ProfilePoint, } from './profile.js';
53
+ export { canHold, colletFitsHolder, defaultStickout, gripRanges, gripsAnyShank, gripsShank, holdBand, holderTakesTool, isHolderProfile, maxStickout, stickoutLimits, type Assembly, type Clamping, type Collet, type GripRanges, type HoldBand, type Holder, } from './holding.js';
54
+ export { DEFAULT_CLAMPING, clampShortfall, clampWanted, headLength, heldDiameter, type ClampingRule, } from './clamping.js';
55
+ export { DEFAULT_STICKOUT_POLICY, HELD_SHARE, minStickout, setupStickout, stickoutCeiling, stickoutRange, type StickoutLimit, type StickoutPolicy, type StickoutRange, type StickoutRequest, type StickoutTool, } from './stickout.js';
56
+ export { heightAt, type ReachCurve } from './reach.js';
57
+ export type { FeatureDemand } from './demand.js';
58
+ export { DRILLING_FORMS, fitAgainst, fitTools, type FitFailure, type ToolFit } from './fit.js';
59
+ export { ASSEMBLY_PARTS, NO_MARGINS, SILHOUETTE_PARTS, type AssemblyPart, type Margins, type Silhouette, type SilhouettePart, } from './parts.js';
60
+ export { materialProfile, type OutlinePoint } from './material.js';
61
+ export { clearance, describeCollision, holderSilhouette, toolCollisions, toolSilhouette, type Clearance, type Collision, type SweptAssembly, } from './clearance.js';
62
+ export { FLOOR_BAND, REACH, sectionOutline, type FeatureSection, type Section, type SectionKind, type SectionPoint, } from './section.js';
63
+ export { NOT_MODELLED, assemblyAgainst, type AssemblyFit } from './assembly-fit.js';
package/dist/index.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The cutting-tool domain: what a tool, a holder, a collet and an assembly
3
+ * _are_, and the arithmetic that follows from them.
4
+ *
5
+ * This package takes no runtime dependencies and imports no React, no DOM, no
6
+ * `fs` and no Toolpath SDK. Everything else that speaks about cutting tools
7
+ * depends on it and it depends on nothing — which is what lets a Node ingest
8
+ * script, a server route and a React renderer share one answer instead of three.
9
+ *
10
+ * ## Why it exists
11
+ *
12
+ * A scraper produced tool data, a drawing consumed it, and every application in
13
+ * between re-derived what a tool assembly is. The same fact was declared three
14
+ * times over — two names for one unit constant, three for one unit vocabulary,
15
+ * three provenance types, two `PROFILES_VERSION`s compared against each other
16
+ * under an alias, and three shapes called "holder" of which no two agreed on
17
+ * which fields exist.
18
+ *
19
+ * That is not a tidiness complaint. How far a tool stands out of its holder was
20
+ * computed in four unconnected places and disagreed by a factor of two on an
21
+ * ordinary tool: a details table printed one number and the drawing beside it
22
+ * drew another, and the dimension line ran past the holder nose into the holder
23
+ * body. It was fixed inside one application, so the next consumer of the same
24
+ * two packages reproduces it from scratch. The quantity that went wrong is a
25
+ * pure function of the tool, the collet and a shop's policy, and it had no home.
26
+ *
27
+ * ## What is here
28
+ *
29
+ * The vocabulary and the contracts: units, provenance, the geometry dictionary,
30
+ * the form list, the holder union, the collet, the profile, the reach curve and
31
+ * what a feature demands of a tool. Everything is a readonly interface or a pure
32
+ * function over one — no classes, deliberately, because a class loses structural
33
+ * typing at a package boundary and `instanceof` breaks across duplicate installs.
34
+ *
35
+ * On them, the arithmetic that had been written more than once: {@link hasNeck}
36
+ * and {@link shankOf}, {@link heightAt} and {@link belowGageLine} each had two
37
+ * copies with a note beside each saying the two must agree and nothing watching
38
+ * whether they did — and {@link stickoutRange}, which had four.
39
+ *
40
+ * With those, the decisions that follow from them: what holds what
41
+ * ({@link holderTakesTool}, {@link gripRanges}), what a shop keeps clamped
42
+ * ({@link clampWanted}), whether a tool cuts a feature ({@link fitAgainst}),
43
+ * whether the whole stack clears the material around it ({@link clearance},
44
+ * {@link assemblyAgainst}), and the feature in section ({@link sectionOutline})
45
+ * that draws what the sweep checked.
46
+ */
47
+ export { MM_PER_INCH, UNIT_ABBREVIATION, UNIT_SYSTEMS, convertArea, convertLength, decimalsFor, formatArea, formatLength, } from './units.js';
48
+ export { PROVENANCE } from './provenance.js';
49
+ export { GEOMETRY_FIELDS, convertGeometry, geometryField, isLengthField, } from './geometry.js';
50
+ export { MILLING_FORMS, TOOL_FORMS, isToolForm, } from './forms.js';
51
+ export { hasNeck, shankOf } from './tool.js';
52
+ export { PROFILES_VERSION, belowGageLine, } from './profile.js';
53
+ export { canHold, colletFitsHolder, defaultStickout, gripRanges, gripsAnyShank, gripsShank, holdBand, holderTakesTool, isHolderProfile, maxStickout, stickoutLimits, } from './holding.js';
54
+ export { DEFAULT_CLAMPING, clampShortfall, clampWanted, headLength, heldDiameter, } from './clamping.js';
55
+ export { DEFAULT_STICKOUT_POLICY, HELD_SHARE, minStickout, setupStickout, stickoutCeiling, stickoutRange, } from './stickout.js';
56
+ export { heightAt } from './reach.js';
57
+ export { DRILLING_FORMS, fitAgainst, fitTools } from './fit.js';
58
+ export { ASSEMBLY_PARTS, NO_MARGINS, SILHOUETTE_PARTS, } from './parts.js';
59
+ export { materialProfile } from './material.js';
60
+ export { clearance, describeCollision, holderSilhouette, toolCollisions, toolSilhouette, } from './clearance.js';
61
+ export { FLOOR_BAND, REACH, sectionOutline, } from './section.js';
62
+ export { NOT_MODELLED, assemblyAgainst } from './assembly-fit.js';
@@ -0,0 +1,29 @@
1
+ import type { ReachCurve } from './reach.js';
2
+ /**
3
+ * The material around a feature, as a drawing.
4
+ *
5
+ * The one reading of a reach curve with two consumers that are not both
6
+ * drawings: a clearance overlay draws the wall from it, and `section.ts` draws
7
+ * a feature section from it. It sat in an application because moving it into a
8
+ * rendering package would have put the second of those behind a dependency it
9
+ * has no use for; here it is behind nothing.
10
+ */
11
+ /** Radius out from the axis and height above the tip, both in millimetres. */
12
+ export interface OutlinePoint {
13
+ readonly r: number;
14
+ readonly z: number;
15
+ }
16
+ /**
17
+ * The material around the feature as a drawing beside the tool.
18
+ *
19
+ * The reach curve's offsets are from the wall of the cut, so each knot lands at
20
+ * `cuttingRadius + offset` across. **The staircase is the sweep's, exactly**:
21
+ * `heightAt` reads "material within h[i] rises to v[i]" as *every* offset up
22
+ * to the knot being that tall, so the rise comes at the start of each run,
23
+ * not at its end. Drawn the other way round — up at the knot, as this once
24
+ * was — the picture showed a nose clearing material the sweep had already
25
+ * failed it on, and a drawing that disagrees with its own verdict is worse
26
+ * than none. Past the last knot the material stays at the last height, which
27
+ * is the renderer's to extend to its edge.
28
+ */
29
+ export declare const materialProfile: (curve: ReachCurve, cuttingRadius: number) => Array<OutlinePoint>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The material around the feature as a drawing beside the tool.
3
+ *
4
+ * The reach curve's offsets are from the wall of the cut, so each knot lands at
5
+ * `cuttingRadius + offset` across. **The staircase is the sweep's, exactly**:
6
+ * `heightAt` reads "material within h[i] rises to v[i]" as *every* offset up
7
+ * to the knot being that tall, so the rise comes at the start of each run,
8
+ * not at its end. Drawn the other way round — up at the knot, as this once
9
+ * was — the picture showed a nose clearing material the sweep had already
10
+ * failed it on, and a drawing that disagrees with its own verdict is worse
11
+ * than none. Past the last knot the material stays at the last height, which
12
+ * is the renderer's to extend to its edge.
13
+ */
14
+ export const materialProfile = (curve, cuttingRadius) => {
15
+ const points = [{ r: cuttingRadius, z: 0 }];
16
+ const push = (point) => {
17
+ const last = points[points.length - 1];
18
+ if (!last || last.r !== point.r || last.z !== point.z) {
19
+ points.push(point);
20
+ }
21
+ };
22
+ let from = 0;
23
+ curve.horizontalOffset.forEach((offset, index) => {
24
+ const height = curve.verticalOffset[index] ?? 0;
25
+ push({ r: cuttingRadius + from, z: height });
26
+ push({ r: cuttingRadius + offset, z: height });
27
+ from = offset;
28
+ });
29
+ return points;
30
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What an assembly is made of, named once.
3
+ *
4
+ * Two vocabularies for these eight words stood in this tree: a drawing's
5
+ * `OutlinePart`, and a clearance sweep's `SilhouettePart`, which was the same
6
+ * list without the two cutting parts. They were not a duplicate by accident —
7
+ * the sweep genuinely does not check the cutting end, because the cutting end
8
+ * is what is cutting — but the *words* were, and a part renamed in one would
9
+ * have gone on meaning the old thing in the other.
10
+ *
11
+ * ## Two representations, one vocabulary
12
+ *
13
+ * A drawing needs a polyline per part; a sweep needs one radius from one height
14
+ * upward. Those are different shapes for different questions and neither
15
+ * projects onto the other, so both survive — {@link Silhouette} here and
16
+ * `OutlineSegment` in `@toolpath/tool-drawing`. What is shared is the naming.
17
+ */
18
+ /** Every part a drawn or swept assembly is made of, tip first. */
19
+ export declare const ASSEMBLY_PARTS: readonly ["tip", "flutes", "neck", "shank", "collet", "nose", "body", "flange"];
20
+ export type AssemblyPart = (typeof ASSEMBLY_PARTS)[number];
21
+ /**
22
+ * The parts a clearance sweep checks: everything but the cutting end.
23
+ *
24
+ * Derived from {@link ASSEMBLY_PARTS} rather than listed again, so a part added
25
+ * there is swept unless it is deliberately excluded here. The tip and the
26
+ * flutes are excluded because they are the cut — material at the cutting radius
27
+ * is what the tool is there to remove.
28
+ */
29
+ export declare const SILHOUETTE_PARTS: readonly Exclude<AssemblyPart, "tip" | "flutes">[];
30
+ export type SilhouettePart = (typeof SILHOUETTE_PARTS)[number];
31
+ /**
32
+ * One step of an assembly's profile: this radius, from this height above the
33
+ * tip.
34
+ *
35
+ * **A radius from a height upward**, not a band: the last stated diameter
36
+ * carries itself up to the next step. That is the layer model a reach curve is
37
+ * swept against, and it is why nothing has to be invented for the shape between
38
+ * a holder's body and its flange.
39
+ */
40
+ export interface Silhouette {
41
+ readonly part: SilhouettePart;
42
+ readonly radius: number;
43
+ readonly fromHeight: number;
44
+ }
45
+ /** Room the shop wants kept between the stack and the part, in millimetres. */
46
+ export interface Margins {
47
+ /**
48
+ * Widens every swept part, so it must clear the wall sideways by this much.
49
+ */
50
+ readonly radial: number;
51
+ /**
52
+ * Lifts the material, so a part must stand this far above what it clears.
53
+ */
54
+ readonly axial: number;
55
+ }
56
+ export declare const NO_MARGINS: Margins;
package/dist/parts.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What an assembly is made of, named once.
3
+ *
4
+ * Two vocabularies for these eight words stood in this tree: a drawing's
5
+ * `OutlinePart`, and a clearance sweep's `SilhouettePart`, which was the same
6
+ * list without the two cutting parts. They were not a duplicate by accident —
7
+ * the sweep genuinely does not check the cutting end, because the cutting end
8
+ * is what is cutting — but the *words* were, and a part renamed in one would
9
+ * have gone on meaning the old thing in the other.
10
+ *
11
+ * ## Two representations, one vocabulary
12
+ *
13
+ * A drawing needs a polyline per part; a sweep needs one radius from one height
14
+ * upward. Those are different shapes for different questions and neither
15
+ * projects onto the other, so both survive — {@link Silhouette} here and
16
+ * `OutlineSegment` in `@toolpath/tool-drawing`. What is shared is the naming.
17
+ */
18
+ /** Every part a drawn or swept assembly is made of, tip first. */
19
+ export const ASSEMBLY_PARTS = [
20
+ 'tip',
21
+ 'flutes',
22
+ 'neck',
23
+ 'shank',
24
+ 'collet',
25
+ 'nose',
26
+ 'body',
27
+ 'flange',
28
+ ];
29
+ /**
30
+ * The parts a clearance sweep checks: everything but the cutting end.
31
+ *
32
+ * Derived from {@link ASSEMBLY_PARTS} rather than listed again, so a part added
33
+ * there is swept unless it is deliberately excluded here. The tip and the
34
+ * flutes are excluded because they are the cut — material at the cutting radius
35
+ * is what the tool is there to remove.
36
+ */
37
+ export const SILHOUETTE_PARTS = ASSEMBLY_PARTS.filter((part) => part !== 'tip' && part !== 'flutes');
38
+ export const NO_MARGINS = { radial: 0, axial: 0 };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A holder as its own CAD model measures it: the silhouette, and nothing
3
+ * parametric.
4
+ *
5
+ * Two `PROFILES_VERSION = 1` constants stood in this tree, one of which was
6
+ * imported under an alias specifically so it could be compared against the
7
+ * other. This is the one.
8
+ */
9
+ import type { ProvenanceMap } from './provenance.js';
10
+ /** Bumped when {@link HolderProfile} changes shape in a way a consumer must handle. */
11
+ export declare const PROFILES_VERSION = 1;
12
+ /** One vertex of a silhouette: `[z, r]`, both in millimetres. */
13
+ export type ProfilePoint = readonly [z: number, r: number];
14
+ /**
15
+ * What `z = 0` means on a profile.
16
+ *
17
+ * `gage-line` is the spindle face, with `z` increasing toward the cutting end —
18
+ * so the taper is negative, the nose positive, and the holder's gauge length is
19
+ * the last vertex's `z`. `nose` is the frame a holder with no taper to solve a
20
+ * gauge plane on is measured in, and it is stated rather than silently
21
+ * referenced to an arbitrary end: there is no gauge length to read off it, and a
22
+ * consumer must say so instead of printing one.
23
+ *
24
+ * **Per profile rather than per document.** One datum over a batch is only true
25
+ * while every holder in it has a taper, and the first Capto or straight-shank
26
+ * holder makes the document's own header wrong about some of its entries.
27
+ */
28
+ export type ProfileDatum = 'gage-line' | 'nose';
29
+ /**
30
+ * The measured envelope.
31
+ *
32
+ * A parametric holder is a handful of numbers off a DIN 4000 sheet, and a
33
+ * drawing built from them is a stylised holder. This is the other thing a
34
+ * catalog can have — the envelope measured off the vendor's STEP model, a
35
+ * hundred-odd vertices carrying the V-flange groove and the thread relief that
36
+ * a machinist actually looks for. **It is not a refinement of the parametric
37
+ * form and does not project onto it**: reducing it to a nose and a body throws
38
+ * away the only reason to measure.
39
+ *
40
+ * So the two are a union rather than one shape with optional extras — see
41
+ * `holding.ts` — and a consumer that has both picks one.
42
+ *
43
+ * How well the model agrees with the vendor's published gauge length, and by
44
+ * how much it falls short when it does not, is a fact about *that measurement
45
+ * run* and stays on whatever document carries the run. A record that has it
46
+ * extends this.
47
+ */
48
+ export interface HolderProfile {
49
+ /**
50
+ * The silhouette as `[z, r]` in millimetres, `z` ascending.
51
+ *
52
+ * Two vertices share a `z` where the solid steps, so this is a polyline and
53
+ * not a function of `z`. Fewer than two vertices is no holder.
54
+ */
55
+ readonly points: readonly ProfilePoint[];
56
+ readonly datum: ProfileDatum;
57
+ /** The series the holder takes, as a parametric `Holder` means it. */
58
+ readonly colletSeries: string | null;
59
+ /** How far the seated collet stands proud of the nose, in millimetres. */
60
+ readonly colletProtrusion: number | null;
61
+ /**
62
+ * Keyed as a parametric `Holder`'s is, plus `points` for the measurement
63
+ * itself — which is `vendor-stated` unless a caller says otherwise, because
64
+ * the shape measured is the vendor's own model rather than a derivation from
65
+ * its table.
66
+ */
67
+ readonly provenance?: ProvenanceMap;
68
+ }
69
+ /**
70
+ * The silhouette from the gage line out, where the measurement knows where the
71
+ * gage line is.
72
+ *
73
+ * A CAT40 model is measured whole, and about half of what comes back is the
74
+ * 7:24 cone and the retention knob — the part that is inside the spindle when
75
+ * the holder is in the machine. That says nothing a machinist is asking a
76
+ * holder drawing, and it costs the frame: the tool ends up a third of the
77
+ * height it could be because the picture is scaled to fit a taper nobody is
78
+ * looking at.
79
+ *
80
+ * So a `gage-line` profile is cut at `z = 0`, which is the spindle face, and
81
+ * where the polyline crosses it between two vertices the crossing point is
82
+ * **interpolated** so the cut is the face rather than the nearest vertex to it.
83
+ * Nothing below the gage line is touched — the vertices that survive are the
84
+ * measurement, grooves and thread reliefs included.
85
+ *
86
+ * A `nose`-datumed profile is returned whole: with no gauge plane solved there
87
+ * is no line to cut on, and guessing one would be inventing the very number the
88
+ * datum exists to say is missing. A profile that would be left shorter than a
89
+ * segment is also returned whole, because a holder measured entirely inside the
90
+ * spindle is bad data and drawing a stub of it hides that.
91
+ *
92
+ * ## The crossing is the part that has to be shared
93
+ *
94
+ * A renderer that draws the whole holder still has to find this same `z = 0`
95
+ * crossing, because it splits the silhouette there to draw the connection in
96
+ * its own shade rather than trimming it away — a different decision about the
97
+ * same line. The two interpolations have to land on the same radius or a holder
98
+ * meets its gage line in two places, and that is asserted against this function
99
+ * rather than left as a note in both files.
100
+ *
101
+ * Takes the profile structurally, so a measurement record that carries more
102
+ * than this satisfies it with no adapter.
103
+ */
104
+ export declare const belowGageLine: (profile: Pick<HolderProfile, "points" | "datum">) => readonly ProfilePoint[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A holder as its own CAD model measures it: the silhouette, and nothing
3
+ * parametric.
4
+ *
5
+ * Two `PROFILES_VERSION = 1` constants stood in this tree, one of which was
6
+ * imported under an alias specifically so it could be compared against the
7
+ * other. This is the one.
8
+ */
9
+ /** Bumped when {@link HolderProfile} changes shape in a way a consumer must handle. */
10
+ export const PROFILES_VERSION = 1;
11
+ /**
12
+ * The silhouette from the gage line out, where the measurement knows where the
13
+ * gage line is.
14
+ *
15
+ * A CAT40 model is measured whole, and about half of what comes back is the
16
+ * 7:24 cone and the retention knob — the part that is inside the spindle when
17
+ * the holder is in the machine. That says nothing a machinist is asking a
18
+ * holder drawing, and it costs the frame: the tool ends up a third of the
19
+ * height it could be because the picture is scaled to fit a taper nobody is
20
+ * looking at.
21
+ *
22
+ * So a `gage-line` profile is cut at `z = 0`, which is the spindle face, and
23
+ * where the polyline crosses it between two vertices the crossing point is
24
+ * **interpolated** so the cut is the face rather than the nearest vertex to it.
25
+ * Nothing below the gage line is touched — the vertices that survive are the
26
+ * measurement, grooves and thread reliefs included.
27
+ *
28
+ * A `nose`-datumed profile is returned whole: with no gauge plane solved there
29
+ * is no line to cut on, and guessing one would be inventing the very number the
30
+ * datum exists to say is missing. A profile that would be left shorter than a
31
+ * segment is also returned whole, because a holder measured entirely inside the
32
+ * spindle is bad data and drawing a stub of it hides that.
33
+ *
34
+ * ## The crossing is the part that has to be shared
35
+ *
36
+ * A renderer that draws the whole holder still has to find this same `z = 0`
37
+ * crossing, because it splits the silhouette there to draw the connection in
38
+ * its own shade rather than trimming it away — a different decision about the
39
+ * same line. The two interpolations have to land on the same radius or a holder
40
+ * meets its gage line in two places, and that is asserted against this function
41
+ * rather than left as a note in both files.
42
+ *
43
+ * Takes the profile structurally, so a measurement record that carries more
44
+ * than this satisfies it with no adapter.
45
+ */
46
+ export const belowGageLine = (profile) => {
47
+ if (profile.datum !== 'gage-line') {
48
+ return profile.points;
49
+ }
50
+ const cut = profile.points.findIndex(([z]) => z >= 0);
51
+ const inside = profile.points[cut - 1];
52
+ const outside = profile.points[cut];
53
+ if (cut <= 0 || inside === undefined || outside === undefined) {
54
+ return profile.points;
55
+ }
56
+ const kept = profile.points.slice(cut);
57
+ if (kept.length < 2) {
58
+ return profile.points;
59
+ }
60
+ if (outside[0] === 0) {
61
+ return kept;
62
+ }
63
+ const meet = [
64
+ 0,
65
+ inside[1] + (-inside[0] / (outside[0] - inside[0])) * (outside[1] - inside[1]),
66
+ ];
67
+ return [meet, ...kept];
68
+ };