diff --git a/AGENTS.md b/AGENTS.md index 2b4d0b9..97375f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,6 +220,21 @@ application unless that application says otherwise. `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. + **Nor is the Mastercam export** (2026-09-12) — it is + `@toolpath/tool-support/export/mastercam`, and a `.TOOLDB` is a SQLite + database carrying Mastercam's own 79-table schema, pinned from a real library + upstream. `app/shared/mastercam-input.ts` is that seam, and it reads + differently from the Fusion one on purpose: Fusion embeds a holder inside the + tool record, so one tool in two holders is two records, where Mastercam joins + them relationally — so the order list goes in **grouped**, one entry per + distinct tool carrying one set-up per stack, under the catalog's own guids. + The one identifier this application mints is the assembly's, per stack, and + that module says why. What both formats read is `app/shared/export-input.ts`; + one dialog serves both, `app/components/library-export-dialog.tsx`, and the + format supplies its four strings. **The exporter is imported on the press**, + not at the top of the route: it carries 82 KB of generated schema, and + `vite.config.ts` pre-bundles the subpath so that import is a fetch rather + than a discovery. **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 @@ -317,7 +332,10 @@ 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 | +| what both exporters read of a row | `app/shared/export-input.ts` | | the bill as a Fusion library, and what its notes say | `app/shared/fusion-input.ts` | +| the bill as a Mastercam `.TOOLDB`, and its notes | `app/shared/mastercam-input.ts` | +| the one dialog both exports ask their question in | `app/components/library-export-dialog.tsx` | | 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 deleted file mode 100644 index 13389b2..0000000 --- a/apps/catalog/app/components/fusion-export-dialog.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { FusionExportDialog } from './fusion-export-dialog' - -describe('FusionExportDialog', () => { - it('passes the trimmed library name to export and reports what landed', async () => { - const onExport = vi.fn().mockResolvedValue({ exported: 1, skipped: [], holderWarnings: [] }) - 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 deleted file mode 100644 index 7f4408a..0000000 --- a/apps/catalog/app/components/fusion-export-dialog.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useState } from 'react' -import { Button, Card, Input } from '@toolpath/ui' -import type { FusionReport } from 'shared/fusion-input' -import { useEscape } from 'shared/use-escape' -import { SECTION_LABEL } from 'shared/type' - -export interface FusionExportDialogProps { - readonly initialName: string - readonly onCancel: () => void - readonly onExport: (name: string) => Promise -} - -/** - * 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 = ({ - initialName, - onCancel, - onExport, -}: FusionExportDialogProps) => { - const [name, setName] = useState(initialName) - const [working, setWorking] = useState(false) - const [result, setResult] = useState(null) - const canExport = name.trim() !== '' && !working - - useEscape(true, onCancel) - - const submit = async () => { - if (!canExport) { - return - } - setWorking(true) - try { - setResult(await onExport(name.trim())) - } finally { - setWorking(false) - } - } - - return ( -
{ - if (event.target === event.currentTarget && !working) { - onCancel() - } - }} - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" - > - -
-

Fusion tool library

-

- Every assembly on this bill, with its holder. Feeds and speeds are left for Fusion. -

-
-
- - {result === null ? null : ( -
-

- {result.exported === 0 - ? 'No Fusion library was downloaded.' - : `Downloaded ${result.exported} tool ${result.exported === 1 ? 'assembly' : 'assemblies'}.`} -

- {result.skipped.map((each) => ( -

- {each.catalogNumber} skipped — {each.reason} -

- ))} - {result.holderWarnings.map((each) => ( -

- {each.catalogNumber} — {each.reason}. -

- ))} -
- )} -
-
- - -
-
-
- ) -} diff --git a/apps/catalog/app/components/library-export-dialog.test.tsx b/apps/catalog/app/components/library-export-dialog.test.tsx new file mode 100644 index 0000000..e159144 --- /dev/null +++ b/apps/catalog/app/components/library-export-dialog.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FUSION_FORMAT, LibraryExportDialog, MASTERCAM_FORMAT } from './library-export-dialog' + +describe('LibraryExportDialog', () => { + it('passes the trimmed library name to export and reports what landed', async () => { + const onExport = vi.fn().mockResolvedValue({ exported: 1, skipped: [], warnings: [] }) + 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 the CAM system 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' }], + warnings: [{ 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() + }) + + /** + * The whole point of the generalisation: one dialog, and the format decides + * its four strings. A second copy of this component is what this pins against. + */ + /** + * `aria-modal` is a promise about the keyboard, and these are the three + * halves of keeping it. They are pinned because the markup makes the claim + * whether or not the behaviour is there, so nothing else would notice it + * going: a reader would simply be told the page behind is inert while Tab + * walked onto it. + */ + describe('the focus it holds while it is open', () => { + it('puts the caret in the name, so the dialog is usable without a mouse', () => { + render( + , + ) + + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Library name' })) + }) + + it('keeps Tab inside itself rather than letting it walk onto the page behind', () => { + render( + , + ) + + const download = screen.getByRole('button', { name: 'Download .json' }) + download.focus() + fireEvent.keyDown(document, { key: 'Tab' }) + + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Library name' })) + }) + + it('hands the focus back to whatever opened it', () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + + const view = render( + , + ) + expect(document.activeElement).not.toBe(opener) + + view.unmount() + + expect(document.activeElement).toBe(opener) + opener.remove() + }) + }) + + it('takes its name, its extension and its blurb from the format it is given', async () => { + const onExport = vi.fn().mockResolvedValue({ exported: 0, skipped: [], warnings: [] }) + render( + , + ) + + expect( + screen.getByRole('dialog', { name: 'Export Mastercam tool library' }), + ).toBeInTheDocument() + expect(screen.getByText('Mastercam tool library')).toBeInTheDocument() + expect( + screen.getByText('Mastercam will use this filename for the library.'), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Download .TOOLDB' })) + + expect(await screen.findByText('No Mastercam library was downloaded.')).toBeInTheDocument() + }) +}) diff --git a/apps/catalog/app/components/library-export-dialog.tsx b/apps/catalog/app/components/library-export-dialog.tsx new file mode 100644 index 0000000..fb4d46d --- /dev/null +++ b/apps/catalog/app/components/library-export-dialog.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef, useState } from 'react' +import { Button, Card, Input } from '@toolpath/ui' +import type { ExportReport } from 'shared/export-input' +import { useEscape } from 'shared/use-escape' +import { SECTION_LABEL } from 'shared/type' + +/** Everything one CAM format changes about this dialog, and nothing else. */ +export interface LibraryFormat { + /** The format in a machinist's words — `Fusion`, `Mastercam`. */ + readonly name: string + /** What the file is called at the end, `.json` or `.TOOLDB`. */ + readonly extension: string + /** One line under the heading saying what is about to be written. */ + readonly blurb: string +} + +export interface LibraryExportDialogProps { + readonly format: LibraryFormat + readonly initialName: string + readonly onCancel: () => void + readonly onExport: (name: string) => Promise +} + +/** + * 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. + * + * **It serves both formats** (2026-09-12). Only four strings differ between a + * Fusion export and a Mastercam one — the name, the extension, the blurb and + * what the report is called — so a second copy of this would be a hundred and + * thirty lines kept in step by hand, and the first thing to fall out of step is + * whichever of the two nobody used that week. `ExportReport` is shared for the + * same reason and is why `exported` means the same quantity in both: rows of + * the order list that reached the file. + */ +export const LibraryExportDialog = ({ + format, + initialName, + onCancel, + onExport, +}: LibraryExportDialogProps) => { + const [name, setName] = useState(initialName) + const [working, setWorking] = useState(false) + const [result, setResult] = useState(null) + const canExport = name.trim() !== '' && !working + const surface = useRef(null) + + useEscape(true, onCancel) + + /** + * The keyboard a modal owes whoever opened it, which `aria-modal` is a + * promise of. + * + * **The kit's `Dialog` is the component to reach for here and does not + * fit.** It is an alert: `Dialog.Provider` hands out `confirm()`, which + * resolves to a boolean and closes on the press, where this dialog has a + * name to read back off an input and a report to go on showing afterwards. + * Its underlying `@base-ui/react` dialog is not a dependency of this + * application. So the focus is written here — once, and beside the markup it + * governs — rather than left out. + * + * Left out is what it was. `aria-modal` tells a screen reader the page + * behind this is inert, and Tab walked straight onto it: the order list's + * own controls took the focus while the reader still announced the dialog. + * A claim the markup does not keep is worse than no claim, so this keeps it: + * the name takes the focus on open, Tab cycles within the dialog, and whatever + * opened the dialog gets the focus back when it goes. + */ + useEffect(() => { + const opener = document.activeElement + const overlay = surface.current + overlay?.querySelector('#library-export-name')?.focus() + + const hold = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || overlay === null) { + return + } + const stops = Array.from( + overlay.querySelectorAll( + 'input, button, a[href], [tabindex]:not([tabindex="-1"])', + ), + ).filter((stop) => !stop.hasAttribute('disabled')) + const first = stops[0] + const last = stops[stops.length - 1] + if (first === undefined || last === undefined) { + return + } + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + return + } + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + + document.addEventListener('keydown', hold) + return () => { + document.removeEventListener('keydown', hold) + if (opener instanceof HTMLElement) { + opener.focus() + } + } + }, []) + + const submit = async () => { + if (!canExport) { + return + } + setWorking(true) + try { + setResult(await onExport(name.trim())) + } finally { + setWorking(false) + } + } + + return ( +
{ + if (event.target === event.currentTarget && !working) { + onCancel() + } + }} + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" + > + +
+

{format.name} tool library

+

{format.blurb}

+
+
+ + {result === null ? null : ( +
+

+ {result.exported === 0 + ? `No ${format.name} library was downloaded.` + : `Downloaded ${result.exported} tool ${result.exported === 1 ? 'assembly' : 'assemblies'}.`} +

+ {result.skipped.map((each) => ( +

+ {each.catalogNumber} skipped — {each.reason} +

+ ))} + {result.warnings.map((each) => ( +

+ {each.catalogNumber} — {each.reason}. +

+ ))} +
+ )} +
+
+ + +
+
+
+ ) +} + +/** The two formats this catalog writes, in the words the dialog shows. */ +export const FUSION_FORMAT: LibraryFormat = { + name: 'Fusion', + extension: '.json', + blurb: 'Every assembly on this bill, with its holder. Feeds and speeds are left for Fusion.', +} + +export const MASTERCAM_FORMAT: LibraryFormat = { + name: 'Mastercam', + extension: '.TOOLDB', + blurb: 'Every assembly on this bill, with its holder. Feeds and speeds are left for Mastercam.', +} diff --git a/apps/catalog/app/routes/order-list.tsx b/apps/catalog/app/routes/order-list.tsx index cdaff7c..b85887d 100644 --- a/apps/catalog/app/routes/order-list.tsx +++ b/apps/catalog/app/routes/order-list.tsx @@ -13,7 +13,11 @@ import { Badge, Button, Card, IconButton, cn, Input } from '@toolpath/ui' import { formatLength, type UnitSystem } from '@toolpath/tool-support' import type { CatalogTool, Collet, Holder } from '@toolpath/catalog-data' import { AppHeader } from 'components/app-header' -import { FusionExportDialog } from 'components/fusion-export-dialog' +import { + FUSION_FORMAT, + LibraryExportDialog, + MASTERCAM_FORMAT, +} from 'components/library-export-dialog' import { ColletIcon, HolderIcon, ToolTypeIcon, formLabel } from './../components/tool-icons' import { allTools, getCollet, getHolder, getProfile, getTool } from 'shared/catalog' import { @@ -45,6 +49,8 @@ import { sanitizeName, } from '@toolpath/tool-support/export/fusion' import { fusionInput, fusionReport } from 'shared/fusion-input' +import { mastercamInput, mastercamReport } from 'shared/mastercam-input' +import type { OrderedStack } from 'shared/export-input' import { saveInBrowser } from 'shared/save-file' import { recallPart } from 'shared/part-session' import { useUnit } from 'shared/use-unit' @@ -506,20 +512,20 @@ const Bom = () => { /** Which way the list is read: by assembly, or by what the assemblies come to. */ const [view, setView] = useState<'assembly' | 'components'>('assembly') - const [fusionDialogOpen, setFusionDialogOpen] = useState(false) + /** Which library is being asked for, or none. */ + const [exporting, setExporting] = useState<'fusion' | 'mastercam' | null>(null) /** - * The whole bill as a Fusion library, saved from the browser. + * The bill resolved through the catalog, as either exporter takes it. * * 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. + * resolves them and nothing more — `shared/export-input.ts` is what both + * formats read, and one module per format turns it into what that exporter + * takes and reads its notes back. */ - const downloadFusion = async (name: string) => { - const requests = fusionInput( + const stacks = useMemo>( + () => assemblies.flatMap(({ key, choice }) => { const tool = getTool(choice.toolGuid) if (tool === null) { @@ -535,7 +541,17 @@ const Bom = () => { }, ] }), - ) + [assemblies], + ) + + /** + * The whole bill as a Fusion library, saved from the browser. + * + * The exporter is `@toolpath/tool-support/export/fusion`, whose rules come + * from Autodesk's own schema rather than from anything written here. + */ + const downloadFusion = async (name: string) => { + const requests = fusionInput(stacks) const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request), }) @@ -546,6 +562,30 @@ const Bom = () => { return report } + /** + * The same bill as a Mastercam `.TOOLDB`, which is a SQLite database. + * + * **Imported on the press rather than at the top of the file.** The exporter + * carries Mastercam's own 79-table schema — 82 KB of generated source before + * a single tool — and every visitor to this page would otherwise download it + * to find out whether they wanted it. `vite.config.ts` pre-bundles the + * subpath so that this import is a fetch rather than a discovery. + * + * `sanitizeName` is Fusion's and is used on both: it is a pure string + * function about filenames, and a second copy of it here would be a second + * opinion about what a filename may contain. + */ + const downloadMastercam = async (name: string) => { + const { request, stacks: rows } = mastercamInput(stacks) + const { mastercamLibrary } = await import('@toolpath/tool-support/export/mastercam') + const { document, notes } = mastercamLibrary(request) + const report = mastercamReport(rows, notes) + if (report.exported > 0) { + saveInBrowser(`${sanitizeName(name)}.TOOLDB`, document, 'application/vnd.sqlite3') + } + return report + } + return (
@@ -602,17 +642,32 @@ const Bom = () => { ))} {assemblies.length === 0 ? null : ( - + /* + **One bill, two CAM systems** (Justin, 2026-09-12). A shop runs + the post it runs, and which one is not this page's business, so + neither button is the default and neither is behind the other. + */ + + {( + [ + ['fusion', 'Export Fusion library', FUSION_FORMAT.name], + ['mastercam', 'Export Mastercam library', MASTERCAM_FORMAT.name], + ] as const + ).map(([format, label, name]) => ( + + ))} + )}

@@ -900,13 +955,14 @@ const Bom = () => {
- {fusionDialogOpen ? ( - setFusionDialogOpen(false)} - onExport={downloadFusion} + onCancel={() => setExporting(null)} + onExport={exporting === 'fusion' ? downloadFusion : downloadMastercam} /> - ) : null} + )}
) } diff --git a/apps/catalog/app/shared/export-input.ts b/apps/catalog/app/shared/export-input.ts new file mode 100644 index 0000000..1ac8b39 --- /dev/null +++ b/apps/catalog/app/shared/export-input.ts @@ -0,0 +1,107 @@ +import type { CatalogTool, Holder, HolderProfile } from '@toolpath/catalog-data' +import type { HolderProfile as DomainProfile } from '@toolpath/tool-support' + +/** + * What the order list looks like on its way into somebody else's tool library, + * whichever library that is. + * + * `fusion-input.ts` was the whole seam while Fusion was the only format. The + * Mastercam exporter landed on 2026-09-12 taking the same `CatalogTool` and the + * same `Holder | HolderProfile` union — `@toolpath/tool-support/export/catalog` + * is shared between them by design — so what both adapters read is here and + * what each format decides stays in its own module. + * + * The rule in AGENTS.md is that the second consumer is the trigger and that a + * copy is a divergence with a delay on it. This is that extraction, inside one + * application rather than out to a package: everything below still reaches for + * this catalog's own record types, which is exactly the coupling that keeps it + * out of `packages/`. + */ + +/** 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 an 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. Both exporters cut it at their 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 a CAM + * system 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. + */ +export 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, + } +} + +/** + * A guid per exported record, minted here rather than taken from the catalog. + * + * **Neither exporter mints one**, and both are right not to: reusing a catalog + * guid is what makes a re-exported library update a tool in a CAM system + * instead of adding a second copy of it. But an order list is not a catalog, + * and the two formats need different things from that fact — see + * `fusion-input.ts` and `mastercam-input.ts`, each of which says what it mints + * and why. This is the seam both take it through, so a test can hand them a + * counter and say which record is which. + */ +export type MintGuid = () => string + +export const browserGuid: MintGuid = () => globalThis.crypto.randomUUID() + +/** One thing worth telling whoever pressed the button, in their words. */ +export interface ExportDiagnostic { + readonly catalogNumber: string + readonly reason: string +} + +/** + * What an export has to say for itself, in the terms the dialog shows. + * + * `warnings` is the middle case and the one worth naming carefully: the record + * went out, and something about it did not travel with it. A holder whose gauge + * length nobody published, a tool set up at two carousel positions when the + * format has room for one. Neither is a failure and neither is nothing. + */ +export interface ExportReport { + readonly exported: number + readonly skipped: ReadonlyArray + readonly warnings: ReadonlyArray +} diff --git a/apps/catalog/app/shared/fusion-input.test.ts b/apps/catalog/app/shared/fusion-input.test.ts index e6fbba7..4d7405f 100644 --- a/apps/catalog/app/shared/fusion-input.test.ts +++ b/apps/catalog/app/shared/fusion-input.test.ts @@ -5,7 +5,8 @@ import { fusionLibrary, fusionLibraryJson, } from '@toolpath/tool-support/export/fusion' -import { DEFAULT_PRESET_NAME, fusionInput, fusionReport, type OrderedStack } from './fusion-input' +import { DEFAULT_PRESET_NAME, fusionInput, fusionReport } from './fusion-input' +import type { OrderedStack } from './export-input' /** * The seam between this catalog and `@toolpath/tool-support/export/fusion`. @@ -202,7 +203,7 @@ describe('what the export has to say for itself', () => { expect(report.exported).toBe(1) expect(report.skipped).toEqual([]) - expect(report.holderWarnings).toEqual([]) + expect(report.warnings).toEqual([]) }) it('names the tool Fusion refused, by the number a machinist orders it under', () => { @@ -222,15 +223,38 @@ describe('what the export has to say for itself', () => { expect(report.skipped[0]?.reason).toContain('RE') }) - it('passes on what the holder could not say, against the tool it was on', () => { + /** + * A holder note reaches the report under the tool it was raised against, + * because a holder guid is not what a machinist orders anything by. + * + * The case used to be a holder stating no gauge length at all. That stopped + * being a warning in `@toolpath/tool-support` 0.5.0: where the exporter once + * `dropped` the gauge length and left Fusion to ask, it now `filled`s it with + * the height of the shape it exported, which is a real number rather than a + * gap. A `filled` note is the format's own convention and is deliberately not + * surfaced — the test below this one is the rule — so the case that still + * raises something a shop has to act on is a holder whose stated gauge length + * its own published dimensions contradict. + */ + it('passes on what the holder got wrong, against the tool it was on', () => { + const longGauge: Holder = { ...holder, gaugeLength: 80 } + const requests = fusionInput(oneStack({ holder: longGauge }), counting()) + const { document, notes } = fusionLibrary({ tools: requests.map((each) => each.request) }) + const report = fusionReport(requests, document, notes) + + expect(report.exported).toBe(1) + expect(report.warnings[0]?.catalogNumber).toBe('TDMX0600') + expect(report.warnings[0]?.reason).toContain('gauge line') + }) + + it('says nothing where the holder simply never stated one', () => { 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') + expect(report.warnings).toEqual([]) }) it('does not surface what the exporter merely filled in', () => { @@ -242,7 +266,7 @@ describe('what the export has to say for itself', () => { expect(notes.some((note) => note.kind === 'filled')).toBe(true) const report = fusionReport(requests, document, notes) - expect(report.skipped.length + report.holderWarnings.length).toBe(0) + expect(report.skipped.length + report.warnings.length).toBe(0) }) }) @@ -344,6 +368,6 @@ describe('the preset every tool carries', () => { const report = fusionReport(requests, document, notes) expect(report.skipped).toEqual([]) - expect(report.holderWarnings).toEqual([]) + expect(report.warnings).toEqual([]) }) }) diff --git a/apps/catalog/app/shared/fusion-input.ts b/apps/catalog/app/shared/fusion-input.ts index e4b7a9e..b8f0daf 100644 --- a/apps/catalog/app/shared/fusion-input.ts +++ b/apps/catalog/app/shared/fusion-input.ts @@ -1,5 +1,4 @@ -import type { CatalogTool, Holder, HolderProfile } from '@toolpath/catalog-data' -import type { HolderProfile as DomainProfile } from '@toolpath/tool-support' +import type { Holder } from '@toolpath/catalog-data' import type { ExportNote } from '@toolpath/tool-support/export' import type { CatalogHolder, @@ -7,16 +6,27 @@ import type { FusionLibrary, ToolRequest, } from '@toolpath/tool-support/export/fusion' +import { + browserGuid, + measuredShape, + type ExportDiagnostic, + type ExportReport, + type MintGuid, + type OrderedStack, +} from 'shared/export-input' /** * 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. + * **This is the Fusion half of the 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. + * + * What this format and Mastercam's ask for in the same words is + * `shared/export-input.ts`; `shared/mastercam-input.ts` is the other half. * * The route resolves guids through `catalog.ts` and hands the records over; * nothing here reaches for the dataset. @@ -33,59 +43,6 @@ import type { * 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. @@ -106,23 +63,6 @@ export interface StackRequest { 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. * @@ -268,18 +208,6 @@ export const fusionInput = ( } }) -/** 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. * @@ -298,10 +226,10 @@ export const fusionReport = ( requests: ReadonlyArray, document: FusionLibrary, notes: ReadonlyArray, -): FusionReport => { +): ExportReport => { const written = new Set(document.data.map((record) => record.guid)) - const skipped: Array = [] - const holderWarnings: Array = [] + const skipped: Array = [] + const warnings: Array = [] for (const request of requests) { const { catalogNumber, toolGuid, holderGuid } = request @@ -320,10 +248,10 @@ export const fusionReport = ( } for (const note of notes) { if (note.subject === holderGuid && note.kind === 'dropped') { - holderWarnings.push({ catalogNumber, reason: note.message }) + warnings.push({ catalogNumber, reason: note.message }) } } } - return { exported: document.data.length, skipped, holderWarnings } + return { exported: document.data.length, skipped, warnings } } diff --git a/apps/catalog/app/shared/mastercam-input.test.ts b/apps/catalog/app/shared/mastercam-input.test.ts new file mode 100644 index 0000000..faade08 --- /dev/null +++ b/apps/catalog/app/shared/mastercam-input.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest' +import type { CatalogTool, Holder, HolderProfile } from '@toolpath/catalog-data' +import { mastercamLibrary } from '@toolpath/tool-support/export/mastercam' +import type { ExportNote } from '@toolpath/tool-support/export' +import { mastercamInput, mastercamReport, type StackRequest } from './mastercam-input' +import type { OrderedStack } from './export-input' + +/** + * The seam between this catalog and `@toolpath/tool-support/export/mastercam`. + * + * The exporter's own rules are tested where they live, against Mastercam's + * pinned schema. What is tested here is only what this application decides: + * which of the order list's rows become one tool and which become two, 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: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + 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: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + 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, +} + +const otherHolder: Holder = { + ...holder, + guid: '3f2504e0-4f89-41d3-9a0c-0305e82c3302', + catalogNumber: 'BT40-ER32-150', + projection: 100, +} + +/** `[z, r]`, z running from the gage line toward the cutting end. */ +const profile: HolderProfile = { + guid: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + 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 assembly is which. + * + * The fixtures above use real uuids rather than readable names on purpose: + * Mastercam stores every identifier as sixteen bytes, so the exporter throws on + * anything that is not one. Every guid in the catalog is a `uuid5` minted by + * `@toolpath/tool-scraper` — all seventeen records of the committed sample are + * — and a fixture spelled `cat-tool-1` would test a shape the application never + * hands it. The same is true of what a {@link MintGuid} returns: production + * mints with `crypto.randomUUID`, and a counter returning `guid-1` would pass + * every assertion below while the real exporter refused it. + */ +const MINTED_PREFIX = ['00000000', '0000', '4000', '8000'].join('-') + +/** The nth guid {@link counting} mints, for an expectation to name. */ +const minted = (nth: number) => `${MINTED_PREFIX}-${String(nth).padStart(12, '0')}` + +const counting = () => { + let at = 0 + return () => { + at += 1 + return minted(at) + } +} + +const oneStack = (over: Partial = {}): Array => [ + { key: 'row-1', tool: endMill, ...over }, +] + +describe('the tool an order-list row becomes', () => { + const { request } = mastercamInput(oneStack({ holder }), counting()) + const [only] = request.tools ?? [] + + 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?.tool.unit).toBe('millimeters') + }) + + it('states the vendor, the order number and the link the catalog holds', () => { + expect(only?.tool.vendor).toBe('Example') + expect(only?.tool.catalogNumber).toBe('TDMX0600') + expect(only?.tool.productLink).toBe('https://example.test/TDMX0600') + expect(only?.tool.description).toBe('Example TDMX0600') + }) + + it('turns a catalog null into an absent field, not an empty string', () => { + const { request: without } = mastercamInput( + oneStack({ tool: { ...endMill, productLink: null }, holder }), + counting(), + ) + expect(without.tools?.[0]?.tool).not.toHaveProperty('productLink') + }) + + it('falls back to the catalog’s own LBH when no stickout was chosen', () => { + expect(only?.assemblies?.[0]?.stickout).toBe(24) + }) + + it('prefers the stickout the stack was set up at', () => { + const { request: chosen } = mastercamInput(oneStack({ holder, stickout: 31 }), counting()) + expect(chosen.tools?.[0]?.assemblies?.[0]?.stickout).toBe(31) + }) + + it('numbers the carousel by the row’s place on the list, on the set-up', () => { + // Mastercam holds a tool number per assembly rather than per tool, which is + // what lets one end mill sit at two positions without a conflict. + const { request: two } = mastercamInput( + [ + { key: 'row-1', tool: endMill, holder }, + { + key: 'row-2', + tool: { ...endMill, guid: '6f9619ff-8b86-d011-b42d-00cf4fc964f2' }, + holder, + }, + ], + counting(), + ) + expect(two.tools?.flatMap((each) => each.assemblies ?? []).map((each) => each.number)).toEqual([ + 1, 2, + ]) + }) + + it('leaves a tool with no holder decided as a tool with no assembly', () => { + // Deciding the cutter and leaving the holding for later is a real state of + // a job; the tool still belongs in the library. + const { request: bare } = mastercamInput(oneStack(), counting()) + expect(bare.tools).toHaveLength(1) + expect(bare.tools?.[0]?.assemblies).toEqual([]) + expect(bare.holders).toEqual([]) + }) +}) + +describe('identity is the catalog’s, and only the assembly is minted', () => { + const { request } = mastercamInput( + [ + { key: 'row-1', tool: endMill, holder }, + { key: 'row-2', tool: endMill, holder: otherHolder }, + ], + counting(), + ) + + it('gives one tool in two holders one tool entry and two set-ups', () => { + // The whole reason this reads differently from `fusion-input.ts`. A Fusion + // library embeds the holder in the tool record and needs two records; + // Mastercam joins them relationally, so six stacks of one end mill are one + // `TlTool` row and six assemblies — which is what a machinist sees in + // Mastercam's own tree. + expect(request.tools).toHaveLength(1) + expect(request.tools?.[0]?.assemblies).toHaveLength(2) + }) + + it('carries the catalog’s own guids for the tool and its holders', () => { + // Minting these is what would make a re-export look like a new tool. + expect(request.tools?.[0]?.tool.guid).toBe('6f9619ff-8b86-d011-b42d-00cf4fc964f1') + expect(request.holders?.map((each) => each.guid)).toEqual([ + '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + '3f2504e0-4f89-41d3-9a0c-0305e82c3302', + ]) + }) + + it('mints an identifier for every set-up', () => { + // Justin Gray, 2026-09-12. The upstream derivation is stable across + // re-exports but cannot tell apart one tool in one holder at two + // stickouts, and those two would silently collapse into one assembly. One + // rule with no condition on it, at the cost of a re-export being a fresh + // set of assemblies. + expect(request.tools?.[0]?.assemblies?.map((each) => each.guid)).toEqual([minted(1), minted(2)]) + }) + + it('keeps two stacks apart when the tool and the holder are both the same', () => { + // The case the derivation cannot see, and the reason for the rule above. + const { request: twice } = mastercamInput( + [ + { key: 'row-1', tool: endMill, holder, stickout: 20 }, + { key: 'row-2', tool: endMill, holder, stickout: 40 }, + ], + counting(), + ) + const setups = twice.tools?.[0]?.assemblies ?? [] + expect(setups.map((each) => each.stickout)).toEqual([20, 40]) + expect(new Set(setups.map((each) => each.guid)).size).toBe(2) + }) + + it('writes one holder entry however many stacks name it', () => { + const { request: shared } = mastercamInput( + [ + { key: 'row-1', tool: endMill, holder }, + { + key: 'row-2', + tool: { ...endMill, guid: '6f9619ff-8b86-d011-b42d-00cf4fc964f2' }, + holder, + }, + ], + counting(), + ) + expect(shared.holders).toHaveLength(1) + }) +}) + +describe('which arm of the holder union travels', () => { + it('sends the measured silhouette where the catalog has a complete one', () => { + const { request } = mastercamInput(oneStack({ holder, profile }), counting()) + expect(request.holders?.[0]?.holder).toMatchObject({ + points: profile.points, + datum: 'gage-line', + colletSeries: 'ER32', + }) + }) + + it('sends the published dimensions where the model stops short', () => { + // An incomplete model is missing its cutting end, which is the end that + // fouls the part — see `measuredShape`. + const { request } = mastercamInput( + oneStack({ holder, profile: { ...profile, complete: false } }), + counting(), + ) + expect(request.holders?.[0]?.holder).toBe(holder) + }) +}) + +describe('what the export has to say for itself', () => { + const stacks: ReadonlyArray = [ + { + key: 'row-1', + toolGuid: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + holderGuid: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + catalogNumber: 'TDMX0600', + }, + { + key: 'row-2', + toolGuid: '6f9619ff-8b86-d011-b42d-00cf4fc964f2', + holderGuid: null, + catalogNumber: 'TDMX0800', + }, + ] + + it('counts the rows that reached the file, not the rows of the database', () => { + // The same quantity `fusionReport` counts, because one dialog shows either. + expect(mastercamReport(stacks, []).exported).toBe(2) + }) + + it('reads a skip off the note the exporter wrote', () => { + const notes: ReadonlyArray = [ + { + subject: '6f9619ff-8b86-d011-b42d-00cf4fc964f2', + kind: 'skipped', + message: 'the catalog states no overall length', + }, + ] + const report = mastercamReport(stacks, notes) + expect(report.exported).toBe(1) + expect(report.skipped).toEqual([ + { catalogNumber: 'TDMX0800', reason: 'the catalog states no overall length' }, + ]) + }) + + it('says a tool was skipped once however many rows ordered it', () => { + // A note names the tool, and one tool can sit on six rows of the list. + const twice: ReadonlyArray = [ + stacks[0] as StackRequest, + { ...(stacks[0] as StackRequest), key: 'row-2' }, + ] + const notes: ReadonlyArray = [ + { + subject: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + kind: 'skipped', + message: 'no Mastercam type draws this', + }, + ] + expect(mastercamReport(twice, notes).skipped).toHaveLength(1) + }) + + it('carries a dropped or coerced note about a tool or its holder as a warning', () => { + const notes: ReadonlyArray = [ + { + subject: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', + kind: 'skipped', + message: 'the vendor states no nose diameter', + }, + { + subject: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + kind: 'dropped', + field: 'TlAssembly', + message: 'the assembly names a holder which is not in this library', + }, + { + subject: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + kind: 'coerced', + field: 'MCToolType', + message: 'written as a bore', + }, + ] + const report = mastercamReport(stacks, notes) + expect(report.warnings.map((each) => each.reason)).toEqual([ + 'the assembly names a holder which is not in this library', + 'written as a bore', + ]) + // The holder was refused, not the tool: the row still reached the file. + expect(report.exported).toBe(2) + expect(report.skipped).toEqual([]) + }) + + it('does not surface a filled note', () => { + // Conventions the format demands, not anything a shop has to act on. + const notes: ReadonlyArray = [ + { + subject: '6f9619ff-8b86-d011-b42d-00cf4fc964f1', + kind: 'filled', + field: 'HelixAngle', + message: 'assumed 30°', + }, + ] + expect(mastercamReport(stacks, notes).warnings).toEqual([]) + }) +}) + +/** + * One pass through the real exporter. + * + * Not a test of the format — that lives upstream against Mastercam's own + * schema. It is the sensor for a field this module spells wrongly: the request + * shape is structural, so a renamed key would otherwise be caught by nothing + * here and by nobody until a shop opened the file. + */ +describe('against the exporter itself', () => { + it('is accepted, and writes a SQLite database with both set-ups in it', () => { + const { request, stacks } = mastercamInput( + [ + { key: 'row-1', tool: endMill, holder, stickout: 30 }, + { key: 'row-2', tool: endMill, holder: otherHolder, stickout: 45 }, + ], + counting(), + ) + const { document, notes } = mastercamLibrary(request) + + expect(new TextDecoder().decode(document.subarray(0, 15))).toBe('SQLite format 3') + const report = mastercamReport(stacks, notes) + expect(report.exported).toBe(2) + expect(report.skipped).toEqual([]) + }) +}) diff --git a/apps/catalog/app/shared/mastercam-input.ts b/apps/catalog/app/shared/mastercam-input.ts new file mode 100644 index 0000000..135b776 --- /dev/null +++ b/apps/catalog/app/shared/mastercam-input.ts @@ -0,0 +1,233 @@ +import type { ExportNote } from '@toolpath/tool-support/export' +import type { + CatalogHolder, + LibraryRequest, + MastercamSetup, + MastercamToolRequest, +} from '@toolpath/tool-support/export/mastercam' +import { + browserGuid, + measuredShape, + type ExportDiagnostic, + type ExportReport, + type MintGuid, + type OrderedStack, +} from 'shared/export-input' + +/** + * The order list as `@toolpath/tool-support/export/mastercam` wants to be + * asked. + * + * The Mastercam half of the seam `shared/export-input.ts` splits, and the same + * division of labour `fusion-input.ts` keeps: the exporter is not this + * application's — its 79 tables are Mastercam's own schema, pinned out of a + * real `.TOOLDB` by `pnpm mastercam:adopt` upstream — and what is ours is only + * the translation from the shapes this catalog stores. + * + * ## Why this reads so differently from the Fusion one + * + * A Fusion library embeds a holder **inside** the tool record, so a tool set up + * in two holders is two tool records, and `fusion-input.ts` mints a guid per + * stack to keep them apart. Mastercam's schema is relational and does not have + * that problem: `TlTool` holds the tool, `TlAssembly` joins it to a holder, and + * the exporter writes every row with `addOnce` keyed on the guid it is given. + * So the shape this module builds is the order list **grouped**: one entry per + * distinct tool carrying one {@link MastercamSetup} per stack, one entry per + * distinct holder, and the catalog's own guids throughout. + * + * That is the better answer as well as the shorter one. Six stacks sharing an + * ER32 chuck put one holder in the file rather than six, which is what a + * machinist sees in Mastercam's own tree. + * + * ## The one guid this mints, and why it is allowed to + * + * An assembly's identifier is derived upstream from the tool and the holder, + * which is stable across re-exports — but two stacks can be *the same tool in + * the same holder at two different stickouts*, and that derivation cannot tell + * them apart. Those two would collapse into one assembly and the second + * stickout would be lost silently. + * + * So every setup states its own guid, minted per stack. The cost is that a + * re-export is a fresh set of assemblies rather than an update of the last one + * — the tools and holders under them keep their catalog guids and do update. + * Justin Gray took that trade on 2026-09-12: "it's not critical that the guid + * be the same each time it's exported at all". One rule with no condition on + * it, rather than a derivation that is right until the day two rows collide. + */ + +/** One stack, with what is needed to read the exporter's notes back to its row. */ +export interface StackRequest { + readonly key: string + /** The catalog's own guid for the tool, which is what the file is keyed on. */ + readonly toolGuid: string + /** The catalog's guid 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 +} + +export interface MastercamInput { + readonly request: LibraryRequest + /** Every stack that went in, in order, for {@link mastercamReport}. */ + readonly stacks: ReadonlyArray +} + +/** + * The holder as the exporter takes one: the shape, plus who made it. + * + * No unit system, unlike the Fusion one — the format has no metric mode and the + * exporter converts every length to inches on the way in. + */ +const holderFor = (stack: OrderedStack): CatalogHolder | undefined => { + const { holder, profile } = stack + if (holder === undefined) { + return undefined + } + return { + guid: holder.guid, + holder: measuredShape(holder, profile) ?? holder, + description: `${holder.brand} ${holder.catalogNumber}`, + vendor: holder.brand, + catalogNumber: holder.catalogNumber, + label: `${holder.brand} ${holder.catalogNumber}`, + } +} + +/** + * 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 — the same trade `fusion-input.ts` makes and for the + * same reason. It rides on the *setup* here rather than on the tool, because + * Mastercam holds a tool number per assembly: one tool at two positions is + * ordinary rather than a conflict, and the exporter says so in a note when it + * has to write the base row's single value. + */ +export const mastercamInput = ( + stacks: ReadonlyArray, + mintGuid: MintGuid = browserGuid, +): MastercamInput => { + const tools = new Map }>() + const holders = new Map() + const written: Array = [] + + stacks.forEach((stack, index) => { + const { tool } = stack + const holder = holderFor(stack) + if (holder !== undefined && !holders.has(holder.guid)) { + holders.set(holder.guid, holder) + } + + const entry = tools.get(tool.guid) ?? { tool, assemblies: [] } + if (holder !== undefined) { + entry.assemblies.push({ + holderGuid: holder.guid, + stickout: stack.stickout ?? tool.geometry.LBH ?? null, + number: index + 1, + guid: mintGuid(), + }) + } + tools.set(tool.guid, entry) + + written.push({ + key: stack.key, + toolGuid: tool.guid, + holderGuid: holder?.guid ?? null, + catalogNumber: tool.catalogNumber, + }) + }) + + const requests: Array = [...tools.values()].map(({ tool, assemblies }) => ({ + tool: { + form: tool.form, + guid: tool.guid, + unit: tool.unitSystem, + geometry: tool.geometry, + threadMethod: tool.threadMethod, + description: `${tool.brand} ${tool.catalogNumber}`, + vendor: tool.brand, + catalogNumber: tool.catalogNumber, + label: `${tool.brand} ${tool.catalogNumber}`, + ...(tool.productLink === null ? {} : { productLink: tool.productLink }), + number: assemblies[0]?.number ?? 0, + }, + assemblies, + })) + + return { request: { tools: requests, holders: [...holders.values()] }, stacks: written } +} + +/** + * What the export has to say for itself, read back off the notes. + * + * **The notes are the only evidence there is**, which is the one real + * difference from `fusionReport`. That function tests membership in the Fusion + * document, because a Fusion library is a JSON object it can look inside. A + * `.TOOLDB` is a SQLite file, and parsing one back to ask whether a row landed + * would be a second implementation of the format in this application. + * + * The notes carry the answer exactly. `mastercamTool` returns `written: null` + * on the same line it pushes a `skipped` note and on no other path, so a + * `skipped` note against a tool's guid means that tool is not in the file — and + * unlike the Fusion exporter, nothing here writes a `skipped` note about a + * record that went out anyway. + * + * `filled` notes are not surfaced, for the reason `fusionReport` gives: they + * are conventions the format demands rather than anything a shop has to act on. + */ +export const mastercamReport = ( + stacks: ReadonlyArray, + notes: ReadonlyArray, +): ExportReport => { + const skipped: Array = [] + const warnings: Array = [] + const refused = new Set( + notes.filter((note) => note.kind === 'skipped').map((note) => note.subject), + ) + // One tool can sit on several rows of the order list, and a note names the + // tool. Counting the rows would say a tool was skipped twice. + const counted = new Set() + + for (const stack of stacks) { + const { catalogNumber, toolGuid, holderGuid } = stack + if (refused.has(toolGuid)) { + if (!counted.has(toolGuid)) { + counted.add(toolGuid) + const said = notes + .filter((note) => note.subject === toolGuid && note.kind === 'skipped') + .map((note) => note.message) + skipped.push({ + catalogNumber, + reason: said.length === 0 ? 'Mastercam has no tool type this fits' : said.join('; '), + }) + } + continue + } + for (const note of notes) { + const about = + note.subject === toolGuid || (holderGuid !== null && note.subject === holderGuid) + if (!about || (note.kind !== 'dropped' && note.kind !== 'coerced')) { + continue + } + const already = warnings.some( + (each) => each.catalogNumber === catalogNumber && each.reason === note.message, + ) + if (!already) { + warnings.push({ catalogNumber, reason: note.message }) + } + } + } + + // Rows of the order list that reached the file, which is the same thing + // `fusionReport` counts — one Fusion record per stack — so that one dialog + // showing either number is showing the same quantity. Not the tool rows in + // the database: six stacks sharing an end mill are deliberately one `TlTool` + // there, and a shop that ordered six assemblies would read "1" as a failure. + // + // A holder the exporter refused takes its assemblies with it and the tool + // still lands, so those rows count as exported and carry a warning. + const exported = stacks.filter((stack) => !refused.has(stack.toolGuid)).length + + return { exported, skipped, warnings } +} diff --git a/apps/catalog/app/shared/save-file.ts b/apps/catalog/app/shared/save-file.ts index 4a407a7..b448494 100644 --- a/apps/catalog/app/shared/save-file.ts +++ b/apps/catalog/app/shared/save-file.ts @@ -22,13 +22,26 @@ export interface SaveTargets { readonly later: (release: () => void) => void } +/** + * What can be saved: text, or the bytes of a file. + * + * A `Uint8Array` is spelled out beside `BlobPart` because an unparameterised + * one is `Uint8Array`, which includes a view over a + * `SharedArrayBuffer` — and a `Blob` genuinely cannot be built from shared + * memory, so TypeScript is right to refuse it. `@toolpath/tool-support`'s + * Mastercam exporter returns exactly that type and never that case, so + * {@link saveFile} narrows it with a copy rather than a cast. + */ +export type Savable = BlobPart | Uint8Array + export const saveFile = ( name: string, - contents: BlobPart, + contents: Savable, type: string, { document, url, later }: SaveTargets, ): void => { - const href = url.createObjectURL(new Blob([contents], { type })) + const part: BlobPart = contents instanceof Uint8Array ? new Uint8Array(contents) : contents + const href = url.createObjectURL(new Blob([part], { type })) const link = document.createElement('a') link.href = href link.download = name @@ -42,7 +55,7 @@ export const saveFile = ( } /** The same, against the real browser. */ -export const saveInBrowser = (name: string, contents: BlobPart, type: string): void => +export const saveInBrowser = (name: string, contents: Savable, type: string): void => saveFile(name, contents, type, { document: globalThis.document, url: URL, diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 47e9d0e..a609f6b 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -18,13 +18,13 @@ "@react-router/node": "^7.6.0", "@react-three/drei": "10.7.8", "@react-three/fiber": "9.7.0", - "@toolpath/app-support": "0.1.4", + "@toolpath/app-support": "0.1.6", "@toolpath/catalog-data": "workspace:*", "@toolpath/part-client": "workspace:*", "@toolpath/part-contracts": "workspace:*", "@toolpath/part-server": "workspace:*", - "@toolpath/tool-drawing": "1.0.1", - "@toolpath/tool-support": "0.4.0", + "@toolpath/tool-drawing": "1.0.3", + "@toolpath/tool-support": "0.6.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 9fbad34..f75b88e 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Locator, type Page } from '@playwright/test' import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' import { onThePart, openCube, openCubeWithHole, orderList } from './cube-fixture' /** @@ -4804,3 +4805,70 @@ test('downloads the order list as a Fusion tool library', async ({ page }) => { await expect(dialog.getByText(/^Downloaded 1 tool assembly\./)).toBeVisible() }) + +/** + * **The same bill leaves as a file Mastercam will load.** + * + * The second exporter, added 2026-09-12, and the reason this is a separate test + * rather than a second assertion on the one above: a `.TOOLDB` is a SQLite + * database, so what proves it landed is opening it and asking SQLite — which + * `node:sqlite` can do here and no unit test in jsdom should try to. + * + * Three things only this can see. That the dynamic `import()` behind the press + * resolves in a real browser at all — the exporter carries 82 KB of pinned + * schema and is deliberately not in the page's first payload, so a + * mis-configured `optimizeDeps` breaks this button and nothing else. That the + * bytes survive `saveInBrowser`, which takes a `Uint8Array` here and a string + * for Fusion. And that the file is a database rather than a plausible-looking + * buffer: `PRAGMA integrity_check` walks every page and every index entry, + * which is the only way to catch a b-tree written slightly wrong — the failure + * mode there is not an exception but a file that opens and is missing rows. + */ +test('downloads the order list as a Mastercam 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 Mastercam library' }).click() + + const dialog = page.getByRole('dialog', { name: 'Export Mastercam tool library' }) + await expect(dialog).toBeVisible() + + const waiting = page.waitForEvent('download') + await dialog.getByRole('button', { name: 'Download .TOOLDB' }).click() + const download = await waiting + expect(download.suggestedFilename()).toBe('tool-library.TOOLDB') + + const path = await download.path() + expect(readFileSync(path).subarray(0, 15).toString('utf8')).toBe('SQLite format 3') + + const db = new DatabaseSync(path, { readOnly: true }) + try { + expect(db.prepare('PRAGMA integrity_check').get()).toMatchObject({ integrity_check: 'ok' }) + + // One row of the order list is one tool, one holder and one assembly that + // joins them. `TlAssemblyComponent` is the flat parent/child list: the + // holder is the root and the tool hangs off it, so two rows per assembly. + const count = (table: string) => + (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n + expect(count('TlTool')).toBe(1) + expect(count('TlToolMill')).toBe(1) + expect(count('TlAssembly')).toBe(1) + expect(count('TlAssemblyComponent')).toBe(2) + + // The geometry is relational rather than hidden in the base row's blob, + // which is what makes this exporter possible at all — so a real dimension + // is readable back out, in inches, the format's only unit. + const mill = db.prepare('SELECT OverallLength, FluteCount FROM TlToolMill').get() as { + OverallLength: number + FluteCount: number + } + expect(mill.OverallLength).toBeGreaterThan(0) + expect(mill.FluteCount).toBeGreaterThan(0) + } finally { + db.close() + } + + await expect(dialog.getByText(/^Downloaded 1 tool assembly\./)).toBeVisible() +}) diff --git a/apps/catalog/vite.config.ts b/apps/catalog/vite.config.ts index 500b471..948a082 100644 --- a/apps/catalog/vite.config.ts +++ b/apps/catalog/vite.config.ts @@ -126,6 +126,13 @@ export default defineConfig(({ mode }) => { '@toolpath/ui', '@toolpath/viewer', '@toolpath/viewer/engine', + // The exporters especially. `export/mastercam` carries Mastercam's + // pinned 79-table schema — 82 KB of it — and is reached by a dynamic + // import on a button press, which is discovery in the middle of a + // session: exactly the case the 504 above describes. + '@toolpath/tool-support', + '@toolpath/tool-support/export/fusion', + '@toolpath/tool-support/export/mastercam', ], }, plugins: [ diff --git a/apps/dfm/package.json b/apps/dfm/package.json index 5445757..83aaa71 100644 --- a/apps/dfm/package.json +++ b/apps/dfm/package.json @@ -20,8 +20,8 @@ "@react-three/drei": "10.7.8", "@react-three/fiber": "9.7.0", "@toolpath/api": "0.4.1", - "@toolpath/app-support": "0.1.4", - "@toolpath/tool-support": "0.4.0", + "@toolpath/app-support": "0.1.6", + "@toolpath/tool-support": "0.6.0", "@toolpath/ui": "1.0.0", "@toolpath/viewer": "1.1.1", "eventsource-parser": "4.0.0", diff --git a/packages/catalog-data/package.json b/packages/catalog-data/package.json index 6681eaa..1b5b5a9 100644 --- a/packages/catalog-data/package.json +++ b/packages/catalog-data/package.json @@ -46,8 +46,8 @@ }, "dependencies": { "@toolpath/part-contracts": "workspace:*", - "@toolpath/tool-scraper": "3.0.1", - "@toolpath/tool-support": "0.4.0" + "@toolpath/tool-scraper": "3.0.3", + "@toolpath/tool-support": "0.6.0" }, "devDependencies": { "@types/node": "24.10.1", diff --git a/packages/part-contracts/package.json b/packages/part-contracts/package.json index b9d3fa0..a5ee7f3 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.4.0", + "@toolpath/tool-support": "0.6.0", "@toolpath/viewer": "1.1.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2d02c8..25a7fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: 9.7.0 version: 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) '@toolpath/app-support': - specifier: 0.1.4 - version: 0.1.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 0.1.6 + version: 0.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/catalog-data': specifier: workspace:* version: link:../../packages/catalog-data @@ -69,11 +69,11 @@ importers: specifier: workspace:* version: link:../../packages/part-server '@toolpath/tool-drawing': - specifier: 1.0.1 - version: 1.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 1.0.3 + version: 1.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/tool-support': - specifier: 0.4.0 - version: 0.4.0 + specifier: 0.6.0 + version: 0.6.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) @@ -178,11 +178,11 @@ importers: specifier: 0.4.1 version: 0.4.1 '@toolpath/app-support': - specifier: 0.1.4 - version: 0.1.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 0.1.6 + version: 0.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/tool-support': - specifier: 0.4.0 - version: 0.4.0 + specifier: 0.6.0 + version: 0.6.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) @@ -278,11 +278,11 @@ importers: specifier: workspace:* version: link:../part-contracts '@toolpath/tool-scraper': - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.0.3 + version: 3.0.3 '@toolpath/tool-support': - specifier: 0.4.0 - version: 0.4.0 + specifier: 0.6.0 + version: 0.6.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.4.0 - version: 0.4.0 + specifier: 0.6.0 + version: 0.6.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) @@ -1301,27 +1301,27 @@ packages: resolution: {integrity: sha512-SJbwQWSJoYQextAdJXHQQMCIFZ/OB+PijLDuhQqQPqy95TOApSpT5emXHhHPRghHQLwdSlKCXz5aQ7lv6nPFYg==} engines: {node: '>=20'} - '@toolpath/app-support@0.1.4': - resolution: {integrity: sha512-41uT8GLG2IMOKJJqmqnm0rtyTPBs+w43jN7Uhw46cgvhoVe8AMw2O9jZ24V4dLQxwCMunwddeUbMYPjAdERPMg==} + '@toolpath/app-support@0.1.6': + resolution: {integrity: sha512-QVSyBuq2joLzcBjadqp04bj1y87yvj+0e+0at9q1CZjSdumeey56nQ6ttcB0j4OorAniSmKMiXxIqvgJiCNB3A==} engines: {node: '>=20'} peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 - '@toolpath/tool-drawing@1.0.1': - resolution: {integrity: sha512-mppu5TPVDoscHY5gi8eSbijRE7Upi5XTCaVq9cDm/PffbCo3edBbGqa9pBNJURR7kz58V2tTtMQNmCgLdGpWDQ==} + '@toolpath/tool-drawing@1.0.3': + resolution: {integrity: sha512-dIQma1SRSIjUxtfAtIlAk6MNbEV37bo9UuVy4CdXYkIkddwNnMqVOipSIBvLcpxnAGNdcr6cPLXSHMtJORFB4g==} engines: {node: '>=20'} peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 - '@toolpath/tool-scraper@3.0.1': - resolution: {integrity: sha512-JuSLGX8cSXWXFP/qvoZu6CDWRljmKA5ViM6VVhOOKV8fArlERInHP33nZM7DN5Vkd1aEFL/seDWfrQC3ukJIQw==} + '@toolpath/tool-scraper@3.0.3': + resolution: {integrity: sha512-Ft/EdcgHGO0PDz55mTtybNQ+TbsRIR20Tm/6VdR0T9jG6EHUqWO4XOlV7RTDdKGK4othlGeRwFbs+2+aDsAnYw==} engines: {node: '>=20'} hasBin: true - '@toolpath/tool-support@0.4.0': - resolution: {integrity: sha512-LVyh/qwcV8izuzKgdfbnUdgGcCPZzxnfFqYlHGF18WO6tAETb/NB7jVgi9idA+13lWM2WFZoq4O/A+apWMuvbA==} + '@toolpath/tool-support@0.6.0': + resolution: {integrity: sha512-X/UbPVkQSGHMFhuMStVTUygqwXO208yTjxgB8u1TNw99Khy3aVezSzOACE67Q/EKc7AzEgIFPwoHzBCr9QKqyQ==} engines: {node: '>=20'} '@toolpath/ui@1.0.0': @@ -3970,24 +3970,24 @@ snapshots: '@toolpath/api@0.4.1': {} - '@toolpath/app-support@0.1.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@toolpath/app-support@0.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@toolpath/tool-support': 0.4.0 + '@toolpath/tool-support': 0.6.0 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@toolpath/tool-drawing@1.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@toolpath/tool-drawing@1.0.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@toolpath/tool-support': 0.4.0 + '@toolpath/tool-support': 0.6.0 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@toolpath/tool-scraper@3.0.1': + '@toolpath/tool-scraper@3.0.3': dependencies: - '@toolpath/tool-support': 0.4.0 + '@toolpath/tool-support': 0.6.0 htmlparser2: 12.0.0 - '@toolpath/tool-support@0.4.0': {} + '@toolpath/tool-support@0.6.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: