From efaa214c267562997f001d847da250ac6485deb6 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Sat, 12 Sep 2026 12:27:46 -0400 Subject: [PATCH] Take the packaged Fusion exporter, and a preset Fusion will load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export moves out of `app/shared/fusion-library.ts` and into `@toolpath/tool-support/export/fusion` 0.4.0, whose per-type rules come from Autodesk's own published JSON Schema rather than from a table written here. `app/shared/fusion-input.ts` is the whole seam that remains: this catalog's records turned into what that package takes, and its `ExportNote`s read back into the dialog. The dialog no longer asks for a workpiece material or a spindle ceiling, because nothing here answers them yet. Every tool now carries one preset, `Default Preset`, with 1 in every numeric field. Libraries exported without one would not import: Fusion refuses a tool whose `start-values.presets` is empty. The schema does ask for a preset — 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 the earlier reading, that Fusion wants the key and not a preset in it, went unchallenged. Of 727 tools across seventeen libraries Fusion itself wrote, every one carries a preset and none carries zero. What is written is the union of what all five preset shapes require, narrowed per type by `fusionPresets`: a tap goes out with its six fields, a drill with eleven and no cutting feedrate, a milling tool with twenty. `pretool-presets.ts` is still the model for what those numbers should be and is still unwired; it goes back through the same `ToolRequest.presets` the placeholder travels on. The bump carries 0.3.1's collet grip tolerance, which widened from 1e-6 mm to a thousandth of an inch, so `toolholding.test.ts` pins both sides of the corridor. --- AGENTS.md | 12 + .../components/fusion-export-dialog.test.tsx | 55 +-- .../app/components/fusion-export-dialog.tsx | 84 +---- apps/catalog/app/routes/order-list.tsx | 97 ++--- apps/catalog/app/shared/fusion-input.test.ts | 349 ++++++++++++++++++ apps/catalog/app/shared/fusion-input.ts | 329 +++++++++++++++++ .../catalog/app/shared/fusion-library.test.ts | 176 --------- apps/catalog/app/shared/fusion-library.ts | 267 -------------- apps/catalog/app/shared/pretool-presets.ts | 27 ++ apps/catalog/package.json | 2 +- apps/catalog/tests/on-the-part.spec.ts | 61 +++ apps/dfm/package.json | 2 +- docs/CATALOG-SPEC.md | 51 ++- packages/catalog-data/package.json | 2 +- packages/catalog-data/src/toolholding.test.ts | 23 +- packages/part-contracts/package.json | 2 +- pnpm-lock.yaml | 30 +- 17 files changed, 945 insertions(+), 624 deletions(-) create mode 100644 apps/catalog/app/shared/fusion-input.test.ts create mode 100644 apps/catalog/app/shared/fusion-input.ts delete mode 100644 apps/catalog/app/shared/fusion-library.test.ts delete mode 100644 apps/catalog/app/shared/fusion-library.ts diff --git a/AGENTS.md b/AGENTS.md index 77ada26..c2674b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,6 +175,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 @@ -272,6 +283,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.

-
- Workpiece material -
- {PRETOOL_MATERIALS.map((each) => ( - - ))} -
-
- {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/package.json b/apps/catalog/package.json index 46494ce..ae2525f 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -24,7 +24,7 @@ "@toolpath/part-contracts": "workspace:*", "@toolpath/part-server": "workspace:*", "@toolpath/tool-drawing": "1.0.0", - "@toolpath/tool-support": "0.3.0", + "@toolpath/tool-support": "0.4.0", "@toolpath/ui": "1.0.0", "@toolpath/viewer": "1.1.1", "hono": "4.13.0", diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index 2d845c2..21bc274 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' /** @@ -4012,3 +4013,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/apps/dfm/package.json b/apps/dfm/package.json index 26bac5b..508c7ce 100644 --- a/apps/dfm/package.json +++ b/apps/dfm/package.json @@ -21,7 +21,7 @@ "@react-three/fiber": "9.7.0", "@toolpath/api": "0.4.1", "@toolpath/app-support": "0.1.3", - "@toolpath/tool-support": "0.3.0", + "@toolpath/tool-support": "0.4.0", "@toolpath/ui": "1.0.0", "@toolpath/viewer": "1.1.1", "eventsource-parser": "4.0.0", diff --git a/docs/CATALOG-SPEC.md b/docs/CATALOG-SPEC.md index 99cc8d9..22875b7 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 @@ -613,11 +612,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** > @@ -627,15 +633,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/package.json b/packages/catalog-data/package.json index 6742be0..9cefe78 100644 --- a/packages/catalog-data/package.json +++ b/packages/catalog-data/package.json @@ -47,7 +47,7 @@ "dependencies": { "@toolpath/part-contracts": "workspace:*", "@toolpath/tool-scraper": "3.0.0", - "@toolpath/tool-support": "0.3.0" + "@toolpath/tool-support": "0.4.0" }, "devDependencies": { "@types/node": "24.10.1", diff --git a/packages/catalog-data/src/toolholding.test.ts b/packages/catalog-data/src/toolholding.test.ts index c28d04f..fa78dfe 100644 --- a/packages/catalog-data/src/toolholding.test.ts +++ b/packages/catalog-data/src/toolholding.test.ts @@ -322,7 +322,28 @@ describe('a shank a hair off the collet’s size', () => { it('is still gripped', () => { const inch = collet({ guid: 'c', clampMin: 9.525, clampMax: 9.525 }) expect(gripsShank(inch, 9.524999999999999)).toBe(true) - expect(gripsShank(inch, 9.5)).toBe(false) + }) + + /** + * 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.46)).toBe(false) }) }) diff --git a/packages/part-contracts/package.json b/packages/part-contracts/package.json index 6af44fd..b9d3fa0 100644 --- a/packages/part-contracts/package.json +++ b/packages/part-contracts/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@toolpath/api": "0.4.1", - "@toolpath/tool-support": "0.3.0", + "@toolpath/tool-support": "0.4.0", "@toolpath/viewer": "1.1.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ac662..a1237b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,8 +72,8 @@ importers: specifier: 1.0.0 version: 1.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/tool-support': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.4.0 + version: 0.4.0 '@toolpath/ui': specifier: 1.0.0 version: 1.0.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.0))(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.3.3) @@ -181,8 +181,8 @@ importers: specifier: 0.1.3 version: 0.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/tool-support': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.4.0 + version: 0.4.0 '@toolpath/ui': specifier: 1.0.0 version: 1.0.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.0))(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.3.3) @@ -281,8 +281,8 @@ importers: specifier: 3.0.0 version: 3.0.0 '@toolpath/tool-support': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.4.0 + version: 0.4.0 devDependencies: '@types/node': specifier: 24.10.1 @@ -322,8 +322,8 @@ importers: specifier: 0.4.1 version: 0.4.1 '@toolpath/tool-support': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.4.0 + version: 0.4.0 '@toolpath/viewer': specifier: 1.1.1 version: 1.1.1(@react-three/drei@10.7.8(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1))(@types/react@19.2.18)(@types/three@0.185.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1))(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1) @@ -1320,14 +1320,14 @@ packages: engines: {node: '>=20'} hasBin: true - '@toolpath/tool-support@0.3.0': - resolution: {integrity: sha512-7sv9UMTgEyOIBKTUhlbgOtUJ5tOA10G61crfRZcO8BILooWzfw/V2cUTOt35M15WyLnxMu/uPX1pBY1eO8nziw==} - engines: {node: '>=20'} - '@toolpath/tool-support@0.3.1': resolution: {integrity: sha512-X2sTebw0liETcmbypaz2I/KYeO2N4oQCN/pHhgkMzNfbeSaCwMkJy5UiukbksAjWJfeH56AcfZrjLMX/aMSCig==} engines: {node: '>=20'} + '@toolpath/tool-support@0.4.0': + resolution: {integrity: sha512-LVyh/qwcV8izuzKgdfbnUdgGcCPZzxnfFqYlHGF18WO6tAETb/NB7jVgi9idA+13lWM2WFZoq4O/A+apWMuvbA==} + engines: {node: '>=20'} + '@toolpath/ui@1.0.0': resolution: {integrity: sha512-5SM/z0uKaA2cfeQDOUK+gVmBbVKQY06VzKP335HAXi5So0tZ0BnGQoEO+oMfPgvAebs6fwsLmdK/4nENBaX6Zg==} engines: {node: '>=20'} @@ -3976,7 +3976,7 @@ snapshots: '@toolpath/app-support@0.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@toolpath/tool-support': 0.3.0 + '@toolpath/tool-support': 0.3.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -3991,10 +3991,10 @@ snapshots: '@toolpath/tool-support': 0.3.1 htmlparser2: 12.0.0 - '@toolpath/tool-support@0.3.0': {} - '@toolpath/tool-support@0.3.1': {} + '@toolpath/tool-support@0.4.0': {} + '@toolpath/ui@1.0.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.0))(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.3.3)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)