diff --git a/AGENTS.md b/AGENTS.md
index 1511797..2b4d0b9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -209,6 +209,17 @@ application unless that application says otherwise.
**The 2D tool drawing is not this application's** — it is
`@toolpath/tool-drawing`, and `app/components/catalog-drawing.tsx` is the one
file that wires it up. See `docs/TOOL-DRAWING-PLAN.md`.
+ **Neither is the Fusion export** — it is
+ `@toolpath/tool-support/export/fusion`, whose per-type rules come from
+ Autodesk's own published JSON Schema rather than from a table written here,
+ and `app/shared/fusion-input.ts` is the whole seam: this catalog's records
+ turned into what that package takes, and its `ExportNote`s read back into the
+ dialog. Every tool carries one placeholder preset, `Default Preset`, whose
+ every number is 1 — a tool with no preset is one Fusion will not load, and
+ `defaultPreset` in that file has the evidence. Real feeds and speeds are
+ `app/shared/pretool-presets.ts`, unreferenced on purpose and going back
+ through the same `ToolRequest.presets`. Do not write a second exporter here — `docs/CATALOG-SPEC.md` § 5 has what changed and
+ what is left.
**A holder can be drawn from its own CAD model** rather than from the nine
numbers a vendor publishes: `catalog-profiles` is a second Vite alias beside
`catalog-dataset`, `shared/catalog.ts` `getProfile` is the only way to reach
@@ -306,6 +317,7 @@ application unless that application says otherwise.
| what is on the order list, for both pages | `app/shared/order-list.ts` |
| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file |
| which of four things the page is being asked | `asked()`, same file |
+| the bill as a Fusion library, and what its notes say | `app/shared/fusion-input.ts` |
| the three presses over the part that add a row | `app/components/add-bar.tsx` |
| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` |
| where the part is framed, beside the questions | `app/shared/frame-inset.ts` |
diff --git a/apps/catalog/app/components/fusion-export-dialog.test.tsx b/apps/catalog/app/components/fusion-export-dialog.test.tsx
index d70bc17..13389b2 100644
--- a/apps/catalog/app/components/fusion-export-dialog.test.tsx
+++ b/apps/catalog/app/components/fusion-export-dialog.test.tsx
@@ -3,29 +3,40 @@ import { describe, expect, it, vi } from 'vitest'
import { FusionExportDialog } from './fusion-export-dialog'
describe('FusionExportDialog', () => {
- it('requires a material and passes the selected material and RPM to export', async () => {
+ it('passes the trimmed library name to export and reports what landed', async () => {
const onExport = vi.fn().mockResolvedValue({ exported: 1, skipped: [], holderWarnings: [] })
- render(
- ,
- )
-
- const download = screen.getByRole('button', { name: 'Download .json' })
- expect(download).toBeDisabled()
- fireEvent.click(screen.getByRole('button', { name: 'Aluminum' }))
- fireEvent.change(screen.getByLabelText('Maximum spindle RPM'), { target: { value: '18000' } })
- fireEvent.click(download)
-
- await vi.waitFor(() =>
- expect(onExport).toHaveBeenCalledWith(
- { material: 'AluWrought', maxRpm: 18000 },
- 'Shop library',
- ),
- )
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download .json' }))
+
+ await vi.waitFor(() => expect(onExport).toHaveBeenCalledWith('Shop library'))
expect(await screen.findByText('Downloaded 1 tool assembly.')).toBeInTheDocument()
})
+
+ it('will not export under an empty name, which Fusion shows as the library label', () => {
+ render()
+
+ fireEvent.change(screen.getByLabelText('Library name'), { target: { value: ' ' } })
+ expect(screen.getByRole('button', { name: 'Download .json' })).toBeDisabled()
+ })
+
+ it('says which tool was left out, and which holder travelled short', async () => {
+ const onExport = vi.fn().mockResolvedValue({
+ exported: 1,
+ skipped: [{ catalogNumber: 'TDMX0800', reason: 'Fusion requires RE and none is stated' }],
+ holderWarnings: [
+ { catalogNumber: 'TDMX0600', reason: 'the vendor publishes no gauge length' },
+ ],
+ })
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download .json' }))
+
+ expect(
+ await screen.findByText('TDMX0800 skipped — Fusion requires RE and none is stated'),
+ ).toBeInTheDocument()
+ expect(
+ await screen.findByText('TDMX0600 — the vendor publishes no gauge length.'),
+ ).toBeInTheDocument()
+ })
})
diff --git a/apps/catalog/app/components/fusion-export-dialog.tsx b/apps/catalog/app/components/fusion-export-dialog.tsx
index 980ef22..7f4408a 100644
--- a/apps/catalog/app/components/fusion-export-dialog.tsx
+++ b/apps/catalog/app/components/fusion-export-dialog.tsx
@@ -1,60 +1,46 @@
import { useState } from 'react'
import { Button, Card, Input } from '@toolpath/ui'
-import { PRETOOL_MATERIALS, type PretoolMaterial } from 'shared/pretool-presets'
-import type { FusionExportDiagnostic, FusionExportSettings } from 'shared/fusion-library'
+import type { FusionReport } from 'shared/fusion-input'
import { useEscape } from 'shared/use-escape'
import { SECTION_LABEL } from 'shared/type'
export interface FusionExportDialogProps {
- readonly initialMaterial: PretoolMaterial | null
readonly initialName: string
readonly onCancel: () => void
- readonly onExport: (
- settings: FusionExportSettings,
- name: string,
- ) => Promise<{
- readonly exported: number
- readonly skipped: ReadonlyArray
- readonly holderWarnings: ReadonlyArray
- }>
+ readonly onExport: (name: string) => Promise
}
-const labelFor = (material: PretoolMaterial): string =>
- material === 'AluWrought'
- ? 'Aluminum'
- : material === 'LowCSteel'
- ? 'Low Carbon Steel'
- : 'Stainless Steel'
-
-/** The one last question before PreTool turns the bill into CAM starting data. */
+/**
+ * The one last question before the bill becomes a file: what to call it.
+ *
+ * It asked two more until the exporter moved to
+ * `@toolpath/tool-support/export/fusion` — a workpiece material and a maximum
+ * spindle speed, which were PreTool's inputs for the feeds and speeds it wrote
+ * into `start-values`. What goes out now is `defaultPreset`, a placeholder of
+ * 1s that exists so the library loads at all, and it needs neither answer. The
+ * two questions come back with `pretool-presets.ts`, and not before: asking a
+ * shop for its spindle ceiling and then writing a 1 would be worse than not
+ * asking.
+ */
export const FusionExportDialog = ({
- initialMaterial,
initialName,
onCancel,
onExport,
}: FusionExportDialogProps) => {
- const [material, setMaterial] = useState(initialMaterial)
const [name, setName] = useState(initialName)
- const [maxRpm, setMaxRpm] = useState('12000')
const [working, setWorking] = useState(false)
- const [result, setResult] = useState<{
- readonly exported: number
- readonly skipped: ReadonlyArray
- readonly holderWarnings: ReadonlyArray
- } | null>(null)
- const rpm = Number(maxRpm)
- const canExport =
- material !== null && name.trim() !== '' && Number.isFinite(rpm) && rpm > 0 && !working
+ const [result, setResult] = useState(null)
+ const canExport = name.trim() !== '' && !working
useEscape(true, onCancel)
const submit = async () => {
- if (!canExport || material === null) {
+ if (!canExport) {
return
}
setWorking(true)
try {
- setResult(await onExport({ material, maxRpm: rpm }, name.trim()))
+ setResult(await onExport(name.trim()))
} finally {
setWorking(false)
}
@@ -76,27 +62,10 @@ export const FusionExportDialog = ({
Fusion tool library
- PreTool will generate roughing and finishing presets for the chosen material.
+ Every assembly on this bill, with its holder. Feeds and speeds are left for Fusion.
-
-
{result === null ? null : (
(
- {each.catalogNumber} — {each.reason}; exported without its holder shape.
+ {each.catalogNumber} — {each.reason}.
))}
diff --git a/apps/catalog/app/routes/order-list.tsx b/apps/catalog/app/routes/order-list.tsx
index 673f2a3..cdaff7c 100644
--- a/apps/catalog/app/routes/order-list.tsx
+++ b/apps/catalog/app/routes/order-list.tsx
@@ -15,7 +15,7 @@ import type { CatalogTool, Collet, Holder } from '@toolpath/catalog-data'
import { AppHeader } from 'components/app-header'
import { FusionExportDialog } from 'components/fusion-export-dialog'
import { ColletIcon, HolderIcon, ToolTypeIcon, formLabel } from './../components/tool-icons'
-import { allTools, getCollet, getHolder, getTool } from 'shared/catalog'
+import { allTools, getCollet, getHolder, getProfile, getTool } from 'shared/catalog'
import {
addChoice,
lineUnder,
@@ -39,12 +39,15 @@ import {
type ComponentTotal,
type OrderAssembly,
} from 'shared/order-list'
-import { fusionLibrary, type FusionExportSettings } from 'shared/fusion-library'
+import {
+ fusionLibrary,
+ fusionLibraryJson,
+ sanitizeName,
+} from '@toolpath/tool-support/export/fusion'
+import { fusionInput, fusionReport } from 'shared/fusion-input'
import { saveInBrowser } from 'shared/save-file'
import { recallPart } from 'shared/part-session'
import { useUnit } from 'shared/use-unit'
-import { usePartMaterial } from 'shared/use-preferences'
-import type { PretoolMaterial } from 'shared/pretool-presets'
import { SECTION_LABEL, TABLE_FACE, TABLE_INK } from 'shared/type'
/**
@@ -108,27 +111,6 @@ const KIND: Readonly> = {
collet: 'Collet',
}
-const pretoolMaterialFor = (group: string | null): PretoolMaterial | null => {
- if (group === 'N') {
- return 'AluWrought'
- }
- if (group === 'P') {
- return 'LowCSteel'
- }
- if (group === 'M') {
- return 'StainlessSteel'
- }
- return null
-}
-
-const libraryFileName = (name: string): string => {
- const safe = name
- .trim()
- .replace(/[\\/:*?"<>|]+/g, '-')
- .replace(/\.+$/g, '')
- return safe === '' ? 'tool-library' : safe
-}
-
const toolLine = (tool: CatalogTool, unit: UnitSystem): Line => {
const say = (value: number) => formatLength(value, unit)
return {
@@ -440,7 +422,6 @@ const Bom = () => {
const [search] = useSearchParams()
const jobId = search.get('job')
const [unit, setUnit] = useUnit()
- const { materialGroup } = usePartMaterial(partId ?? '')
const { sheet, commit } = useSetupSheet(partId ?? '')
const remembered = partId && jobId ? recallPart(partId, jobId) : null
const features = remembered?.report.features ?? []
@@ -530,48 +511,39 @@ const Bom = () => {
/**
* The whole bill as a Fusion library, saved from the browser.
*
- * Built here rather than on the server because everything it needs is
- * already in this page: the sheet's guids, resolved through the catalog.
- * `fusion-library.ts` is where the shape of the file lives, and is tested
- * there.
+ * Built here rather than on the server because everything it needs is already
+ * in this page: the sheet's guids, resolved through the catalog. This route
+ * resolves them and nothing more — `shared/fusion-input.ts` turns the records
+ * into what the exporter takes and reads its notes back, and the exporter
+ * itself is `@toolpath/tool-support/export/fusion`, whose rules come from
+ * Autodesk's own schema rather than from anything written here.
*/
- const downloadFusion = async (settings: FusionExportSettings, name: string) => {
- const exported = fusionLibrary(
+ const downloadFusion = async (name: string) => {
+ const requests = fusionInput(
assemblies.flatMap(({ key, choice }) => {
const tool = getTool(choice.toolGuid)
- return tool === null
- ? []
- : [
- {
- key,
- tool,
- holder:
- choice.holderGuid == null
- ? undefined
- : (getHolder(choice.holderGuid) ?? undefined),
- collet:
- choice.colletGuid == null
- ? undefined
- : (getCollet(choice.colletGuid) ?? undefined),
- stickout: choice.stickout,
- },
- ]
+ if (tool === null) {
+ return []
+ }
+ const holder = choice.holderGuid == null ? null : getHolder(choice.holderGuid)
+ return [
+ {
+ key,
+ tool,
+ ...(holder === null ? {} : { holder, profile: getProfile(holder.guid) }),
+ stickout: choice.stickout,
+ },
+ ]
}),
- settings,
)
- if (exported.library.data.length === 0) {
- return { exported: 0, skipped: exported.skipped, holderWarnings: exported.holderWarnings }
- }
- saveInBrowser(
- `${libraryFileName(name)}.json`,
- JSON.stringify(exported.library, null, 2),
- 'application/json',
- )
- return {
- exported: exported.library.data.length,
- skipped: exported.skipped,
- holderWarnings: exported.holderWarnings,
+ const { document, notes } = fusionLibrary({
+ tools: requests.map((each) => each.request),
+ })
+ const report = fusionReport(requests, document, notes)
+ if (report.exported > 0) {
+ saveInBrowser(`${sanitizeName(name)}.json`, fusionLibraryJson(document), 'application/json')
}
+ return report
}
return (
@@ -930,7 +902,6 @@ const Bom = () => {
{fusionDialogOpen ? (
setFusionDialogOpen(false)}
onExport={downloadFusion}
diff --git a/apps/catalog/app/shared/fusion-input.test.ts b/apps/catalog/app/shared/fusion-input.test.ts
new file mode 100644
index 0000000..e6fbba7
--- /dev/null
+++ b/apps/catalog/app/shared/fusion-input.test.ts
@@ -0,0 +1,349 @@
+import { describe, expect, it } from 'vitest'
+import type { CatalogTool, Holder, HolderProfile, ToolForm } from '@toolpath/catalog-data'
+import {
+ FUSION_TYPES,
+ fusionLibrary,
+ fusionLibraryJson,
+} from '@toolpath/tool-support/export/fusion'
+import { DEFAULT_PRESET_NAME, fusionInput, fusionReport, type OrderedStack } from './fusion-input'
+
+/**
+ * The seam between this catalog and `@toolpath/tool-support/export/fusion`.
+ *
+ * The exporter's own rules are tested where they live, against Autodesk's
+ * schema. What is tested here is only what this application decides: which
+ * field answers which, which guid a record gets, which arm of the holder union
+ * travels, and how a note comes back as something a machinist can read.
+ */
+
+/** Every length in millimetres, as the catalog stores them whatever the vendor published. */
+const endMill: CatalogTool = {
+ guid: 'cat-tool-1',
+ familyId: 'example_square_4fl',
+ brand: 'Example',
+ vendor: 'Example Tools Inc',
+ catalogNumber: 'TDMX0600',
+ materialNumber: null,
+ toolType: 'endmill',
+ form: 'flat end mill',
+ unitSystem: 'millimeters',
+ geometry: { DC: 6, SFDM: 6, OAL: 57, LCF: 18, NOF: 4, LBH: 24 },
+ materialGroups: ['N'],
+ productLine: null,
+ threadMethod: null,
+ productLink: 'https://example.test/TDMX0600',
+ provenance: {},
+}
+
+const holder: Holder = {
+ guid: 'cat-holder-1',
+ familyId: 'example_bt40_er32',
+ brand: 'ExampleHold',
+ vendor: 'ExampleHold GmbH',
+ catalogNumber: 'BT40-ER32-100',
+ materialNumber: null,
+ contact: 'taper',
+ taper: 'BT40',
+ clamping: 'collet',
+ boreDiameter: null,
+ productLink: null,
+ cadModelUrl: null,
+ provenance: {},
+ noseDiameter: 33,
+ noseLength: 45,
+ bodyDiameter: 45,
+ bodyLength: 5,
+ projection: 50,
+ flangeDiameter: 63,
+ gaugeLength: 50,
+ colletSeries: 'ER32',
+ colletProtrusion: null,
+}
+
+/** `[z, r]`, z running from the gage line toward the cutting end. */
+const profile: HolderProfile = {
+ guid: 'cat-holder-1',
+ catalogNumber: 'BT40-ER32-100',
+ datum: 'gage-line',
+ points: [
+ [-30, 22],
+ [0, 31.5],
+ [40, 22.5],
+ [60, 16.5],
+ ],
+ complete: true,
+ shortfallMm: null,
+}
+
+/** Deterministic guids, so a test can say which record is which. */
+const counting = () => {
+ let at = 0
+ return () => {
+ at += 1
+ return `guid-${at}`
+ }
+}
+
+const oneStack = (over: Partial = {}): Array => [
+ { key: 'row-1', tool: endMill, ...over },
+]
+
+describe('the tool an order-list row becomes', () => {
+ const [only] = fusionInput(oneStack(), counting())
+
+ it('answers the catalog’s unitSystem to the exporter’s unit', () => {
+ // The one rename ingestion makes that the exporter's input does not.
+ expect(only?.request.tool.unit).toBe('millimeters')
+ })
+
+ it('states the vendor, the order number and the link the catalog holds', () => {
+ expect(only?.request.tool.vendor).toBe('Example')
+ expect(only?.request.tool.catalogNumber).toBe('TDMX0600')
+ expect(only?.request.tool.productLink).toBe('https://example.test/TDMX0600')
+ expect(only?.request.tool.description).toBe('Example TDMX0600')
+ })
+
+ it('turns a catalog null into an absent field, not an empty string', () => {
+ // `null` is the vendor naming none; the exporter's input says that by
+ // saying nothing, and `'null'` would be written into the file as a link.
+ const [without] = fusionInput(oneStack({ tool: { ...endMill, productLink: null } }), counting())
+ expect(without?.request.tool).not.toHaveProperty('productLink')
+ })
+
+ it('falls back to the catalog’s own LBH when no stickout was chosen', () => {
+ expect(only?.request.assembly?.stickout).toBe(24)
+ })
+
+ it('prefers the stickout the stack was set up at', () => {
+ const [chosen] = fusionInput(oneStack({ stickout: 31 }), counting())
+ expect(chosen?.request.assembly?.stickout).toBe(31)
+ })
+
+ it('numbers the carousel by the row’s place on the list', () => {
+ const two = fusionInput([...oneStack(), { key: 'row-2', tool: endMill }], counting())
+ expect(two.map((each) => each.request.tool.number)).toEqual([1, 2])
+ })
+})
+
+describe('identity is per stack, not per catalog tool', () => {
+ it('gives one tool in two holders two Fusion records', () => {
+ // The exporter mints no guid and would reuse the catalog's, under which
+ // Fusion would hold the second assembly and forget the first. A machinist
+ // sets these up separately, so they are two tools.
+ const requests = fusionInput(
+ [
+ { key: 'row-1', tool: endMill, holder },
+ { key: 'row-2', tool: endMill, holder },
+ ],
+ counting(),
+ )
+ const [first, second] = requests
+ expect(first?.toolGuid).not.toBe(second?.toolGuid)
+ expect(first?.request.tool.guid).toBe(first?.toolGuid)
+
+ const { document } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ expect(new Set(document.data.map((record) => record.guid)).size).toBe(2)
+ })
+
+ it('gives the holder a guid of its own, and remembers whose it is', () => {
+ const [only] = fusionInput(oneStack({ holder }), counting())
+ expect(only?.holderGuid).not.toBe(only?.toolGuid)
+ expect(only?.request.assembly?.holder?.guid).toBe(only?.holderGuid)
+ })
+
+ it('reports no holder guid for a stack held in nothing', () => {
+ const [only] = fusionInput(oneStack(), counting())
+ expect(only?.holderGuid).toBeNull()
+ })
+})
+
+describe('which holder shape travels', () => {
+ it('sends the measured silhouette when the model is complete', () => {
+ const [only] = fusionInput(oneStack({ holder, profile }), counting())
+ expect(only?.request.assembly?.holder?.holder).toHaveProperty('points')
+
+ const { document } = fusionLibrary({ tools: [only!.request] })
+ const written = document.data[0] as { holder?: { gaugeLength?: number } }
+ // Everything above the gage line is the taper and the retention knob, which
+ // Fusion does not want: the stack is the 60 mm below it.
+ expect(written.holder?.gaugeLength).toBe(60)
+ })
+
+ it('carries the collet series and protrusion across from the holder record', () => {
+ // The measurement record states neither, and the domain shape requires both.
+ const [only] = fusionInput(oneStack({ holder, profile }), counting())
+ expect(only?.request.assembly?.holder?.holder).toMatchObject({
+ colletSeries: 'ER32',
+ colletProtrusion: null,
+ })
+ })
+
+ it('refuses an incomplete model and sends the published dimensions instead', () => {
+ // A model that stops at the threaded nose is missing the collet nut — the
+ // end that fouls the part. Exported, it would let Fusion clear material the
+ // real holder runs into.
+ const short = { ...profile, complete: false, shortfallMm: 12 }
+ const [only] = fusionInput(oneStack({ holder, profile: short }), counting())
+ expect(only?.request.assembly?.holder?.holder).not.toHaveProperty('points')
+ expect(only?.request.assembly?.holder?.holder).toMatchObject({ noseDiameter: 33 })
+ })
+
+ it('sends the published dimensions when nothing was measured', () => {
+ const [only] = fusionInput(oneStack({ holder, profile: null }), counting())
+ expect(only?.request.assembly?.holder?.holder).not.toHaveProperty('points')
+ })
+})
+
+describe('what the export has to say for itself', () => {
+ it('counts what landed and says nothing where nothing went wrong', () => {
+ const requests = fusionInput(oneStack({ holder }), counting())
+ const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ const report = fusionReport(requests, document, notes)
+
+ expect(report.exported).toBe(1)
+ expect(report.skipped).toEqual([])
+ expect(report.holderWarnings).toEqual([])
+ })
+
+ it('names the tool Fusion refused, by the number a machinist orders it under', () => {
+ // A bull nose end mill with no stated corner radius is not a flat end mill,
+ // so the exporter leaves it out rather than flattening it.
+ const bullNose: CatalogTool = {
+ ...endMill,
+ catalogNumber: 'TDMX0800',
+ form: 'bull nose end mill',
+ }
+ const requests = fusionInput([{ key: 'row-1', tool: bullNose }], counting())
+ const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ const report = fusionReport(requests, document, notes)
+
+ expect(report.exported).toBe(0)
+ expect(report.skipped[0]?.catalogNumber).toBe('TDMX0800')
+ expect(report.skipped[0]?.reason).toContain('RE')
+ })
+
+ it('passes on what the holder could not say, against the tool it was on', () => {
+ const noGauge: Holder = { ...holder, gaugeLength: null }
+ const requests = fusionInput(oneStack({ holder: noGauge }), counting())
+ const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ const report = fusionReport(requests, document, notes)
+
+ expect(report.exported).toBe(1)
+ expect(report.holderWarnings[0]?.catalogNumber).toBe('TDMX0600')
+ expect(report.holderWarnings[0]?.reason).toContain('gauge length')
+ })
+
+ it('does not surface what the exporter merely filled in', () => {
+ // Every tool draws several `filled` notes — the hand, the coolant flag, the
+ // shoulder read off the shank. They are the format's conventions, not
+ // anything a shop has to act on.
+ const requests = fusionInput(oneStack({ holder }), counting())
+ const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) })
+
+ expect(notes.some((note) => note.kind === 'filled')).toBe(true)
+ const report = fusionReport(requests, document, notes)
+ expect(report.skipped.length + report.holderWarnings.length).toBe(0)
+ })
+})
+
+describe('the file that comes out', () => {
+ const requests = fusionInput(oneStack({ holder }), counting())
+ const { document } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ const text = fusionLibraryJson(document)
+
+ it('writes a preset, because a tool with none is one Fusion will not load', () => {
+ // This went out as `presets: []` until 2026-09-11, on the reading that the
+ // schema wants the key and not a preset in it. Autodesk's `minLength: 1` on
+ // the array is a no-op — it is a string keyword — so nothing caught it, and
+ // the libraries would not import.
+ const written = JSON.parse(text).data[0]['start-values'].presets
+ expect(written).toHaveLength(1)
+ expect(written[0].name).toBe(DEFAULT_PRESET_NAME)
+ })
+
+ it('keeps the decimal point on a dimension that lands on a whole number', () => {
+ // `JSON.stringify(24.0)` is `"24"`, which is the half of this the old
+ // exporter got wrong: it wrote the document with `JSON.stringify`.
+ expect(text).toContain('"LB": 24.0')
+ expect(text).toContain('"NOF": 4')
+ expect(text).not.toContain('"NOF": 4.0')
+ })
+})
+
+describe('the preset every tool carries', () => {
+ /**
+ * The written record for a tool of this form.
+ *
+ * `geometry` is topped up per form because Fusion refuses a record short of a
+ * measurement — a drill states its point angle, a tap its pitch — and a tool
+ * the format refused would never reach the question this describe is asking.
+ */
+ const exportedTool = (form: ToolForm, geometry: Record = {}) => {
+ const tool = { ...endMill, form, geometry: { ...endMill.geometry, ...geometry } }
+ const requests = fusionInput(oneStack({ tool, holder }), counting())
+ const { document } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ return document.data.find((record) => record.guid === requests[0]?.toolGuid)
+ }
+
+ /** The one preset on that record, as a plain bag of fields. */
+ const presetOn = (form: ToolForm, geometry: Record = {}) => {
+ const record = exportedTool(form, geometry) as {
+ 'start-values'?: { presets?: Array }
+ }
+ const presets = record['start-values']?.presets ?? []
+ expect(presets).toHaveLength(1)
+ return presets[0] as Record
+ }
+
+ it('puts exactly one on every request, under its own guid', () => {
+ const requests = fusionInput(oneStack({ holder }), counting())
+ const presets = requests[0]?.request.presets ?? []
+
+ expect(presets).toHaveLength(1)
+ expect(presets[0]?.name).toBe(DEFAULT_PRESET_NAME)
+ // A preset's guid is Fusion's identity for it, so it is not the tool's and
+ // not the holder's.
+ expect(presets[0]?.guid).not.toBe(requests[0]?.toolGuid)
+ expect(presets[0]?.guid).not.toBe(requests[0]?.holderGuid)
+ })
+
+ it('states every field Fusion requires of a milling preset', () => {
+ // Measured against the exporter's own reduction of Autodesk's schema rather
+ // than a list retyped here: a field added upstream fails this test.
+ const preset = presetOn('flat end mill')
+ for (const field of FUSION_TYPES['flat end mill'].presetRequired) {
+ expect(preset[field]).toBeDefined()
+ }
+ })
+
+ it('narrows to what a drill models, which is not what a mill models', () => {
+ // One shape is written for all five, and `fusionPresets` drops what the type
+ // does not model. A drill has no cutting feedrate at all.
+ const preset = presetOn('drill', { SIG: 140 })
+ for (const field of FUSION_TYPES.drill.presetRequired) {
+ expect(preset[field]).toBeDefined()
+ }
+ expect(preset.v_f).toBeUndefined()
+ // Autodesk's own `if`/`then`: the switch is set, so both fields it demands
+ // have to have travelled.
+ expect(preset['use-feed-per-revolution']).toBe(true)
+ expect(preset.f_n).toBe(1)
+ expect(preset.f_n_retract).toBe(1)
+ })
+
+ it('leaves a tap the six fields it models and nothing else', () => {
+ const preset = presetOn('tap right hand', { TP: 1 })
+ expect(Object.keys(preset).sort()).toEqual([...FUSION_TYPES['tap right hand'].presetRequired])
+ })
+
+ it('says nothing a shop has to act on', () => {
+ // Narrowing the union draws a `dropped` note per field per tool. They are
+ // the format's business, not a machinist's, so none of them may surface.
+ const requests = fusionInput(oneStack({ holder }), counting())
+ const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) })
+ const report = fusionReport(requests, document, notes)
+
+ expect(report.skipped).toEqual([])
+ expect(report.holderWarnings).toEqual([])
+ })
+})
diff --git a/apps/catalog/app/shared/fusion-input.ts b/apps/catalog/app/shared/fusion-input.ts
new file mode 100644
index 0000000..e4b7a9e
--- /dev/null
+++ b/apps/catalog/app/shared/fusion-input.ts
@@ -0,0 +1,329 @@
+import type { CatalogTool, Holder, HolderProfile } from '@toolpath/catalog-data'
+import type { HolderProfile as DomainProfile } from '@toolpath/tool-support'
+import type { ExportNote } from '@toolpath/tool-support/export'
+import type {
+ CatalogHolder,
+ CatalogPreset,
+ FusionLibrary,
+ ToolRequest,
+} from '@toolpath/tool-support/export/fusion'
+
+/**
+ * The order list as `@toolpath/tool-support/export/fusion` wants to be asked.
+ *
+ * **This is the whole seam.** The exporter itself is not this application's —
+ * its per-type rules come from Autodesk's own published JSON Schema, reduced
+ * into that repository's `fusion/digest.json` and watched for drift. What is
+ * ours is the translation from the shapes this catalog stores into the shapes
+ * that package takes, and it lives here rather than in the route so it can be
+ * tested without mounting a page.
+ *
+ * The route resolves guids through `catalog.ts` and hands the records over;
+ * nothing here reaches for the dataset.
+ *
+ * ## Three names this catalog spells differently
+ *
+ * The package documents that a `@toolpath/tool-scraper` `ToolRecord` satisfies
+ * its input with no adapter, and that is true of a record. It is not true of
+ * *this* catalog's `CatalogTool`, which is a different document: ingestion
+ * renames `unit` to `unitSystem`, and drops `description`, `substrate` and
+ * `coolantThrough` on the way in. So `BMC` is always `unspecified` and `CSP` is
+ * always the exporter's filled `false` — including on a through-coolant drill.
+ * Carrying those two fields through ingest is a catalog version bump and a
+ * re-ingest, and until then the silence is honest rather than invented.
+ */
+
+/** One distinct stack on the order list, resolved through the current catalog. */
+export interface OrderedStack {
+ /** The order list's own key for the row, so a note can be sent back to it. */
+ readonly key: string
+ readonly tool: CatalogTool
+ readonly holder?: Holder | undefined
+ /**
+ * The holder measured off the vendor's CAD model, where the catalog has one.
+ *
+ * Preferred over the published dimensions when it exists: a `Holder` states a
+ * nose, a body and a flange, and the exporter draws three stepped cylinders
+ * from them, where a profile is the vendor's own silhouette and carries the
+ * V-flange groove and the thread relief. `fusionHolder` cuts it at its own
+ * gage line, so the two arms agree about where the holder starts.
+ */
+ readonly profile?: HolderProfile | null | undefined
+ /** The setout selected for this stack; absent means the catalog's LBH setup value. */
+ readonly stickout?: number | undefined
+}
+
+/**
+ * The measured silhouette in the shape the domain states one, or `null` to use
+ * the vendor's published dimensions instead.
+ *
+ * Two things make this a conversion rather than a pass-through. This catalog's
+ * `HolderProfile` is a *measurement record* — a guid, the catalog number, how
+ * well the model agreed with the vendor's gage length — where the domain's is
+ * the *shape*, and the two fields the shape needs and the measurement does not
+ * carry, `colletSeries` and `colletProtrusion`, are on the holder beside it.
+ *
+ * **And an incomplete model is refused.** `complete: false` means the vendor's
+ * STEP file stops short — five BTKV30 models end at the threaded nose and omit
+ * the collet nut altogether. That missing piece is at the cutting end, which is
+ * the end that fouls the part, so exporting the measurement would hand Fusion a
+ * holder shorter than the real one and let it clear material it cannot. The
+ * published dimensions are the honest answer there, and `catalog.ts` has
+ * already backfilled them from whatever the model did reach.
+ */
+const measuredShape = (
+ holder: Holder,
+ profile: HolderProfile | null | undefined,
+): DomainProfile | null => {
+ if (profile === undefined || profile === null || !profile.complete) {
+ return null
+ }
+ return {
+ points: profile.points,
+ datum: profile.datum,
+ colletSeries: holder.colletSeries,
+ colletProtrusion: holder.colletProtrusion,
+ }
+}
+
+/**
+ * One stack, as the exporter takes it, with what is needed to read its notes
+ * back.
+ *
+ * An `ExportNote` names its subject by guid. Because this module mints those
+ * guids it also knows which order-list row each one came from, which is what
+ * lets a diagnostic point at a row rather than at a catalog number that two
+ * rows may share.
+ */
+export interface StackRequest {
+ readonly key: string
+ /** The guid written into the Fusion record for the tool. */
+ readonly toolGuid: string
+ /** The guid written for its holder, or `null` where the stack has none. */
+ readonly holderGuid: string | null
+ /** What a machinist orders the tool by, for a message they can act on. */
+ readonly catalogNumber: string
+ readonly request: ToolRequest
+}
+
+/**
+ * A guid per exported record, minted here rather than taken from the catalog.
+ *
+ * **The package deliberately mints none**, and it is right not to: reusing a
+ * catalog guid is what makes a re-exported library update a tool in Fusion
+ * instead of adding a second copy of it. But an order list is not a catalog. The
+ * same end mill can be ordered in two different holders, and those are two
+ * assemblies a machinist sets up separately — under one guid Fusion would hold
+ * only the second. So identity here is per stack, and this application owns it.
+ *
+ * The cost is that a re-export is a fresh set of tools rather than an update of
+ * the last one, which is what this page already did.
+ */
+export type MintGuid = () => string
+
+const browserGuid: MintGuid = () => globalThis.crypto.randomUUID()
+
+/**
+ * The holder as the exporter takes one: the shape, plus who made it.
+ *
+ * A holder carries no unit system of its own — nothing in the catalog publishes
+ * one — so it takes the tool's, which is also the record it will sit inside.
+ */
+const holderFor = (stack: OrderedStack, mintGuid: MintGuid): CatalogHolder | undefined => {
+ const { holder, profile, tool } = stack
+ if (holder === undefined) {
+ return undefined
+ }
+ return {
+ guid: mintGuid(),
+ holder: measuredShape(holder, profile) ?? holder,
+ unit: tool.unitSystem,
+ description: `${holder.brand} ${holder.catalogNumber}`,
+ vendor: holder.brand,
+ catalogNumber: holder.catalogNumber,
+ ...(holder.productLink === null ? {} : { productLink: holder.productLink }),
+ }
+}
+
+/**
+ * Every ordered stack, as a request the exporter can be handed.
+ *
+ * `number` is the stack's place in this list rather than its place among the
+ * tools that survive the export, so a tool the format refuses leaves a gap in
+ * the carousel numbering. That is the trade for not exporting twice: a tool's
+ * number then depends only on where it sits on the order list, and not on
+ * whether some other tool three rows up happened to state a corner radius.
+ *
+ * Every tool carries {@link defaultPreset}, because a tool with none is a tool
+ * Fusion will not load — see that function for the evidence.
+ */
+/** What the one preset every exported tool carries is called. */
+export const DEFAULT_PRESET_NAME = 'Default Preset'
+
+/**
+ * The placeholder preset every exported tool carries.
+ *
+ * ## Why a tool must carry one
+ *
+ * `start-values.presets` used to go out empty, on the reading that Autodesk's
+ * schema requires the key and not a preset in it. That reading is wrong and the
+ * libraries would not import. The schema does ask for one — every `start-values`
+ * branch declares `presets: { type: 'array', minLength: 1 }` — but `minLength`
+ * is a *string* keyword, so on an array it is a no-op and no validator enforces
+ * it. Autodesk meant `minItems`. The corroboration is Fusion's own output: of
+ * 727 tools across seventeen libraries Fusion itself wrote, every one carries at
+ * least one preset and none carries zero.
+ *
+ * ## Why the numbers are all 1
+ *
+ * They are placeholders and nothing else. What a tool's feeds and speeds should
+ * be is a machining model — a chip load per material, a surface speed, a shop's
+ * own opinion about both — and this application does not answer that yet.
+ * `pretool-presets.ts` is that model, still unwired and still carrying the three
+ * things to fix before it goes back through this same argument. Until then a
+ * visible 1 is the honest placeholder: it loads, and nobody mistakes it for a
+ * recommendation. The same reasoning keeps the name plain rather than dressed up
+ * as a material.
+ *
+ * A `1` also cannot be wrong about its units, which matters here: the exporter
+ * converts geometry between unit systems and deliberately does not convert feeds
+ * and speeds, since which unit a preset field uses differs per field.
+ *
+ * ## Why one shape serves all five
+ *
+ * Autodesk states a preset's shape per tool type and the five shapes are not
+ * variations on one another — a milling preset requires seventeen fields, a
+ * tap's requires six and models nine, a drill states a feed per revolution and
+ * no cutting feedrate at all. What is written below is the *union* of what the
+ * five require, and `fusionPresets` narrows it: a field the type does not model
+ * is dropped with a note before the record is written, so a tap goes out with
+ * its six and a drill with its seven. Writing one shape per type here would be a
+ * fourth copy of a table that already exists in the exporter and in the digest
+ * it is checked against.
+ *
+ * The booleans are the two switches Autodesk hangs `if`/`then` rules off, and
+ * both are set so the fields they demand travel with their own 1. `material` is
+ * deliberately absent: an absent band is not a narrower one but no restriction
+ * at all, and the exporter supplies the all-materials band for exactly that
+ * reason.
+ */
+export const defaultPreset = (guid: string): CatalogPreset => ({
+ guid,
+ name: DEFAULT_PRESET_NAME,
+ 'tool-coolant': 'flood',
+ n: 1,
+ n_ramp: 1,
+ v_c: 1,
+ f_n: 1,
+ f_n_retract: 1,
+ f_z: 1,
+ v_f: 1,
+ v_f_leadIn: 1,
+ v_f_leadOut: 1,
+ v_f_plunge: 1,
+ v_f_ramp: 1,
+ v_f_retract: 1,
+ v_f_transition: 1,
+ 'ramp-angle': 1,
+ 'use-stepdown': true,
+ stepdown: 1,
+ 'use-stepover': true,
+ stepover: 1,
+ 'use-feed-per-revolution': true,
+})
+
+export const fusionInput = (
+ stacks: ReadonlyArray,
+ mintGuid: MintGuid = browserGuid,
+): Array =>
+ stacks.map((stack, index) => {
+ const { tool } = stack
+ const toolGuid = mintGuid()
+ const holder = holderFor(stack, mintGuid)
+ const stickout = stack.stickout ?? tool.geometry.LBH ?? null
+
+ return {
+ key: stack.key,
+ toolGuid,
+ holderGuid: holder?.guid ?? null,
+ catalogNumber: tool.catalogNumber,
+ request: {
+ tool: {
+ form: tool.form,
+ guid: toolGuid,
+ unit: tool.unitSystem,
+ geometry: tool.geometry,
+ threadMethod: tool.threadMethod,
+ description: `${tool.brand} ${tool.catalogNumber}`,
+ vendor: tool.brand,
+ catalogNumber: tool.catalogNumber,
+ ...(tool.productLink === null ? {} : { productLink: tool.productLink }),
+ number: index + 1,
+ },
+ presets: [defaultPreset(mintGuid())],
+ ...(holder === undefined && stickout === null
+ ? {}
+ : { assembly: { stickout, ...(holder === undefined ? {} : { holder }) } }),
+ },
+ }
+ })
+
+/** One thing worth telling whoever pressed the button, in their words. */
+export interface FusionExportDiagnostic {
+ readonly catalogNumber: string
+ readonly reason: string
+}
+
+export interface FusionReport {
+ readonly exported: number
+ readonly skipped: ReadonlyArray
+ readonly holderWarnings: ReadonlyArray
+}
+
+/**
+ * What the export has to say for itself, read back off the notes.
+ *
+ * **A tool is skipped when it is absent from the document, not when a note says
+ * so.** The exporter reports a missing required key twice — once from the
+ * geometry block naming the key and once from the record summarising it — and a
+ * `skipped` note is also written for a preset that was refused while the tool
+ * itself exported fine. Membership in `document.data` is the only exact test,
+ * and this module can make it because it minted the guids.
+ *
+ * `filled` notes are not surfaced. Every tool draws several — the hand, the
+ * through-coolant flag, the shoulder read off the shank — and they are
+ * conventions the format demands rather than anything a shop has to act on.
+ */
+export const fusionReport = (
+ requests: ReadonlyArray,
+ document: FusionLibrary,
+ notes: ReadonlyArray,
+): FusionReport => {
+ const written = new Set(document.data.map((record) => record.guid))
+ const skipped: Array = []
+ const holderWarnings: Array = []
+
+ for (const request of requests) {
+ const { catalogNumber, toolGuid, holderGuid } = request
+ if (!written.has(toolGuid)) {
+ const said = notes
+ .filter((note) => note.subject === toolGuid && note.kind === 'skipped')
+ .map((note) => note.message)
+ skipped.push({
+ catalogNumber,
+ reason: said.length === 0 ? 'Fusion has no record this tool fits' : said.join('; '),
+ })
+ continue
+ }
+ if (holderGuid === null) {
+ continue
+ }
+ for (const note of notes) {
+ if (note.subject === holderGuid && note.kind === 'dropped') {
+ holderWarnings.push({ catalogNumber, reason: note.message })
+ }
+ }
+ }
+
+ return { exported: document.data.length, skipped, holderWarnings }
+}
diff --git a/apps/catalog/app/shared/fusion-library.test.ts b/apps/catalog/app/shared/fusion-library.test.ts
deleted file mode 100644
index ae5607d..0000000
--- a/apps/catalog/app/shared/fusion-library.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { CatalogTool, Holder } from '@toolpath/catalog-data'
-import { fusionLibrary } from './fusion-library'
-
-const tool = (over: Partial = {}): CatalogTool =>
- ({
- guid: '28e01f4e-14bb-4807-a0cb-9ef2789d74ea',
- catalogNumber: 'TDMX0800',
- brand: 'WIDIA',
- vendor: 'Kennametal',
- form: 'bull nose end mill',
- toolType: 'endmill',
- geometry: { DC: 8, LCF: 19, OAL: 63, SFDM: 8, NOF: 4, RE: 1, LBH: 27 },
- productLink: 'https://example.com/TDMX0800',
- provenance: {},
- ...over,
- }) as unknown as CatalogTool
-
-const holder = {
- guid: '973eaa5d-4474-46c8-aaac-d89dcefcaa0f',
- brand: 'Kennametal',
- catalogNumber: 'BT30ER16060M',
- noseDiameter: 28,
- noseLength: 20,
- bodyDiameter: 34,
- bodyLength: 15,
- flangeDiameter: 46,
- projection: 60,
- gaugeLength: 44,
-} as unknown as Holder
-
-const ids = (): (() => string) => {
- let at = 0
- return () => `00000000-0000-4000-8000-${String(++at).padStart(12, '0')}`
-}
-
-describe('the order list as a Fusion library', () => {
- it('writes a current Fusion tool and PreTool starting presets', () => {
- const { library } = fusionLibrary(
- [{ key: 'one', tool: tool(), holder, stickout: 31 }],
- { material: 'LowCSteel', maxRpm: 12000 },
- { nextGuid: ids() },
- )
- const first = library.data[0]!
-
- expect(library.version).toBe(33)
- expect(first.BMC).toBe('unspecified')
- expect(first.geometry).toMatchObject({
- CSP: false,
- HAND: true,
- LB: 31,
- assemblyGaugeLength: 75,
- 'shoulder-length': 19,
- 'shoulder-diameter': 8,
- })
- expect(first.holder).toMatchObject({
- type: 'holder',
- gaugeLength: 44,
- segments: [
- { height: 20, 'lower-diameter': 28, 'upper-diameter': 28 },
- { height: 15, 'lower-diameter': 34, 'upper-diameter': 34 },
- { height: 25, 'lower-diameter': 46, 'upper-diameter': 46 },
- ],
- })
- expect(first['start-values'].presets).toHaveLength(3)
- expect(first['start-values'].presets[0]).toMatchObject({
- name: 'LowCSteel_Adaptive_Rough',
- 'tool-coolant': 'flood',
- 'use-stepdown': true,
- 'use-stepover': true,
- })
- expect(first['start-values'].presets.every((preset) => preset.n <= 12000)).toBe(true)
- })
-
- it('uses the catalog setup length for a tool with no holder', () => {
- const { library } = fusionLibrary(
- [{ key: 'one', tool: tool() }],
- { material: 'AluWrought', maxRpm: 12000 },
- { nextGuid: ids() },
- )
-
- expect(library.data[0]?.geometry).toMatchObject({ LB: 27, assemblyGaugeLength: 27 })
- expect(library.data[0]?.holder).toBeUndefined()
- })
-
- it('writes PreTool drilling and tapping presets when their Fusion geometry is complete', () => {
- const drill = tool({
- catalogNumber: 'DRILL',
- form: 'drill',
- toolType: 'drill',
- geometry: { DC: 6, LCF: 30, OAL: 70, SFDM: 6, NOF: 2, SIG: 118, LBH: 30 },
- })
- const tap = tool({
- catalogNumber: 'TAP',
- form: 'tap right hand',
- toolType: 'tap',
- geometry: { DC: 6, LCF: 18, OAL: 65, SFDM: 6, NOF: 3, TP: 1, LBH: 25 },
- })
- const { library } = fusionLibrary(
- [
- { key: 'drill', tool: drill },
- { key: 'tap', tool: tap },
- ],
- { material: 'StainlessSteel', maxRpm: 5000 },
- { nextGuid: ids() },
- )
-
- expect(library.data.map((each) => each['start-values'].presets[0]?.name)).toEqual([
- 'StainlessSteel_Drill',
- 'StainlessSteel_Tap',
- ])
- expect(
- library.data
- .flatMap((each) => each['start-values'].presets)
- .every((preset) => preset.n <= 5000),
- ).toBe(true)
- })
-
- it('makes every ordered assembly a separately identified Fusion tool', () => {
- const { library } = fusionLibrary(
- [
- { key: 'short', tool: tool(), stickout: 20 },
- { key: 'long', tool: tool(), holder, stickout: 31 },
- ],
- { material: 'StainlessSteel', maxRpm: 9000 },
- { nextGuid: ids() },
- )
-
- expect(library.data.map((each) => each.guid)).toHaveLength(2)
- expect(new Set(library.data.map((each) => each.guid)).size).toBe(2)
- expect(library.data.map((each) => each['post-process'].number)).toEqual([1, 2])
- })
-
- it('does not invent missing vendor geometry', () => {
- const { library, skipped } = fusionLibrary(
- [
- { key: 'unknown', tool: tool({ form: 'other' }) },
- { key: 'missing', tool: tool({ catalogNumber: 'MISS', geometry: { DC: 8 } }) },
- ],
- { material: 'AluWrought', maxRpm: 12000 },
- { nextGuid: ids() },
- )
-
- expect(library.data).toHaveLength(0)
- expect(skipped).toEqual([
- { catalogNumber: 'TDMX0800', reason: 'Fusion does not support the catalog form “other”' },
- { catalogNumber: 'MISS', reason: 'missing LCF, OAL, NOF, RE, SFDM, LBH (selected stickout)' },
- ])
- })
-
- it('does not generate CAM data without a positive spindle ceiling', () => {
- const result = fusionLibrary(
- [{ key: 'one', tool: tool() }],
- { material: 'AluWrought', maxRpm: 0 },
- { nextGuid: ids() },
- )
-
- expect(result.library.data).toEqual([])
- expect(result.skipped).toEqual([
- { catalogNumber: 'TDMX0800', reason: 'maximum spindle RPM must be greater than zero' },
- ])
- })
-
- it('exports a tool while honestly warning when its selected holder has no shape', () => {
- const bare = { ...holder, noseLength: null, bodyLength: null, projection: null } as Holder
- const result = fusionLibrary(
- [{ key: 'one', tool: tool(), holder: bare }],
- { material: 'AluWrought', maxRpm: 12000 },
- { nextGuid: ids() },
- )
-
- expect(result.library.data).toHaveLength(1)
- expect(result.library.data[0]?.holder).toBeUndefined()
- expect(result.holderWarnings[0]?.reason).toContain('no complete published Fusion shape')
- })
-})
diff --git a/apps/catalog/app/shared/fusion-library.ts b/apps/catalog/app/shared/fusion-library.ts
deleted file mode 100644
index daf5795..0000000
--- a/apps/catalog/app/shared/fusion-library.ts
+++ /dev/null
@@ -1,267 +0,0 @@
-import type { CatalogTool, Collet, Holder } from '@toolpath/catalog-data'
-import { pretoolPresets, type FusionPreset, type PretoolMaterial } from './pretool-presets'
-
-/**
- * The order list as a current Fusion tool library. This module is deliberately
- * pure: the route resolves catalog ids, the dialog supplies shop inputs, and
- * this boundary reports every line Fusion cannot safely receive.
- */
-
-interface Segment {
- readonly height: number
- readonly 'lower-diameter': number
- readonly 'upper-diameter': number
-}
-
-interface FusionHolder {
- readonly guid: string
- readonly type: 'holder'
- readonly unit: 'millimeters'
- readonly description: string
- readonly vendor: string
- readonly 'product-id': string
- readonly gaugeLength: number
- readonly segments: ReadonlyArray
-}
-
-interface FusionTool {
- readonly BMC: 'unspecified'
- readonly guid: string
- readonly type: string
- readonly unit: 'millimeters'
- readonly vendor: string
- readonly 'product-id': string
- readonly 'product-link'?: string
- readonly description: string
- readonly geometry: Readonly>
- readonly holder?: FusionHolder
- readonly 'post-process': {
- readonly number: number
- readonly 'diameter-offset': number
- readonly 'length-offset': number
- readonly live: true
- readonly turret: 0
- readonly 'break-control': false
- readonly 'manual-tool-change': false
- }
- readonly 'start-values': { readonly presets: ReadonlyArray }
-}
-
-export interface FusionLibrary {
- readonly version: 33
- readonly data: ReadonlyArray
-}
-
-/** One distinct stack on the order list, resolved through the current catalog. */
-export interface LibraryLine {
- readonly key: string
- readonly tool: CatalogTool
- readonly holder?: Holder | undefined
- readonly collet?: Collet | undefined
- /** The setout selected for this stack; absent means the catalog's LBH setup value. */
- readonly stickout?: number | undefined
-}
-
-export interface FusionExportSettings {
- readonly material: PretoolMaterial
- readonly maxRpm: number
-}
-
-export interface FusionExportDiagnostic {
- readonly catalogNumber: string
- readonly reason: string
-}
-
-export interface FusionExport {
- readonly library: FusionLibrary
- readonly skipped: ReadonlyArray
- readonly holderWarnings: ReadonlyArray
-}
-
-export interface FusionLibraryOptions {
- /** Injected for deterministic tests; the browser passes crypto.randomUUID. */
- readonly nextGuid?: () => string
-}
-
-const required: Readonly>> = {
- 'flat end mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM'],
- 'ball end mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM'],
- 'bull nose end mill': ['DC', 'LCF', 'OAL', 'NOF', 'RE', 'SFDM'],
- 'face mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM'],
- 'chamfer mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'TA', 'tip-diameter'],
- 'radius mill': ['DC', 'LCF', 'OAL', 'NOF', 'RE', 'SFDM'],
- 'slot mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM'],
- 'thread mill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'TP'],
- drill: ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'SIG'],
- 'center drill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'SIG'],
- 'spot drill': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'SIG'],
- reamer: ['DC', 'LCF', 'OAL', 'NOF', 'SFDM'],
- 'counter sink': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'SIG'],
- 'tap left hand': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'TP'],
- 'tap right hand': ['DC', 'LCF', 'OAL', 'NOF', 'SFDM', 'TP'],
-}
-
-const validNumber = (value: number | null | undefined): value is number =>
- value !== null && value !== undefined && Number.isFinite(value) && value > 0
-
-/** Fusion calls holder steps cylinders, written bottom-up. */
-const segmentsOf = (holder: Holder): Array => {
- const segments: Array = []
- const step = (height: number | null, diameter: number | null) => {
- if (height !== null && height > 0 && diameter !== null && diameter > 0) {
- segments.push({ height, 'lower-diameter': diameter, 'upper-diameter': diameter })
- }
- }
- step(holder.noseLength, holder.noseDiameter)
- step(holder.bodyLength, holder.bodyDiameter)
- if (holder.projection !== null && holder.flangeDiameter !== null) {
- step(
- holder.projection - (holder.noseLength ?? 0) - (holder.bodyLength ?? 0),
- holder.flangeDiameter,
- )
- }
- return segments
-}
-
-const missingGeometry = (tool: CatalogTool): Array => {
- const codes = required[tool.form]
- if (codes === undefined) {
- return [`Fusion does not support the catalog form “${tool.form}”`]
- }
- return codes.filter((code) => !validNumber(tool.geometry[code]))
-}
-
-const geometryOf = (line: LibraryLine): Record => {
- const { tool } = line
- const setout = line.stickout ?? tool.geometry.LBH
- const geometry: Record = {
- CSP: false,
- HAND: true,
- DC: tool.geometry.DC!,
- LCF: tool.geometry.LCF!,
- OAL: tool.geometry.OAL!,
- NOF: tool.geometry.NOF!,
- SFDM: tool.geometry.SFDM!,
- LB: setout!,
- 'shoulder-length': tool.geometry['shoulder-length'] ?? tool.geometry.LCF!,
- 'shoulder-diameter': tool.geometry['shoulder-diameter'] ?? tool.geometry.SFDM!,
- assemblyGaugeLength: setout! + (line.holder?.gaugeLength ?? 0),
- }
- for (const code of ['RE', 'SIG', 'TA', 'TP', 'TPX', 'TPN', 'NT', 'tip-diameter'] as const) {
- const value = tool.geometry[code]
- if (value !== undefined) {
- geometry[code] = value
- }
- }
- if (tool.form === 'face mill') {
- geometry['upper-radius'] = 0
- }
- return geometry
-}
-
-const holderOf = (holder: Holder, nextGuid: () => string): FusionHolder | null => {
- const segments = segmentsOf(holder)
- if (segments.length === 0 || !validNumber(holder.gaugeLength)) {
- return null
- }
- return {
- guid: nextGuid(),
- type: 'holder',
- unit: 'millimeters',
- description: `${holder.brand} ${holder.catalogNumber}`,
- vendor: holder.brand,
- 'product-id': holder.catalogNumber,
- gaugeLength: holder.gaugeLength,
- segments,
- }
-}
-
-/**
- * Creates a schema-ready Fusion v33 document and tells its caller exactly why
- * an ordered stack could not land in it. It never makes up missing vendor
- * geometry: defaults are limited to Fusion's structural fields.
- */
-export const fusionLibrary = (
- lines: ReadonlyArray,
- settings: FusionExportSettings,
- { nextGuid = () => globalThis.crypto.randomUUID() }: FusionLibraryOptions = {},
-): FusionExport => {
- const skipped: Array = []
- const holderWarnings: Array = []
- const data: Array = []
- if (!Number.isFinite(settings.maxRpm) || settings.maxRpm <= 0) {
- return {
- library: { version: 33, data },
- skipped: lines.map((line) => ({
- catalogNumber: line.tool.catalogNumber,
- reason: 'maximum spindle RPM must be greater than zero',
- })),
- holderWarnings,
- }
- }
- for (const line of lines) {
- const missing = missingGeometry(line.tool)
- const stickout = line.stickout ?? line.tool.geometry.LBH
- if (!validNumber(stickout)) {
- missing.push('LBH (selected stickout)')
- }
- if (missing.length > 0) {
- skipped.push({
- catalogNumber: line.tool.catalogNumber,
- reason: missing[0]!.startsWith('Fusion') ? missing[0]! : `missing ${missing.join(', ')}`,
- })
- continue
- }
- const holder = line.holder === undefined ? null : holderOf(line.holder, nextGuid)
- if (line.holder !== undefined && holder === null) {
- holderWarnings.push({
- catalogNumber: line.tool.catalogNumber,
- reason: `holder ${line.holder.catalogNumber} has no complete published Fusion shape`,
- })
- }
- const presets = pretoolPresets(
- {
- form: line.tool.form,
- diameter: line.tool.geometry.DC!,
- fluteLength: line.tool.geometry.LCF!,
- flutes: line.tool.geometry.NOF!,
- cornerRadius: line.tool.geometry.RE ?? 0,
- unit: 'millimeters',
- },
- settings.material,
- settings.maxRpm,
- nextGuid,
- )
- if (presets.length === 0) {
- skipped.push({
- catalogNumber: line.tool.catalogNumber,
- reason: `PreTool has no preset generator for ${line.tool.form}`,
- })
- continue
- }
- const at = data.length + 1
- data.push({
- BMC: 'unspecified',
- guid: nextGuid(),
- type: line.tool.form,
- unit: 'millimeters',
- vendor: line.tool.brand,
- 'product-id': line.tool.catalogNumber,
- ...(line.tool.productLink === null ? {} : { 'product-link': line.tool.productLink }),
- description: `${line.tool.brand} ${line.tool.catalogNumber}`,
- geometry: geometryOf(line),
- ...(holder === null ? {} : { holder }),
- 'post-process': {
- number: at,
- 'diameter-offset': at,
- 'length-offset': at,
- live: true,
- turret: 0,
- 'break-control': false,
- 'manual-tool-change': false,
- },
- 'start-values': { presets },
- })
- }
- return { library: { version: 33, data }, skipped, holderWarnings }
-}
diff --git a/apps/catalog/app/shared/pretool-presets.ts b/apps/catalog/app/shared/pretool-presets.ts
index dd406c4..29a14a2 100644
--- a/apps/catalog/app/shared/pretool-presets.ts
+++ b/apps/catalog/app/shared/pretool-presets.ts
@@ -9,6 +9,33 @@
* catalog has only vendor geometry, so this module is the one place where the
* shop-selected material and spindle ceiling turn that geometry into CAM
* starting values.
+ *
+ * ## Nothing calls this, on purpose
+ *
+ * The Fusion export moved to `@toolpath/tool-support/export/fusion`, which
+ * writes `defaultPreset` from `shared/fusion-input.ts` on every tool — a
+ * placeholder whose every number is 1, there because a tool carrying no preset
+ * is one Fusion refuses to load. This is kept because it is the whole model of
+ * what those numbers should actually be, and re-deriving it is the expensive
+ * part; the way back is the same argument the placeholder already travels on,
+ * `ToolRequest.presets`.
+ *
+ * **Three things to fix before wiring it back**, each found by measuring what
+ * this emits against the five preset shapes that package pins from Autodesk's
+ * schema:
+ *
+ * * `holePreset` serves spot and centre drills as well as drills, and a
+ * `spotting` preset requires five feedrates a `drilling` one does not —
+ * `v_f`, `v_f_leadIn`, `v_f_leadOut`, `v_f_ramp`, `v_f_transition`. Those
+ * presets are refused today and would be silently short otherwise.
+ * * A tap is fed by its own pitch, so a `tapping` preset models no feedrate at
+ * all. The `f_n`, `v_f_plunge`, `v_f_retract` and `use-feed-per-revolution`
+ * this writes onto one are dropped, because Fusion would never show them.
+ * * Everything here is computed in millimetres, and the export now states each
+ * record in the unit system the vendor published in. That package converts
+ * geometry and deliberately does not convert feeds and speeds — which unit a
+ * preset field uses differs per field — so an inch tool needs inch feeds
+ * from here, or its record carries two unit systems at once.
*/
export const PRETOOL_MATERIALS = ['AluWrought', 'LowCSteel', 'StainlessSteel'] as const
diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts
index 94bd744..9fbad34 100644
--- a/apps/catalog/tests/on-the-part.spec.ts
+++ b/apps/catalog/tests/on-the-part.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
+import { readFileSync } from 'node:fs'
import { onThePart, openCube, openCubeWithHole, orderList } from './cube-fixture'
/**
@@ -4743,3 +4744,63 @@ test('never walks the readings with the arrow keys', async ({ page }) => {
await expect(field(page)).toHaveText(named)
})
+
+/**
+ * **The bill leaves as a file Fusion will load.**
+ *
+ * The export was the one shipped feature of this application with no end-to-end
+ * coverage at all: a unit test pinned the document a pure function built, and
+ * nothing checked that pressing the button produced a file. It had already
+ * failed that way once — on 2026-09-01 the download did nothing, because the
+ * page revoked the object URL before the click had used it, which is the bug
+ * `shared/save-file.ts` exists to prevent and which no unit test could see.
+ *
+ * So this asserts the two things only a browser can: that the press yields a
+ * download, and that what comes out is the document rather than a shape
+ * `JSON.stringify` flattened on the way. `LB` carries its decimal point because
+ * `fusionLibraryJson` writes dimensions as floats, and the flute count does not
+ * because a count is an integer — the pair is the whole rule, and the old
+ * exporter got it wrong in both directions by serializing with `JSON.stringify`.
+ */
+test('downloads the order list as a Fusion tool library', async ({ page }) => {
+ await ready(page)
+ const tree = await buildStack(page)
+ await tree.getByRole('button', { name: 'Add to order list' }).click()
+
+ await page.getByRole('link', { name: 'Order list' }).click()
+ await page.getByRole('button', { name: 'Export Fusion library' }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Export Fusion tool library' })
+ await expect(dialog).toBeVisible()
+
+ const waiting = page.waitForEvent('download')
+ await dialog.getByRole('button', { name: 'Download .json' }).click()
+ const download = await waiting
+ expect(download.suggestedFilename()).toBe('tool-library.json')
+
+ const text = readFileSync(await download.path(), 'utf8')
+ const library = JSON.parse(text) as {
+ version: number
+ data: Array>
+ }
+ expect(library.version).toBe(33)
+ expect(library.data).toHaveLength(1)
+
+ const [written] = library.data
+ // One preset, because Fusion will not load a tool carrying none. What is in
+ // it is `defaultPreset`'s business and `shared/fusion-input.test.ts` checks
+ // it per form; what this asserts is that it survived the press.
+ const startValues = written?.['start-values'] as { presets: Array<{ name: string }> }
+ expect(startValues.presets).toHaveLength(1)
+ expect(startValues.presets[0]?.name).toBe('Default Preset')
+ // The holder travelled with the tool, and the assembly closes on it:
+ // `assemblyGaugeLength` is the holder's gauge length plus the stickout.
+ const holder = written?.holder as { gaugeLength: number }
+ const geometry = written?.geometry as Record
+ expect(geometry.assemblyGaugeLength).toBeCloseTo(holder.gaugeLength + geometry.LB!, 6)
+
+ expect(text).toMatch(/"LB": \d+\.\d/)
+ expect(text).toMatch(/"NOF": \d+,/)
+
+ await expect(dialog.getByText(/^Downloaded 1 tool assembly\./)).toBeVisible()
+})
diff --git a/docs/CATALOG-SPEC.md b/docs/CATALOG-SPEC.md
index bf7cd1a..8416cc8 100644
--- a/docs/CATALOG-SPEC.md
+++ b/docs/CATALOG-SPEC.md
@@ -113,9 +113,8 @@ _What all of this was for._
2. Every component is its own row — tool, holder, collet — with its own quantity
and its own way to the vendor's page.
3. Type the quantities.
-4. Press **Export Fusion library**, give the library a name, choose the
- workpiece material and the machine's maximum RPM, then save the whole bill
- as a `.json` library Fusion can import.
+4. Press **Export Fusion library**, give the library a name, then save the
+ whole bill as a `.json` library Fusion can import.
> **Open question — there is no walkthrough for "I already know the tool".**
> Every path above starts from the part. A shop that wants to check whether a
@@ -624,11 +623,18 @@ every render, so it can never disagree with the catalog about a diameter.
- **A line whose tool has left the catalog shows as gone**, not as a stale
number.
- **Export Fusion library** — the whole bill as a Fusion `.json` library. The
- export names the file/library, asks for Aluminum, Low Carbon Steel or
- Stainless Steel and a spindle RPM ceiling, then applies the catalog-local
- PreTool Excel presets. It exports one record per distinct assembly, carries
- its selected setout and published holder shape, and names every omitted tool
- or holder shape rather than inventing vendor geometry.
+ export asks for a name and nothing else. It writes one record per distinct
+ assembly, carrying its selected setout and its holder — the silhouette
+ measured off the vendor's CAD model where the catalog has a complete one, and
+ the published dimensions otherwise — and names every omitted tool or holder
+ shape rather than inventing vendor geometry.
+
+ **The exporter is not this application's.** It is
+ `@toolpath/tool-support/export/fusion`, whose per-type rules are derived from
+ Autodesk's own published JSON Schema and held against it by a test upstream.
+ `app/shared/fusion-input.ts` is the whole seam: it turns this catalog's
+ records into what that package takes and reads its notes back into the
+ dialog. Do not write a second exporter here.
> **Open questions — this page has had the least attention**
>
@@ -638,15 +644,36 @@ every render, so it can never disagree with the catalog about a diameter.
> end mill" would match how the work was decided.
> - **Nothing says what a tool is for.** A line does not name the feature it was
> chosen for, so the reasoning is lost the moment you leave the part page.
-> - **Fusion export uses PreTool's default Excel model.** Its feeds and speeds
-> are CAM starting values for the selected material and spindle ceiling, not a
-> substitute for a shop's own verification.
+> - **The export states no real feeds and speeds.** Every record carries one
+> placeholder preset called `Default Preset` whose every number is 1. It is
+> there because a tool with no preset at all is a tool Fusion will not load:
+> the export went out as `start-values: { presets: [] }` until 2026-09-11, on
+> the reading that the schema wants the key and not a preset in it, and the
+> libraries were refused. Autodesk does ask for one — every `start-values`
+> branch declares `presets: { type: 'array', minLength: 1 }` — but `minLength`
+> is a string keyword and a no-op on an array, so no validator enforces it and
+> nothing here caught it. Of 727 tools across seventeen libraries Fusion itself
+> wrote, every one carries a preset and none carries zero.
+> `app/shared/pretool-presets.ts` is still here and is deliberately
+> unreferenced: it is the whole PreTool model, and the package takes presets
+> back through `ToolRequest.presets` when this application is ready to answer
+> for them. Three things need fixing first, each found by
+> checking PreTool's output against the five preset shapes the package pins:
+> spot and centre drills route through the drill generator and are short of
+> the five feedrates a `spotting` preset requires; a tap states four fields a
+> `tapping` preset does not model; and PreTool computes in millimetres while
+> the export now states each record in the vendor's own unit system.
+> - **A face mill no longer exports.** Fusion requires `DCX`, `RE`, `TA` and
+> `upper-radius` on one, all four of them measurements, and the catalog states
+> none. It used to export with a fabricated `upper-radius: 0` — a record that
+> loaded and stated dimensions nobody measured.
**Where it lives**
- `app/routes/order-list.tsx` — the page
- `app/shared/setup-sheet.ts` — the sheet itself — lines, quantities, storage
-- `app/shared/fusion-library.ts` — the bill as a Fusion library
+- `app/shared/fusion-input.ts` — the bill as `@toolpath/tool-support/export/fusion`
+ takes it, and what its notes mean
- `app/shared/save-file.ts` — saving it from the browser
---
diff --git a/packages/catalog-data/src/toolholding.test.ts b/packages/catalog-data/src/toolholding.test.ts
index 4cd5ef7..b67f910 100644
--- a/packages/catalog-data/src/toolholding.test.ts
+++ b/packages/catalog-data/src/toolholding.test.ts
@@ -325,15 +325,35 @@ describe('a shank a hair off the collet’s size', () => {
* How wide "a hair" is belongs to `@toolpath/tool-support`, and it moved in
* 0.3.1: from a float-artifact's last bit to a thousandth of an inch, which
* is the coarsest last place the vendor catalogs print. Four Kennametal
- * collets were refusing the shank they are sold for. So a 9.5 mm shank —
- * 0.025 mm under a 3/8" collet, inside a thousandth — is now the same shank
- * too, and this pins both sides of that boundary rather than only the float.
+ * collets were refusing the shank they are sold for. This one keeps the
+ * float case the corridor started as; the test below pins both sides of the
+ * width it moved to.
*/
it('is still gripped', () => {
const inch = collet({ guid: 'c', clampMin: 9.525, clampMax: 9.525 })
expect(gripsShank(inch, 9.524999999999999)).toBe(true)
+ })
+
+ /**
+ * The corridor is one thousandth of an inch, and it is
+ * `@toolpath/tool-support`'s number rather than this package's.
+ *
+ * It was `1e-6` mm until 0.3.1 — floating-point noise and nothing else —
+ * which refused a shank a vendor's own two unit columns disagreed about in
+ * their last printed digit. A thou is the coarsest last place these catalogs
+ * print, so it is the width of the artifact rather than a figure picked to
+ * clear it, and `holding.ts` there carries the evidence on both sides.
+ *
+ * Both halves are pinned here because the corridor has to stay a corridor: a
+ * shank inside it is the same shank spelled twice, and one outside it is a
+ * size the vendor meant. `40ERSS0312` is the real case on the far side — a
+ * sealed ER40 collet stating 7.874 mm against a 5/16 in name, 0.0635 mm
+ * undersize in both unit columns, which stays refused.
+ */
+ it('is a thousandth of an inch wide, and no wider', () => {
+ const inch = collet({ guid: 'c', clampMin: 9.525, clampMax: 9.525 })
expect(gripsShank(inch, 9.5)).toBe(true)
- expect(gripsShank(inch, 9.4)).toBe(false)
+ expect(gripsShank(inch, 9.46)).toBe(false)
})
})