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 (
-
- )
-}
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 (
+
+ )
+}
+
+/** 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: