Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ application unless that application says otherwise.
**The 2D tool drawing is not this application's** — it is
`@toolpath/tool-drawing`, and `app/components/catalog-drawing.tsx` is the one
file that wires it up. See `docs/TOOL-DRAWING-PLAN.md`.
**Neither is the Fusion export** — it is
`@toolpath/tool-support/export/fusion`, whose per-type rules come from
Autodesk's own published JSON Schema rather than from a table written here,
and `app/shared/fusion-input.ts` is the whole seam: this catalog's records
turned into what that package takes, and its `ExportNote`s read back into the
dialog. Every tool carries one placeholder preset, `Default Preset`, whose
every number is 1 — a tool with no preset is one Fusion will not load, and
`defaultPreset` in that file has the evidence. Real feeds and speeds are
`app/shared/pretool-presets.ts`, unreferenced on purpose and going back
through the same `ToolRequest.presets`. Do not write a second exporter here — `docs/CATALOG-SPEC.md` § 5 has what changed and
what is left.
**A holder can be drawn from its own CAD model** rather than from the nine
numbers a vendor publishes: `catalog-profiles` is a second Vite alias beside
`catalog-dataset`, `shared/catalog.ts` `getProfile` is the only way to reach
Expand Down Expand Up @@ -306,6 +317,7 @@ application unless that application says otherwise.
| what is on the order list, for both pages | `app/shared/order-list.ts` |
| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file |
| which of four things the page is being asked | `asked()`, same file |
| the bill as a Fusion library, and what its notes say | `app/shared/fusion-input.ts` |
| the three presses over the part that add a row | `app/components/add-bar.tsx` |
| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` |
| where the part is framed, beside the questions | `app/shared/frame-inset.ts` |
Expand Down
55 changes: 33 additions & 22 deletions apps/catalog/app/components/fusion-export-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,40 @@ import { describe, expect, it, vi } from 'vitest'
import { FusionExportDialog } from './fusion-export-dialog'

describe('FusionExportDialog', () => {
it('requires a material and passes the selected material and RPM to export', async () => {
it('passes the trimmed library name to export and reports what landed', async () => {
const onExport = vi.fn().mockResolvedValue({ exported: 1, skipped: [], holderWarnings: [] })
render(
<FusionExportDialog
initialMaterial={null}
initialName="Shop library"
onCancel={vi.fn()}
onExport={onExport}
/>,
)

const download = screen.getByRole('button', { name: 'Download .json' })
expect(download).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Aluminum' }))
fireEvent.change(screen.getByLabelText('Maximum spindle RPM'), { target: { value: '18000' } })
fireEvent.click(download)

await vi.waitFor(() =>
expect(onExport).toHaveBeenCalledWith(
{ material: 'AluWrought', maxRpm: 18000 },
'Shop library',
),
)
render(<FusionExportDialog initialName="Shop library" onCancel={vi.fn()} onExport={onExport} />)

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(<FusionExportDialog initialName="Shop library" onCancel={vi.fn()} onExport={vi.fn()} />)

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(<FusionExportDialog initialName="Shop library" onCancel={vi.fn()} onExport={onExport} />)

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()
})
})
84 changes: 20 additions & 64 deletions apps/catalog/app/components/fusion-export-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,46 @@
import { useState } from 'react'
import { Button, Card, Input } from '@toolpath/ui'
import { PRETOOL_MATERIALS, type PretoolMaterial } from 'shared/pretool-presets'
import type { FusionExportDiagnostic, FusionExportSettings } from 'shared/fusion-library'
import type { FusionReport } from 'shared/fusion-input'
import { useEscape } from 'shared/use-escape'
import { SECTION_LABEL } from 'shared/type'

export interface FusionExportDialogProps {
readonly initialMaterial: PretoolMaterial | null
readonly initialName: string
readonly onCancel: () => void
readonly onExport: (
settings: FusionExportSettings,
name: string,
) => Promise<{
readonly exported: number
readonly skipped: ReadonlyArray<FusionExportDiagnostic>
readonly holderWarnings: ReadonlyArray<FusionExportDiagnostic>
}>
readonly onExport: (name: string) => Promise<FusionReport>
}

const labelFor = (material: PretoolMaterial): string =>
material === 'AluWrought'
? 'Aluminum'
: material === 'LowCSteel'
? 'Low Carbon Steel'
: 'Stainless Steel'

/** The one last question before PreTool turns the bill into CAM starting data. */
/**
* The one last question before the bill becomes a file: what to call it.
*
* It asked two more until the exporter moved to
* `@toolpath/tool-support/export/fusion` — a workpiece material and a maximum
* spindle speed, which were PreTool's inputs for the feeds and speeds it wrote
* into `start-values`. What goes out now is `defaultPreset`, a placeholder of
* 1s that exists so the library loads at all, and it needs neither answer. The
* two questions come back with `pretool-presets.ts`, and not before: asking a
* shop for its spindle ceiling and then writing a 1 would be worse than not
* asking.
*/
export const FusionExportDialog = ({
initialMaterial,
initialName,
onCancel,
onExport,
}: FusionExportDialogProps) => {
const [material, setMaterial] = useState<PretoolMaterial | null>(initialMaterial)
const [name, setName] = useState(initialName)
const [maxRpm, setMaxRpm] = useState('12000')
const [working, setWorking] = useState(false)
const [result, setResult] = useState<{
readonly exported: number
readonly skipped: ReadonlyArray<FusionExportDiagnostic>
readonly holderWarnings: ReadonlyArray<FusionExportDiagnostic>
} | null>(null)
const rpm = Number(maxRpm)
const canExport =
material !== null && name.trim() !== '' && Number.isFinite(rpm) && rpm > 0 && !working
const [result, setResult] = useState<FusionReport | null>(null)
const canExport = name.trim() !== '' && !working

useEscape(true, onCancel)

const submit = async () => {
if (!canExport || material === null) {
if (!canExport) {
return
}
setWorking(true)
try {
setResult(await onExport({ material, maxRpm: rpm }, name.trim()))
setResult(await onExport(name.trim()))
} finally {
setWorking(false)
}
Expand All @@ -76,27 +62,10 @@ export const FusionExportDialog = ({
<div className="border-b border-zinc-800 px-4 py-3">
<p className="text-sm font-semibold text-zinc-100">Fusion tool library</p>
<p className="mt-1 text-xs text-zinc-400">
PreTool will generate roughing and finishing presets for the chosen material.
Every assembly on this bill, with its holder. Feeds and speeds are left for Fusion.
</p>
</div>
<div className="space-y-4 p-4">
<fieldset>
<legend className={SECTION_LABEL}>Workpiece material</legend>
<div className="mt-2 flex flex-wrap gap-2">
{PRETOOL_MATERIALS.map((each) => (
<Button
key={each}
type="button"
size="md"
variant={material === each ? 'secondary' : 'muted'}
aria-pressed={material === each}
onClick={() => setMaterial(each)}
>
{labelFor(each)}
</Button>
))}
</div>
</fieldset>
<label className="flex flex-col gap-1">
<span className={SECTION_LABEL}>Library name</span>
<Input
Expand All @@ -110,19 +79,6 @@ export const FusionExportDialog = ({
Fusion will use this filename for the library.
</span>
</label>
<label className="flex flex-col gap-1">
<span className={SECTION_LABEL}>Maximum spindle RPM</span>
<Input
id="fusion-export-max-rpm"
name="fusion-export-max-rpm"
type="number"
min="1"
step="1"
value={maxRpm}
onChange={(event) => setMaxRpm(event.target.value)}
aria-label="Maximum spindle RPM"
/>
</label>
{result === null ? null : (
<div
aria-live="polite"
Expand All @@ -140,7 +96,7 @@ export const FusionExportDialog = ({
))}
{result.holderWarnings.map((each) => (
<p key={`${each.catalogNumber}:${each.reason}`} className="text-zinc-400">
{each.catalogNumber} — {each.reason}; exported without its holder shape.
{each.catalogNumber} — {each.reason}.
</p>
))}
</div>
Expand Down
97 changes: 34 additions & 63 deletions apps/catalog/app/routes/order-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { CatalogTool, Collet, Holder } from '@toolpath/catalog-data'
import { AppHeader } from 'components/app-header'
import { FusionExportDialog } from 'components/fusion-export-dialog'
import { ColletIcon, HolderIcon, ToolTypeIcon, formLabel } from './../components/tool-icons'
import { allTools, getCollet, getHolder, getTool } from 'shared/catalog'
import { allTools, getCollet, getHolder, getProfile, getTool } from 'shared/catalog'
import {
addChoice,
lineUnder,
Expand All @@ -39,12 +39,15 @@ import {
type ComponentTotal,
type OrderAssembly,
} from 'shared/order-list'
import { fusionLibrary, type FusionExportSettings } from 'shared/fusion-library'
import {
fusionLibrary,
fusionLibraryJson,
sanitizeName,
} from '@toolpath/tool-support/export/fusion'
import { fusionInput, fusionReport } from 'shared/fusion-input'
import { saveInBrowser } from 'shared/save-file'
import { recallPart } from 'shared/part-session'
import { useUnit } from 'shared/use-unit'
import { usePartMaterial } from 'shared/use-preferences'
import type { PretoolMaterial } from 'shared/pretool-presets'
import { SECTION_LABEL, TABLE_FACE, TABLE_INK } from 'shared/type'

/**
Expand Down Expand Up @@ -108,27 +111,6 @@ const KIND: Readonly<Record<Component, string>> = {
collet: 'Collet',
}

const pretoolMaterialFor = (group: string | null): PretoolMaterial | null => {
if (group === 'N') {
return 'AluWrought'
}
if (group === 'P') {
return 'LowCSteel'
}
if (group === 'M') {
return 'StainlessSteel'
}
return null
}

const libraryFileName = (name: string): string => {
const safe = name
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\.+$/g, '')
return safe === '' ? 'tool-library' : safe
}

const toolLine = (tool: CatalogTool, unit: UnitSystem): Line => {
const say = (value: number) => formatLength(value, unit)
return {
Expand Down Expand Up @@ -440,7 +422,6 @@ const Bom = () => {
const [search] = useSearchParams()
const jobId = search.get('job')
const [unit, setUnit] = useUnit()
const { materialGroup } = usePartMaterial(partId ?? '')
const { sheet, commit } = useSetupSheet(partId ?? '')
const remembered = partId && jobId ? recallPart(partId, jobId) : null
const features = remembered?.report.features ?? []
Expand Down Expand Up @@ -530,48 +511,39 @@ const Bom = () => {
/**
* The whole bill as a Fusion library, saved from the browser.
*
* Built here rather than on the server because everything it needs is
* already in this page: the sheet's guids, resolved through the catalog.
* `fusion-library.ts` is where the shape of the file lives, and is tested
* there.
* Built here rather than on the server because everything it needs is already
* in this page: the sheet's guids, resolved through the catalog. This route
* resolves them and nothing more — `shared/fusion-input.ts` turns the records
* into what the exporter takes and reads its notes back, and the exporter
* itself is `@toolpath/tool-support/export/fusion`, whose rules come from
* Autodesk's own schema rather than from anything written here.
*/
const downloadFusion = async (settings: FusionExportSettings, name: string) => {
const exported = fusionLibrary(
const downloadFusion = async (name: string) => {
const requests = fusionInput(
assemblies.flatMap(({ key, choice }) => {
const tool = getTool(choice.toolGuid)
return tool === null
? []
: [
{
key,
tool,
holder:
choice.holderGuid == null
? undefined
: (getHolder(choice.holderGuid) ?? undefined),
collet:
choice.colletGuid == null
? undefined
: (getCollet(choice.colletGuid) ?? undefined),
stickout: choice.stickout,
},
]
if (tool === null) {
return []
}
const holder = choice.holderGuid == null ? null : getHolder(choice.holderGuid)
return [
{
key,
tool,
...(holder === null ? {} : { holder, profile: getProfile(holder.guid) }),
stickout: choice.stickout,
},
]
}),
settings,
)
if (exported.library.data.length === 0) {
return { exported: 0, skipped: exported.skipped, holderWarnings: exported.holderWarnings }
}
saveInBrowser(
`${libraryFileName(name)}.json`,
JSON.stringify(exported.library, null, 2),
'application/json',
)
return {
exported: exported.library.data.length,
skipped: exported.skipped,
holderWarnings: exported.holderWarnings,
const { document, notes } = fusionLibrary({
tools: requests.map((each) => each.request),
})
const report = fusionReport(requests, document, notes)
if (report.exported > 0) {
saveInBrowser(`${sanitizeName(name)}.json`, fusionLibraryJson(document), 'application/json')
}
return report
}

return (
Expand Down Expand Up @@ -930,7 +902,6 @@ const Bom = () => {
</div>
{fusionDialogOpen ? (
<FusionExportDialog
initialMaterial={pretoolMaterialFor(materialGroup)}
initialName="tool-library"
onCancel={() => setFusionDialogOpen(false)}
onExport={downloadFusion}
Expand Down
Loading