From 8fa7a4c8aa7d6c37ffdabf546aa8e2acc16c89c5 Mon Sep 17 00:00:00 2001
From: Justin Gray
Date: Mon, 14 Sep 2026 09:18:37 -0400
Subject: [PATCH 1/3] Write the order list as a Mastercam tool library
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A `.TOOLDB` is a SQLite database carrying Mastercam's own 79-table schema,
and writing one is `@toolpath/tool-support/export/mastercam`, not this
application. `app/shared/mastercam-input.ts` is the seam.
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; Mastercam
joins them relationally, so the order list goes in grouped — one entry per
distinct tool carrying one set-up per stack, one entry per distinct holder,
under the catalog's own guids. Six stacks sharing an ER32 chuck put one
holder in the file rather than six.
The one identifier this mints is the assembly's, per stack. An assembly's id
is derived upstream from its tool and holder, which cannot tell apart the
same tool in the same holder at two stickouts — those would collapse and the
second stickout would be lost. A minted guid costs a re-export a fresh set of
assemblies; the tools and holders under them keep their catalog guids.
`app/shared/export-input.ts` is what both formats read, extracted from
`fusion-input.ts` now that there is a second consumer. One dialog serves both,
`library-export-dialog.tsx` — only four strings differ between the two — and
`ExportReport.holderWarnings` is `warnings`, meaning the same quantity for
each: rows of the order list that reached the file.
The exporter is imported on the press rather than at the top of the route: it
carries 82 KB of pinned schema, and `vite.config.ts` pre-bundles the subpath
so that import is a fetch rather than a discovery.
`tool-support` moves to 0.6.0, which is what carries the Mastercam exporter.
That release also changed what a holder stating no gauge length means: the
Fusion exporter `filled`s it with the height of the shape it wrote rather than
`dropped`ing it, so it is no longer a warning. `fusion-input.test.ts` now pins
a stated gauge length its own dimensions contradict, which still is one, and
pins the silence on the case that stopped being.
---
AGENTS.md | 18 +
.../components/fusion-export-dialog.test.tsx | 42 --
.../components/library-export-dialog.test.tsx | 89 ++++
...t-dialog.tsx => library-export-dialog.tsx} | 65 ++-
apps/catalog/app/routes/order-list.tsx | 108 +++--
apps/catalog/app/shared/export-input.ts | 107 +++++
apps/catalog/app/shared/fusion-input.test.ts | 38 +-
apps/catalog/app/shared/fusion-input.ts | 118 ++----
.../app/shared/mastercam-input.test.ts | 383 ++++++++++++++++++
apps/catalog/app/shared/mastercam-input.ts | 233 +++++++++++
apps/catalog/app/shared/save-file.ts | 19 +-
apps/catalog/package.json | 2 +-
apps/catalog/tests/on-the-part.spec.ts | 68 ++++
apps/catalog/vite.config.ts | 7 +
apps/dfm/package.json | 2 +-
packages/catalog-data/package.json | 2 +-
packages/part-contracts/package.json | 2 +-
17 files changed, 1109 insertions(+), 194 deletions(-)
delete mode 100644 apps/catalog/app/components/fusion-export-dialog.test.tsx
create mode 100644 apps/catalog/app/components/library-export-dialog.test.tsx
rename apps/catalog/app/components/{fusion-export-dialog.tsx => library-export-dialog.tsx} (61%)
create mode 100644 apps/catalog/app/shared/export-input.ts
create mode 100644 apps/catalog/app/shared/mastercam-input.test.ts
create mode 100644 apps/catalog/app/shared/mastercam-input.ts
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/library-export-dialog.test.tsx b/apps/catalog/app/components/library-export-dialog.test.tsx
new file mode 100644
index 0000000..69d5079
--- /dev/null
+++ b/apps/catalog/app/components/library-export-dialog.test.tsx
@@ -0,0 +1,89 @@
+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.
+ */
+ 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/fusion-export-dialog.tsx b/apps/catalog/app/components/library-export-dialog.tsx
similarity index 61%
rename from apps/catalog/app/components/fusion-export-dialog.tsx
rename to apps/catalog/app/components/library-export-dialog.tsx
index 7f4408a..071ed1b 100644
--- a/apps/catalog/app/components/fusion-export-dialog.tsx
+++ b/apps/catalog/app/components/library-export-dialog.tsx
@@ -1,13 +1,24 @@
import { useState } from 'react'
import { Button, Card, Input } from '@toolpath/ui'
-import type { FusionReport } from 'shared/fusion-input'
+import type { ExportReport } from 'shared/export-input'
import { useEscape } from 'shared/use-escape'
import { SECTION_LABEL } from 'shared/type'
-export interface FusionExportDialogProps {
+/** 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
+ readonly onExport: (name: string) => Promise
}
/**
@@ -21,15 +32,24 @@ export interface FusionExportDialogProps {
* 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 FusionExportDialog = ({
+export const LibraryExportDialog = ({
+ format,
initialName,
onCancel,
onExport,
-}: FusionExportDialogProps) => {
+}: LibraryExportDialogProps) => {
const [name, setName] = useState(initialName)
const [working, setWorking] = useState(false)
- const [result, setResult] = useState(null)
+ const [result, setResult] = useState(null)
const canExport = name.trim() !== '' && !working
useEscape(true, onCancel)
@@ -50,7 +70,7 @@ export const FusionExportDialog = ({
)
}
+
+/** 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..1f63d35 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.1",
- "@toolpath/tool-support": "0.4.0",
+ "@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..2bdec8a 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.4",
- "@toolpath/tool-support": "0.4.0",
+ "@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..30201ee 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.1",
- "@toolpath/tool-support": "0.4.0"
+ "@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": {
From eac421ce68a0bb92149f66ad3432b33d8fa13f71 Mon Sep 17 00:00:00 2001
From: Justin Gray
Date: Mon, 14 Sep 2026 09:26:12 -0400
Subject: [PATCH 2/3] Resolve the Toolpath packages onto one tool-support again
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`tool-support` 0.6.0 carries the Mastercam exporter, and `app-support` 0.1.6,
`tool-drawing` 1.0.3 and `tool-scraper` 3.0.3 each depend on `^0.6.0`. A 0.x
caret does not span a minor, so bumping `tool-support` alone left the older
three asking for 0.4.0 and the install carried two copies — the same split
`build: resolve the Toolpath packages onto one tool-support` closed before.
`pnpm-lock.yaml` resolves one copy again, and the suite now runs against the
published package rather than a local link.
---
apps/catalog/package.json | 4 +-
apps/dfm/package.json | 2 +-
packages/catalog-data/package.json | 2 +-
pnpm-lock.yaml | 62 +++++++++++++++---------------
4 files changed, 35 insertions(+), 35 deletions(-)
diff --git a/apps/catalog/package.json b/apps/catalog/package.json
index 1f63d35..a609f6b 100644
--- a/apps/catalog/package.json
+++ b/apps/catalog/package.json
@@ -18,12 +18,12 @@
"@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-drawing": "1.0.3",
"@toolpath/tool-support": "0.6.0",
"@toolpath/ui": "1.0.0",
"@toolpath/viewer": "1.1.1",
diff --git a/apps/dfm/package.json b/apps/dfm/package.json
index 2bdec8a..83aaa71 100644
--- a/apps/dfm/package.json
+++ b/apps/dfm/package.json
@@ -20,7 +20,7 @@
"@react-three/drei": "10.7.8",
"@react-three/fiber": "9.7.0",
"@toolpath/api": "0.4.1",
- "@toolpath/app-support": "0.1.4",
+ "@toolpath/app-support": "0.1.6",
"@toolpath/tool-support": "0.6.0",
"@toolpath/ui": "1.0.0",
"@toolpath/viewer": "1.1.1",
diff --git a/packages/catalog-data/package.json b/packages/catalog-data/package.json
index 30201ee..1b5b5a9 100644
--- a/packages/catalog-data/package.json
+++ b/packages/catalog-data/package.json
@@ -46,7 +46,7 @@
},
"dependencies": {
"@toolpath/part-contracts": "workspace:*",
- "@toolpath/tool-scraper": "3.0.1",
+ "@toolpath/tool-scraper": "3.0.3",
"@toolpath/tool-support": "0.6.0"
},
"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:
From 7a5b3497f958e9e9756bed339ca874c12dd1d8d9 Mon Sep 17 00:00:00 2001
From: Justin Gray
Date: Mon, 14 Sep 2026 09:31:30 -0400
Subject: [PATCH 3/3] Keep the focus inside the export dialog, as aria-modal
promises
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dialog says `aria-modal="true"`, which tells a screen reader the page
behind it is inert, and nothing kept that promise: Tab walked straight onto the
order list underneath while the reader still announced the dialog, and opening
it left the focus on the button behind the overlay.
The kit's `Dialog` is the component to reach for and does not fit — it is an
alert whose `confirm()` resolves to a boolean and closes on the press, where
this dialog reads a name back off an input and goes on showing a report
afterwards; its `@base-ui/react` primitive is not a dependency here. So the
focus is handled beside the markup it governs, and the component now says which
kit component it turned down and why.
The name takes the focus on open, Tab cycles within the dialog, and the opener
gets the focus back when it closes. Three tests pin those, because the markup
makes the claim whether or not the behaviour is there.
---
.../components/library-export-dialog.test.tsx | 60 ++++++++++++++++++
.../app/components/library-export-dialog.tsx | 62 ++++++++++++++++++-
2 files changed, 121 insertions(+), 1 deletion(-)
diff --git a/apps/catalog/app/components/library-export-dialog.test.tsx b/apps/catalog/app/components/library-export-dialog.test.tsx
index 69d5079..e159144 100644
--- a/apps/catalog/app/components/library-export-dialog.test.tsx
+++ b/apps/catalog/app/components/library-export-dialog.test.tsx
@@ -63,6 +63,66 @@ describe('LibraryExportDialog', () => {
* 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(
diff --git a/apps/catalog/app/components/library-export-dialog.tsx b/apps/catalog/app/components/library-export-dialog.tsx
index 071ed1b..fb4d46d 100644
--- a/apps/catalog/app/components/library-export-dialog.tsx
+++ b/apps/catalog/app/components/library-export-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+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'
@@ -51,9 +51,68 @@ export const LibraryExportDialog = ({
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
@@ -68,6 +127,7 @@ export const LibraryExportDialog = ({
return (