From 18e49d950041fe6cddaa3eb41c92c4b3e3153bc2 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Tue, 8 Sep 2026 10:09:13 -0400 Subject: [PATCH 1/6] Serve Toolpath's favicon, and quiet the dev server's 404 traces The catalog served no icon, so every boot logged a "No route matches URL /favicon.ico" stack trace: the adapter left the path to Vite, Vite found no file, and React Router's dev handler took it as a document. Chrome's DevTools workspace probe did the same on every DevTools open, reaching the Hono app because nothing excluded it. public/favicon.svg is toolpath.com's own mark, from the rel="icon" it declares. Declaring it in root.tsx is what stops the guess at /favicon.ico; the two new exclude patterns cover a browser that guesses anyway and the .well-known probe, so Vite answers both instead of React Router failing to route them. --- apps/catalog/app/root.tsx | 5 +++++ apps/catalog/dev-server-exclude.test.ts | 19 +++++++++++++++++++ apps/catalog/dev-server-exclude.ts | 9 +++++++++ apps/catalog/public/favicon.svg | 14 ++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 apps/catalog/public/favicon.svg diff --git a/apps/catalog/app/root.tsx b/apps/catalog/app/root.tsx index 8a9a3e2..387f02e 100644 --- a/apps/catalog/app/root.tsx +++ b/apps/catalog/app/root.tsx @@ -14,6 +14,11 @@ import appCss from './styles.css?url' export const links = () => [ { rel: 'stylesheet', href: appCss }, + // Toolpath's own mark, from toolpath.com's declared `rel="icon"`. Declaring + // it stops the browser guessing at `/favicon.ico`, which nothing serves — + // that guess reached React Router as a document and logged a 404 stack + // trace on every boot. + { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, // The same type scale as the rest of the portal: Open Sans for UI copy, // Nunito for headings, and Roboto Mono for identifiers and measured values. { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, diff --git a/apps/catalog/dev-server-exclude.test.ts b/apps/catalog/dev-server-exclude.test.ts index 83383f1..01e8809 100644 --- a/apps/catalog/dev-server-exclude.test.ts +++ b/apps/catalog/dev-server-exclude.test.ts @@ -26,6 +26,25 @@ describe('what the dev server leaves to Vite', () => { } }) + /** + * The two a browser asks for on its own. Neither is a route, so both + * reached React Router and logged a "No route matches" stack trace on the + * dev server — the favicon on every boot, the Chrome probe on every + * DevTools open. + */ + it('leaves what a browser asks for unprompted to Vite', () => { + for (const url of [ + '/favicon.ico', + '/favicon.svg', + '/favicon-32x32.png', + '/apple-touch-icon.png', + '/apple-touch-icon-precomposed.png', + '/.well-known/appspecific/com.chrome.devtools.json', + ]) { + expect(leftToVite(url), url).toBe(true) + } + }) + it('leaves a document to the app', () => { for (const url of ['/', '/parts', '/parts/part-1?job=job-1', '/api/session']) { expect(leftToVite(url), url).toBe(false) diff --git a/apps/catalog/dev-server-exclude.ts b/apps/catalog/dev-server-exclude.ts index 87211d8..c750844 100644 --- a/apps/catalog/dev-server-exclude.ts +++ b/apps/catalog/dev-server-exclude.ts @@ -19,6 +19,15 @@ export const DEV_SERVER_EXCLUDE: Array = [ /^\/@.+$/, /\?t=\d+$/, /^\/favicon\.ico$/, + // The rest of the icons a browser asks for unprompted. `root.tsx` declares + // `/favicon.svg`, and a browser that ignores the declaration still guesses + // at `.png` or an apple-touch icon; each guess is Vite's to answer or 404 + // quietly, never a document for React Router to fail to route. + /^\/(?:favicon|apple-touch-icon)[\w-]*\.(?:ico|svg|png)$/, + // Chrome asks for this on every DevTools open, looking for a workspace + // mapping nobody here publishes. It is not a route, so React Router logged + // a "No route matches" stack trace each time DevTools came up. + /^\/\.well-known\/.*/, /^\/static\/.+/, /^\/node_modules\/.*/, '/assets/**', diff --git a/apps/catalog/public/favicon.svg b/apps/catalog/public/favicon.svg new file mode 100644 index 0000000..489b0f3 --- /dev/null +++ b/apps/catalog/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + From 7401a6241116a3cd1ccfa013225a33251806a3d0 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Thu, 10 Sep 2026 14:03:21 -0400 Subject: [PATCH 2/6] checkpoint --- apps/catalog/app/kit-usage.test.ts | 19 ++- apps/dfm/app/kit-usage.test.ts | 24 +++- docs/TOOL-SCRAPER-REFACTOR.md | 25 ++++ package.json | 5 + packages/catalog-data/scripts/profiles.mjs | 132 +++++++++++++-------- packages/catalog-data/scripts/store.mjs | 70 +++++++++++ packages/catalog-data/src/scrape.test.ts | 105 ++++++++++------ packages/catalog-data/src/scrape.ts | 127 ++++++++++++++------ pnpm-lock.yaml | 68 +---------- 9 files changed, 385 insertions(+), 190 deletions(-) diff --git a/apps/catalog/app/kit-usage.test.ts b/apps/catalog/app/kit-usage.test.ts index 67e31a3..6b27417 100644 --- a/apps/catalog/app/kit-usage.test.ts +++ b/apps/catalog/app/kit-usage.test.ts @@ -12,8 +12,25 @@ const componentFiles = (dir: string): Array => return path.endsWith('.tsx') && !path.endsWith('.test.tsx') ? [path] : [] }) +/** + * The file with its comments taken out. + * + * ` )} {/* @@ -467,18 +408,33 @@ export const ToolDetails = ({ the sheet, all lay the tool on its side; the first fix only gave the box a definite height, which left the ratio to chance. - **A capped width and a floor under the height**, rather than an - aspect ratio off `h-full`. That version was right two runs in three - and wrong in the other: `h-full` resolves against a parent whose own - height is not definite on the first layout pass, so `aspect-ratio` - derived the height from the width instead and the box came out - landscape — and the package reads the box once. 16 rem of width under - 18 rem of height cannot be landscape whatever the panel is doing, - because neither figure waits on a percentage to resolve. A taller - panel only makes it more portrait. + **The height, and a width derived from it** (Paul, 2026-09-11: "be + more aggressive about adjusting the viewer panel to match the + available space"). It was a flat 16 rem cap on the width, which is + upright at any panel size and throttles the drawing at most of them: + the scale is the smaller of the two ratios that fit, so on a panel + taller than it is wide the *width* is what binds — 16 rem of sheet + under 56 rem of panel drew a BT40 stack a third of the height it had + room for, with the rest of the sheet empty above and below it. + + So the box fills the height it is given and takes its width from + that: `width = height × 3/4`, clamped by the panel it is in. A box + whose width is three quarters of its own height cannot be landscape + whichever of the two the browser settles first, which is what the + 16 rem cap was buying — and on a panel narrower than that it is the + `max-w-full` that binds, so the sheet takes the whole column. + + The floor under the height is what keeps `w-auto` from collapsing: + the width is derived from the used height, so a height of nothing + would be a width of nothing. And the package re-measures — it watches + its own `` with a `ResizeObserver` as of 0.3.1 — so a box that + settles a frame late is corrected rather than fixed wrong, which is + what the first aspect-ratio attempt could not rely on. */} -
-
+ {/* Named so a test can measure the sheet against the room it was + given — the rule is a ratio, and a ratio needs both numbers. */} +
+
diff --git a/apps/catalog/app/routes/part.tsx b/apps/catalog/app/routes/part.tsx index 24ad498..efc4543 100644 --- a/apps/catalog/app/routes/part.tsx +++ b/apps/catalog/app/routes/part.tsx @@ -97,7 +97,6 @@ import { TABLE_OPENS_AT, ToolTableToolbar, hiddenByDefault, - type Holding, } from 'components/part-tool-table' import { ColumnPicker, sameBound } from 'components/column-filter' import { BUTTON_FILTERS, FACET_AXES } from 'components/filter-panel' @@ -106,6 +105,7 @@ import { hiddenAfterAuto } from 'shared/auto-columns' import { capRows, firstBy, keptFirst, oneEach } from 'shared/tool-order' import { allTools as catalogTools, + builtAt, collets as allCollets, facets, familyName, @@ -163,6 +163,7 @@ import { narrowTools, whyEmpty, } from 'shared/assembly-narrowing' +import { holderReport } from 'shared/holder-debug' import { COLLET_COLUMNS, HOLDER_COLUMNS, @@ -202,14 +203,7 @@ import { } from 'shared/holding' import { sectionOf } from 'shared/section-of' import { belowHolder, type BelowHolder } from 'shared/drawn-assembly' -import { - drawable, - holdable, - holderOptions, - policyOf, - thresholdsFrom, - type HolderOption, -} from 'shared/holder-choice' +import { drawable, holdable, holderOptions, policyOf, thresholdsFrom } from 'shared/holder-choice' import { closestMisses, closestPerForm, type Format } from 'shared/judge' import { cautionedTypes, @@ -423,22 +417,6 @@ const busyTyping = (event: KeyboardEvent): boolean => typingInto(event) || (event.target as HTMLElement | null)?.closest('[data-part-tool-table]') != null -/** - * Whether a holder option has a silhouette to draw. - * - * **Only the holders that can be drawn are offered** (Paul, 2026-09-07: - * "exclude any holders without models"). A record with no measured profile and - * no published nose has no shape at all, so picking it draws a blank panel. - * `drawable` is the rule and `holder-choice.ts` documents it; this is the one - * place the catalog's own profile document is what answers it. - * - * The dropdown only. Whether a tool can be *held* is a different question from - * whether its holder has a picture, and narrowing the tool list by this would - * take tools off a shop's list because a vendor publishes no CAD. - */ -const hasPicture = (option: HolderOption): boolean => - drawable(option.holder, (guid: string) => getProfile(guid) !== null) - const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: string }) => { const [unit, setUnit] = useUnit() const [search, setSearch] = useSearchParams() @@ -2513,209 +2491,27 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: const shownRows = useMemo(() => keptFirst(searched, keptHere), [searched, keptHere]) /** - * Holder, collet and stickout picked on a row, per tool, for the feature - * being read — the person's, until Save writes them to the sheet. Cleared - * when the reading changes: a holder picked for a pocket is not a holder - * picked for a hole. + * A tool a press asked for, kept across the reading it also asked for. + * + * **The holder beside it is gone** (2026-09-11). A `picked` map sat here + * holding a holder, a collet and a stickout per tool, written by the panel's + * own dropdowns and read back by whatever wrote the sheet. The dropdowns + * were its only writer, so with them the map could only ever be empty — and + * an empty map still read like a person's unsaved answer at three call + * sites. What holds a tool is the tree's slots, and `shared/assembly-tree` + * keeps those. */ - const [picked, setPicked] = useState< - Readonly< - Record< - string, - { holderGuid?: string | null; colletGuid?: string | null; stickout?: number | null } - > - > - >({}) - /** A tool a press asked for, kept across the reading it also asked for. */ const wantedTool = useRef(null) useEffect(() => { - setPicked({}) setChosenTool(wantedTool.current) wantedTool.current = null }, [focused]) - /** - * The holder and collet for a tool: the columns, the stickout column, and - * the panel beside the part all ask the same question. - * - * Grading every holder in the crib against a tool is real work, so it is - * done **per tool that asks**, cached for as long as the crib and the - * clearances hold still. Nothing is graded until something calls for it, so - * the panel's one tool costs one tool, and a list of two hundred pays only - * for the columns that are actually ticked. - */ - const optionsFor = useMemo(() => { - const cache = new Map>() - return (each: CatalogTool): Array => { - const had = cache.get(each.guid) - if (had) { - return had - } - const made = holderOptions( - each, - allHolders, - allCollets, - holderFilters, - curve, - margins, - thresholds, - ) - cache.set(each.guid, made) - return made - } - }, [holderFilters, curve, margins, thresholds]) - const holding = useMemo(() => { - return { - /** - * **A collet chosen first puts its own chucks at the top** (Paul, - * 2026-09-01: "then all holders are shown but we show the ones that work - * with that collet at the top"). Every holder is still offered — the - * collet is a preference, not a filter — and the ones of its series lead. - */ - holdersFor: (each) => { - const chosenCollet = picked[each.guid]?.colletGuid - const series = - chosenCollet == null ? undefined : (getCollet(chosenCollet)?.series ?? undefined) - // Only the holders that can be drawn — `hasPicture` above says why, and - // `undrawable` below reports what that hid. - const options = optionsFor(each).filter(hasPicture) - const ordered = - series === undefined - ? options - : [ - ...options.filter((option) => option.holder.colletSeries === series), - ...options.filter((option) => option.holder.colletSeries !== series), - ] - return ordered.map((option) => ({ - guid: option.holder.guid, - label: - option.holder.colletSeries === series - ? `${option.holder.catalogNumber} · takes this collet` - : option.holder.catalogNumber, - holder: option.holder, - trouble: option.unstocked - ? `no ${option.holder.colletSeries ?? 'matching'} collet stocked` - : option.clears === false - ? 'collision with geometry' - : option.band === 'bad' - ? 'too little grip' - : null, - })) - }, - /** - * How many holders were left off for having no picture, so the panel can - * say so rather than showing an empty dropdown (Paul, 2026-09-07). - */ - undrawable: (each) => optionsFor(each).filter((option) => !hasPicture(option)).length, - /** - * With a holder: the collets of its series that close on the shank. - * **Without one: every collet that closes on the shank**, whatever series - * it belongs to, each saying which series that is — the dropdown used to - * be empty until a holder was picked, which read as broken (Paul, - * 2026-09-01). - */ - colletsFor: (each, holderGuid) => { - const holder = optionsFor(each).find((option) => option.holder.guid === holderGuid)?.holder - if (holder === undefined) { - return colletsForShank(each, allCollets).map((collet) => ({ - guid: collet.guid, - label: `${collet.catalogNumber} · ${collet.series}`, - })) - } - return colletsFor(each, holder, allCollets).map((collet) => ({ - guid: collet.guid, - label: collet.catalogNumber, - })) - }, - chosen: (each) => ({ - holderGuid: picked[each.guid]?.holderGuid ?? null, - colletGuid: picked[each.guid]?.colletGuid ?? null, - }), - /** What the chosen stack stands out at: the person's, or the option's own. */ - stickoutFor: (each) => { - const holderGuid = picked[each.guid]?.holderGuid ?? null - return ( - picked[each.guid]?.stickout ?? - optionsFor(each).find((option) => option.holder.guid === holderGuid)?.stickout ?? - null - ) - }, - requiredStickout: (each) => { - const holderGuid = picked[each.guid]?.holderGuid ?? null - if (holderGuid === null) { - return null - } - return ( - optionsFor(each).find((option) => option.holder.guid === holderGuid)?.required ?? null - ) - }, - /** - * Why nothing in the crib can hold it, in one line. - * - * The holder stage drops a tool for one of two reasons and said neither: - * every stack fouls the part at the stickout this feature needs, or the - * tool is too short to stand out that far and keep hold. Both are about - * a length, and a length is what somebody can go and change. - */ - reachNote: (each) => { - const options = optionsFor(each) - /** - * **Never "no holder grips this shank"** (Paul, 2026-09-01: "means - * nothing, never show it"). It said the crib holds nothing that takes - * this shank, which is a fact about the crib rather than about the - * length the cell is for — and it stood in that cell against every - * tool of a size nobody has a collet for, which is most of a - * seventeen-thousand-tool catalog. - */ - if (options.length === 0) { - return null - } - if (options.some((option) => option.grade !== 'bad')) { - return null - } - /** - * **One stack's story, not two halves of two.** - * - * Taking the least required stickout from one holder and the longest - * grip from another read as "needs 53 mm out; holds at 55" — which - * says it fits (Paul, 2026-08-31). The stack that comes closest is the - * one worth quoting, and closest means the smallest gap between what - * it needs and what it can hold. - */ - const gaps = options.flatMap((option) => { - const needs = option.required - const most = option.range?.max ?? null - return needs === null || most === null || needs <= most - ? [] - : [{ needs, most, by: needs - most }] - }) - const closestStack = gaps.sort((a, b) => a.by - b.by)[0] - return closestStack === undefined - ? 'no holder clears the part here' - : `needs ${format(closestStack.needs, 'mm')} out, holds ${format(closestStack.most, 'mm')}` - }, - onChoose: (each, choice) => - setPicked((current) => ({ - ...current, - [each.guid]: { ...current[each.guid], ...choice }, - })), - } - }, [optionsFor, picked]) - /** * The list: the ten best, each as the assembly the rules recommend — and, * when fewer than ten fit, the nearest misses after them, marked * incompatible and saying by how much. */ - /** - * The tool being read, and the holders for it. - * - * This was a ten-row table with a superlative badge on each, computed on - * every render — and nothing has drawn that table since the list took its - * place. What survives is the one thing the page still asks: which tool is - * being read, and what can hold it (Paul, 2026-08-31, on a page running - * slowly: ten `holderOptions` sweeps per render, thrown away). - */ /** * The row being drawn: the one clicked, or the first — the drawing is never * empty once a feature is read. From the full table, any tool at all. @@ -2809,49 +2605,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: return panes.tap ?? tool ?? shownRows[0] ?? null }, [chosenTool, threadSpec, tool, shownRows, tapRows]) - const pick = useCallback( - ( - guid: string, - change: { holderGuid?: string | null; colletGuid?: string | null; stickout?: number | null }, - ) => setPicked((current) => ({ ...current, [guid]: { ...current[guid], ...change } })), - [], - ) - - /** Save writes the drawn assembly to the sheet for this feature, and opens the strip. */ - const saveAssembly = useCallback( - (saved: CatalogTool) => { - const mine = picked[saved.guid] - const options = holderOptions( - saved, - allHolders, - allCollets, - holderFilters, - curve, - margins, - thresholds, - ) - const option = - options.find((each) => each.holder.guid === mine?.holderGuid) ?? - options.find((each) => each.recommended) ?? - options[0] ?? - null - const stickout = mine?.stickout ?? option?.stickout ?? null - commit( - addChoice(sheet, choiceKey, { - toolGuid: saved.guid, - ...(option ? { holderGuid: option.holder.guid } : {}), - ...(mine?.colletGuid - ? { colletGuid: mine.colletGuid } - : option?.collet - ? { colletGuid: option.collet.guid } - : {}), - ...(stickout === null ? {} : { stickout }), - }), - ) - }, - [picked, holderFilters, curve, margins, thresholds, commit, sheet, choiceKey], - ) - /** Identical holes are one decision — `shared/part-interaction` says why. */ const groupOf = useCallback( (featureTag: string) => holeGroupOf(report.features, featureTag), @@ -4441,6 +4194,66 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: return filterComponents('holder', offered.shown, holderQuery).length - holderRows.length }, [noCollet, holderRows, offered, holderQuery]) + /** + * The whole holder funnel, on demand, for somebody looking at an empty rack. + * + * **A row that is not there says nothing about which rule removed it.** Five + * gates stand between the crib and the table and every one of them fails + * identically on screen, so "why are there no hydraulic or shrink-fit holders + * for this feature" cannot be answered by looking. `holderReport` re-asks the + * same rules over the same lists and counts the answers. + * + * Dev only, and reached from the console rather than from the page: + * `__holderDebug()` prints it and returns it, so it can be copied out. There + * is no UI for it because it is a question asked while something is already + * wrong, not a thing the page offers. + */ + useEffect(() => { + if (!import.meta.env.DEV) { + return + } + const report = (): string => { + const text = holderReport({ + about: [ + `job ${jobId}`, + askedNow.tags.length === 0 + ? 'no feature asked' + : `feature tags [${askedNow.tags.join(', ')}]`, + assembly === null ? 'no assembly' : `assembly ${assembly.id} (role ${assembly.role})`, + `slot ${componentSlot ?? 'tool'}`, + ].join(' · '), + dataset: builtAt, + holders: allHolders, + collets: allCollets, + tools: asking ? stackShanks : null, + chosenTool: treeTool, + chosenCollet: treeCollet, + filters: holderFilters, + canDraw: (holder) => drawable(holder, (guid) => getProfile(guid) !== null), + inQuery: (holder) => filterComponents('holder', [holder], holderQuery).length > 0, + noCollet, + }) + console.log(text) + return text + } + ;(window as unknown as { __holderDebug?: () => string }).__holderDebug = report + return () => { + delete (window as unknown as { __holderDebug?: () => string }).__holderDebug + } + }, [ + jobId, + askedNow, + assembly, + componentSlot, + asking, + stackShanks, + treeTool, + treeCollet, + holderFilters, + holderQuery, + noCollet, + ]) + const colletPool = useMemo( // The same rule the holder slot follows, for the same reason: a collet // closing on nothing this feature can be cut with is not a row to click. @@ -5010,24 +4823,22 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: if (panelTool === null) { return [] } - const held = holding.chosen(panelTool) - const first = distinctIn(askedNow.tags)[0]?.[0] - const line = first === undefined ? null : chosenFor(sheet, first, panelTool.guid) const wanted = toolActions({ active: asking && !askedNow.summary, mapped: mappedHere.length, here: mappedHere.includes(panelTool.guid), - assemblyChanged: - line !== null && - ((line.holderGuid ?? null) !== held.holderGuid || - (line.colletGuid ?? null) !== held.colletGuid), + /* + **The panel cannot change an assembly any more** (2026-09-11). It + compared its own holder and collet dropdowns against the ordered line, + and offered *Update* where the two differed. The dropdowns are gone — + a holder is a slot of the tree — so the panel holds nothing to differ + *with*, and reading the empty pick as a change would offer *Update* on + every tool ordered with a holder. + */ + assemblyChanged: false, }) - /** This tool, with whatever the panel has it held in. */ - const asLine = { - toolGuid: panelTool.guid, - ...(held.holderGuid === null ? {} : { holderGuid: held.holderGuid }), - ...(held.colletGuid === null ? {} : { colletGuid: held.colletGuid }), - } + /** This tool alone: what holds it is the tree's answer, not this panel's. */ + const asLine = { toolGuid: panelTool.guid } /* **Every tag being asked about, not the first of each distinct feature.** The tree writes a line under all of a row's keys and this wrote it under @@ -5084,8 +4895,6 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: })) }, [ panelTool, - holding, - distinctIn, askedNow, asking, sheet, @@ -5313,19 +5122,15 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: )} feature={reading ? listTitle.replace(/^Cuts the /, '') : null} unit={unit} - // What the row already had chosen in its Holder and Collet columns, - // so the box opens on it rather than asking twice. - // What the row had chosen, or what the bill already holds for it — - // the pencil reopens the box on the decision it is editing. + // What the bill already holds for it — the pencil reopens the box on + // the decision it is editing. It read an unsaved pick off the row's + // Holder and Collet dropdowns first; those came off on 2026-09-11, + // so the sheet is the whole answer. holderGuid={ - picked[adding.tool.guid]?.holderGuid ?? - chosenFor(sheet, adding.featureTag ?? choiceKey, adding.tool.guid)?.holderGuid ?? - null + chosenFor(sheet, adding.featureTag ?? choiceKey, adding.tool.guid)?.holderGuid ?? null } colletGuid={ - picked[adding.tool.guid]?.colletGuid ?? - chosenFor(sheet, adding.featureTag ?? choiceKey, adding.tool.guid)?.colletGuid ?? - null + chosenFor(sheet, adding.featureTag ?? choiceKey, adding.tool.guid)?.colletGuid ?? null } onCancel={() => setAdding(null)} onConfirm={({ holderGuid, colletGuid }) => { @@ -6916,8 +6721,8 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: One sheet for every slot: the panel decides what goes under the drawing, this decides what the drawing is of. No `holding` — the holder and the collet are slots of the tree - with tables of their own, so the panel offers no dropdowns; - `stack` is what still draws them. + with tables of their own, so the panel offers no dropdowns + anywhere; `stack` is what draws them. */ toolDetails={ treeTool === null @@ -6956,13 +6761,13 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId: = document.tools + +/** + * When the loaded dataset was built. + * + * **Which document this page is reading, in one value.** `vite.config.ts` + * picks the gitignored scrape or the committed sample, and a stale scrape + * silently falls back to the sample — three holders and nine tools, which + * looks exactly like a crib that holds nothing for the feature in front of + * somebody. `holder-debug.ts` prints this so a report says which. + */ +export const builtAt: string = document.builtAt export const facets: Facets = document.facets /** diff --git a/apps/catalog/app/shared/clearance-debug.test.ts b/apps/catalog/app/shared/clearance-debug.test.ts new file mode 100644 index 0000000..bf53de6 --- /dev/null +++ b/apps/catalog/app/shared/clearance-debug.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { materialProfile, NO_MARGINS } from '@toolpath/catalog-data' +import type { ReachCurve } from '@toolpath/part-contracts' +import { clearanceCase, clearanceReport, type ClearanceDebugInput } from './clearance-debug' + +const CURVE: ReachCurve = { horizontalOffset: [0, 8], verticalOffset: [12, 30] } + +const input = (over: Partial = {}): ClearanceDebugInput => ({ + about: 'ER16 · ⌀6 end mill', + curve: CURVE, + cuttingRadius: 3, + profile: materialProfile(CURVE, 3), + extent: { height: 60, radius: 23 }, + box: { width: 380, height: 506 }, + asked: { plus: 240 }, + granted: { + padding: { minus: 16, plus: 16, along: 16 }, + reserve: { minus: 16, plus: 120, along: 16 }, + }, + scale: 10, + fontSize: 1.4, + viewBox: '-24 -2 52 64', + margins: NO_MARGINS, + stickout: 31.75, + ...over, +}) + +describe('clearanceCase', () => { + it('states its units and copies the curve, so a saved case cannot be read as inches', () => { + const shape = clearanceCase(input()) + expect(shape.units).toBe('mm') + expect(shape.curve.horizontalOffset).toEqual([0, 8]) + expect(shape.curve.verticalOffset).toEqual([12, 30]) + expect(JSON.parse(JSON.stringify(shape)).cuttingRadius).toBe(3) + }) +}) + +describe('clearanceReport', () => { + it('carries the four things the probe is run on', () => { + const text = clearanceReport(input()) + expect(text).toContain('horizontalOffset [0,8]') + expect(text).toContain('cutting 3 mm') + expect(text).toContain('height 60 mm, radius 23 mm') + expect(text).toContain('380 × 506 px') + expect(text).toContain('asked {"plus":240} px') + }) + + it('raises the unit check on a curve small enough to be inches', () => { + const inches: ReachCurve = { horizontalOffset: [0, 0.315], verticalOffset: [0.472, 1.181] } + const text = clearanceReport(input({ curve: inches, profile: materialProfile(inches, 3) })) + expect(text).toContain('SUSPECT') + }) + + it('does not raise it on a millimetre curve', () => { + expect(clearanceReport(input())).toContain('the curve is millimetre-sized against the stack') + }) + + it('names the sheet when the flank grants less room than the wall wants', () => { + const wide: ReachCurve = { horizontalOffset: [0, 10, 40], verticalOffset: [12, 20, 30] } + const text = clearanceReport(input({ curve: wide, profile: materialProfile(wide, 3) })) + expect(text).toContain('clipped by: the sheet') + }) + + it('says nothing clipped it where the wall got its room', () => { + expect(clearanceReport(input())).toContain('clipped by: nothing') + }) +}) diff --git a/apps/catalog/app/shared/clearance-debug.ts b/apps/catalog/app/shared/clearance-debug.ts new file mode 100644 index 0000000..561585d --- /dev/null +++ b/apps/catalog/app/shared/clearance-debug.ts @@ -0,0 +1,159 @@ +import type { Margins, OutlinePoint } from '@toolpath/catalog-data' +import type { ReachCurve } from '@toolpath/part-contracts' +import type { Box, Extent, Padding } from '@toolpath/tool-drawing' +import { clipped, lastRise, wallCorners } from '@toolpath/tool-drawing/clearance' + +/** + * Everything the clearance wall was drawn from, in one paste-able case. + * + * **The overlay's inputs are four numbers apart and none of them is on the + * screen.** A wall that collapses against the cut looks the same whether the + * curve reached it in inches, whether the sheet reserved no room on the `+r` + * flank, or whether the staircase itself has no rise in it — and the three have + * nothing to do with each other. So this dumps the inputs rather than a + * verdict: `case.json` for `scratchpad/reach-probe.mjs` in the drawing + * package's own repository, which re-runs the whole clip arithmetic offline, + * and a short report for the console under it. + * + * Pure, and reads nothing but its arguments. The frame and the panel box are + * the caller's to measure — only `` knows them, and it publishes + * the frame through `useDrawingContext`. + */ + +/** What the probe is told, and what the report is written from. */ +export interface ClearanceDebugInput { + /** What this drawing is of, in a phrase: the feature, the tool, the assembly. */ + readonly about: string + readonly curve: ReachCurve + readonly cuttingRadius: number + /** The material as the overlay takes it — `materialProfile(curve, cuttingRadius)`. */ + readonly profile: ReadonlyArray + /** What the sheet was framed to, in millimetres: the stack's extent, or a zoom's. */ + readonly extent: Extent + /** The panel as the package measured it, in CSS pixels. Zero before the observer fires. */ + readonly box: Box + /** The `padding` prop handed to ``, in pixels, as asked for. */ + readonly asked: Partial + /** The chrome and the reservation the frame actually granted, in pixels. */ + readonly granted: { readonly padding: Padding; readonly reserve: Padding | null } + readonly scale: number + readonly fontSize: number + readonly viewBox: string + readonly margins: Margins + /** The length of tool below the holder the sheet was drawn at, or null on a tool alone. */ + readonly stickout: number | null +} + +/** The case, as JSON: what the probe reads, and what a bug report should carry. */ +export interface ClearanceCase extends Omit { + /** Millimetres, stated so a case captured elsewhere cannot be read as inches. */ + readonly units: 'mm' + readonly curve: { + readonly horizontalOffset: ReadonlyArray + readonly verticalOffset: ReadonlyArray + } + readonly profile: ReadonlyArray +} + +export const clearanceCase = (input: ClearanceDebugInput): ClearanceCase => ({ + ...input, + units: 'mm', + curve: { + horizontalOffset: [...input.curve.horizontalOffset], + verticalOffset: [...input.curve.verticalOffset], + }, + profile: input.profile.map((point) => ({ r: point.r, z: point.z })), +}) + +const round = (value: number): number => Math.round(value * 1e4) / 1e4 + +const last = (values: ReadonlyArray): T | null => values[values.length - 1] ?? null + +const point = (at: OutlinePoint | null): string => + at === null ? '—' : `r ${round(at.r)}, z ${round(at.z)}` + +/** + * The unit check, which costs nothing and is the first thing to rule out. + * + * **The whole pipeline is millimetres and a caption can be in inches.** A curve + * that arrived in inches is 25.4× too small, which draws a wall standing hard + * against the cut — the same picture a genuinely shallow feature draws, and the + * same one a flank with no room left draws. So the maxima are stated with their + * inch reading beside them, and the suspicion is raised rather than asserted: a + * feature really can be a third of a millimetre deep. + */ +const unitCheck = (input: ClearanceDebugInput): string => { + const deepest = Math.max(0, ...input.curve.verticalOffset) + const widest = Math.max(0, ...input.curve.horizontalOffset) + const suspect = + deepest > 0 && deepest * 25.4 < input.extent.height && deepest < input.cuttingRadius + return [ + ` curve reaches ${round(widest)} across and ${round(deepest)} up`, + ` read as inches that would be ${round(widest * 25.4)} and ${round(deepest * 25.4)} mm`, + ` the stack it is drawn beside is ${round(input.extent.radius)} across and ${round(input.extent.height)} tall`, + suspect + ? ' SUSPECT: the curve is small enough against the stack to be an inch curve in a millimetre drawing' + : ' the curve is millimetre-sized against the stack', + ].join('\n') +} + +/** + * The overlay's own clip arithmetic, restated. + * + * **A twin of the first dozen lines of `ClearanceOverlay`**, deliberately: the + * question being asked is where the wall was cut, and the answer is three + * numbers the overlay computes and draws without ever naming. It uses the + * package's own `wallCorners`, `lastRise` and `clipped`, so only the formula + * between them is copied here — if that formula changes upstream, this block + * goes stale and the numbers it prints stop matching the picture. Check it + * against `scratchpad/reach-probe.mjs`, which is the authority. + */ +const clipping = (input: ClearanceDebugInput): string => { + const { extent, scale, fontSize, cuttingRadius } = input + const granted = input.granted + const noise = extent.height * 0.0005 + const corners = wallCorners(input.profile, noise) + const sheetEdge = extent.radius + (granted.padding.plus + (granted.reserve?.plus ?? 0)) / scale + const wanted = corners.length === 0 ? 0 : Math.max(lastRise(corners), cuttingRadius) + 2 + const wallEdge = Math.max(Math.min(wanted, sheetEdge - fontSize * 0.5), cuttingRadius + 1) + const wall = clipped(corners, wallEdge, extent.height) + const cutBy = + wanted > sheetEdge - fontSize * 0.5 + ? 'the sheet — the flank granted less room than the wall wanted' + : wallEdge <= cuttingRadius + 1 + ? 'the floor — the wall was pushed back to the cut itself' + : 'nothing — the wall got the room it asked for' + return [ + ` staircase: ${corners.length} corners, last rise at r ${round(lastRise(corners))}, last corner ${point(last(corners))}`, + ` sheet edge: ${round(sheetEdge)} mm (extent radius ${round(extent.radius)} + (padding ${round(granted.padding.plus)} + reserve ${round(granted.reserve?.plus ?? 0)}) px / ${round(scale)} px per mm)`, + ` wall wants: ${round(wanted)} mm gets: ${round(wallEdge)} mm`, + ` clipped by: ${cutBy}`, + ` drawn wall: ${wall.length} knots, last ${point(last(wall))}`, + ].join('\n') +} + +/** The whole report, as one string to read in the console. */ +export const clearanceReport = (input: ClearanceDebugInput): string => + [ + '=== clearance debug ===', + `about: ${input.about}`, + `units: millimetres throughout`, + `curve: ${input.curve.horizontalOffset.length} knots`, + ` horizontalOffset ${JSON.stringify([...input.curve.horizontalOffset])}`, + ` verticalOffset ${JSON.stringify([...input.curve.verticalOffset])}`, + `radius: cutting ${round(input.cuttingRadius)} mm`, + `extent: height ${round(input.extent.height)} mm, radius ${round(input.extent.radius)} mm`, + `box: ${round(input.box.width)} × ${round(input.box.height)} px${input.box.width === 0 ? ' (not measured yet)' : ''}`, + `padding: asked ${JSON.stringify(input.asked)} px`, + ` granted ${JSON.stringify(input.granted.padding)} px, reserve ${JSON.stringify(input.granted.reserve)} px`, + `frame: scale ${round(input.scale)} px/mm, type ${round(input.fontSize)} mm, viewBox "${input.viewBox}"`, + `margins: radial ${round(input.margins.radial)} mm, axial ${round(input.margins.axial)} mm`, + `stickout: ${input.stickout === null ? '— (tool alone)' : `${round(input.stickout)} mm below the holder`}`, + '', + 'unit check:', + unitCheck(input), + '', + 'where the wall is cut:', + clipping(input), + '=== end ===', + ].join('\n') diff --git a/apps/catalog/app/shared/holder-debug.test.ts b/apps/catalog/app/shared/holder-debug.test.ts new file mode 100644 index 0000000..b81165f --- /dev/null +++ b/apps/catalog/app/shared/holder-debug.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import type { CatalogTool, Collet, Holder } from '@toolpath/catalog-data' +import { holdersToOffer } from './assembly-narrowing' +import { holderReport } from './holder-debug' + +const holder = (over: Partial): Holder => + ({ + guid: 'holder-a', + familyId: 'sample-bt30-holders', + brand: 'Kennametal', + vendor: 'Kennametal', + catalogNumber: 'BT30ER16060M', + materialNumber: null, + taper: 'BT30', + contact: 'taper', + clamping: 'collet', + colletSeries: 'ER16', + boreDiameter: null, + gaugeLength: 60, + noseDiameter: 34, + noseLength: 8, + bodyDiameter: 42, + bodyLength: 3, + projection: 11.6, + flangeDiameter: 46, + colletProtrusion: 2, + productLink: null, + cadModelUrl: null, + provenance: {}, + ...over, + }) as Holder + +const collet = (over: Partial): Collet => + ({ + guid: 'collet-a', + familyId: 'sample-er16-collets', + brand: 'Kennametal', + vendor: 'Kennametal', + catalogNumber: 'ER16-6', + materialNumber: null, + series: 'ER16', + clampMin: 5, + clampMax: 6, + clampLength: 18, + productLink: null, + provenance: {}, + ...over, + }) as Collet + +const tool = (guid: string, shank: number): CatalogTool => + ({ + guid, + catalogNumber: guid, + brand: 'Kennametal', + familyId: 'sample', + form: 'square end mill', + geometry: { DC: shank, SFDM: shank, LCF: 20, OAL: 60 }, + }) as unknown as CatalogTool + +const er16 = collet({}) +const er16Chuck = holder({}) +/** A shrink chuck whose one bore is 6 mm, drawable. */ +const shrink = holder({ + guid: 'holder-shrink', + catalogNumber: 'BT30SF06', + clamping: 'shrink', + colletSeries: null, + boreDiameter: 6, +}) +/** A hydraulic chuck that publishes no silhouette from any source. */ +const hydraulic = holder({ + guid: 'holder-hyd', + catalogNumber: 'BT30HC12', + clamping: 'hydraulic', + colletSeries: null, + boreDiameter: 12, + noseDiameter: null, +}) + +const input = (over: Partial[0]> = {}) => ({ + about: 'a feature', + dataset: '2026-09-10T00:00:00.000Z', + holders: [er16Chuck, shrink, hydraulic], + collets: [er16], + tools: [tool('t6', 6)], + chosenTool: null, + chosenCollet: null, + filters: {}, + canDraw: (each: Holder) => each.noseDiameter !== null, + inQuery: () => true, + noCollet: false, + ...over, +}) + +describe('the holder report', () => { + it('says which dataset is loaded, since the fallback looks like an empty crib', () => { + expect(holderReport(input())).toContain('dataset: built 2026-09-10T00:00:00.000Z') + }) + + it('names the shanks being asked about', () => { + expect(holderReport(input({ tools: [tool('t6', 6), tool('t12', 12)] }))).toContain( + 'shanks 6, 12', + ) + }) + + it('blames the bore when a shrink chuck does not match any asked shank', () => { + const text = holderReport(input({ tools: [tool('t10', 10)] })) + expect(text).toContain('bore 6 is none of the asked shanks (10)') + }) + + it('blames the silhouette when a holder that fits cannot be drawn', () => { + const text = holderReport(input({ tools: [tool('t12', 12)] })) + expect(text).toContain('no published nose diameter') + expect(text).toContain('BT30HC12') + }) + + it('blames a crib filter before anything else, so a forgotten filter is not read as a misfit', () => { + const text = holderReport(input({ filters: { taper: ['CAT40'] } })) + expect(text).toContain('a crib filter excludes it') + }) + + it('says a chuck was held back for want of a collet, and which press shows it', () => { + const text = holderReport(input({ collets: [], tools: [tool('t6', 6)], holders: [er16Chuck] })) + expect(text).toContain('show with no collet') + }) + + /** + * The report re-asks the rules rather than reading the page, so the one thing + * that can go wrong is the two disagreeing. This is the lockstep check. + */ + it('counts exactly what the table would draw', () => { + const each = input({ tools: [tool('t6', 6)] }) + const offered = holdersToOffer( + each.holders, + { tool: null, collet: null }, + each.collets, + each.filters, + each.canDraw, + each.tools, + () => null, + ) + const text = holderReport(each) + const line = text.split('\n').find((row) => row.startsWith('match table filters')) + expect(line?.trim().split(/\s+/).slice(3).join(' ')).toBeDefined() + expect(Number(line?.trim().split(/\s+/)[3])).toBe(offered.stocked.length) + }) +}) diff --git a/apps/catalog/app/shared/holder-debug.ts b/apps/catalog/app/shared/holder-debug.ts new file mode 100644 index 0000000..5c4ac5e --- /dev/null +++ b/apps/catalog/app/shared/holder-debug.ts @@ -0,0 +1,301 @@ +import { + holderCanTake, + holderMayTake, + holderNeedsCollet, + matchesFilters, + seriesSize, + seriesUnstocked, + type CatalogTool, + type Collet, + type Holder, + type HolderFilters, +} from '@toolpath/catalog-data' + +/** + * Why the rack in front of somebody holds what it holds — the whole funnel, in + * text they can paste. + * + * **An empty rack says nothing about which rule emptied it.** A holder reaches + * the table through five gates in `holdersToOffer` and the table's own query, + * and every one of them fails the same way on screen: the row is not there. + * "Why are there no hydraulic or shrink-fit holders for this feature" is a + * question the page cannot answer and this can — a shrink chuck is dropped for + * publishing no bore, for publishing one that is not this shank, for having no + * measured silhouette, or for a filter somebody forgot they set, and those are + * four different things to do about it. + * + * Pure, and reads nothing but its arguments: the caller hands in the two + * predicates that need the catalog's own documents, exactly as + * `holdersToOffer` takes `canDraw`. Nothing here decides anything the page + * shows — it re-asks the same rules and counts the answers, so a report that + * disagrees with the table is a bug in this module rather than a second + * opinion. + */ + +/** The gates a holder passes on its way to a row, in the order it meets them. */ +export type Gate = 'filters' | 'fit' | 'drawable' | 'collet' | 'query' + +const GATE_LABEL: Readonly> = { + filters: 'match crib filters', + fit: 'can hold the asked tool(s)', + drawable: 'has a silhouette', + collet: 'crib has a collet for it', + query: 'match table filters', +} + +const GATES: ReadonlyArray = ['filters', 'fit', 'drawable', 'collet', 'query'] + +export interface HolderDebugInput { + /** What the report is about, in a phrase: the feature, the slot, the role. */ + readonly about: string + /** + * Which dataset is loaded — `builtAt` from `shared/catalog.ts`. + * + * **The first thing to check and the one a screenshot cannot show.** A + * scrape built against an older contract falls back to the committed sample, + * which is three holders and nine tools, and a rack that is empty because of + * that is indistinguishable from a rack that is empty because of a rule. + */ + readonly dataset: string + readonly holders: ReadonlyArray + readonly collets: ReadonlyArray + /** + * The tools the slot is standing on — `stackShanks`, one per shank — or null + * where no feature is being asked and the rack is a catalog again. + */ + readonly tools: ReadonlyArray | null + readonly chosenTool: CatalogTool | null + readonly chosenCollet: Collet | null + readonly filters: HolderFilters + /** `drawable(holder, …)`, handed in because it needs the profile document. */ + readonly canDraw: (holder: Holder) => boolean + /** Whether the table's own column filters keep this holder. */ + readonly inQuery: (holder: Holder) => boolean + /** Whether the press in the chrome is showing chucks the crib has no collet for. */ + readonly noCollet: boolean +} + +/** Which gate a holder fell at, and in a few words why. */ +interface Fate { + readonly holder: Holder + readonly gate: Gate | null + readonly why: string +} + +const shanksOf = (tools: ReadonlyArray): ReadonlyArray => + [...new Set(tools.map((tool) => tool.geometry.SFDM).filter((shank) => shank !== undefined))].sort( + (a, b) => a - b, + ) + +const list = (values: ReadonlyArray): string => + values.length === 0 ? 'none' : values.map((value) => String(value)).join(', ') + +/** Why this holder holds none of the asked tools, for a holder that grips the shank. */ +const boreWhy = (holder: Holder, shanks: ReadonlyArray): string => { + if (holder.boreDiameter === null) { + return 'publishes no bore diameter' + } + if (shanks.length === 0) { + return 'no asked tool states a shank' + } + return `bore ${holder.boreDiameter} is none of the asked shanks (${list(shanks)})` +} + +/** The same, for a collet chuck, which is judged on its series' nominal size. */ +const colletWhy = ( + holder: Holder, + collets: ReadonlyArray, + shanks: ReadonlyArray, + chosen: Collet | null, +): string => { + if (chosen !== null) { + return holder.colletSeries === chosen.series + ? `${chosen.catalogNumber} does not close on ${list(shanks)}` + : `takes ${holder.colletSeries ?? 'no'} collets, not the chosen ${chosen.series}` + } + const bound = seriesSize(holder.colletSeries) + if (bound !== null && shanks.every((shank) => shank > bound)) { + return `${holder.colletSeries ?? 'its series'} cannot reach ${list(shanks)}` + } + if (seriesUnstocked(holder, collets)) { + return `the crib stocks no ${holder.colletSeries ?? ''} collet`.replace(' ', ' ') + } + return `no stocked ${holder.colletSeries ?? ''} collet closes on ${list(shanks)}`.replace( + ' ', + ' ', + ) +} + +/** Why a holder has no silhouette, which is one of two absent things. */ +const drawWhy = (holder: Holder): string => + holder.cadModelUrl === null + ? 'no measured profile, no published nose diameter, and no CAD model to measure' + : 'no measured profile and no published nose diameter — its CAD model has not been measured' + +const fateOf = (holder: Holder, input: HolderDebugInput, shanks: ReadonlyArray): Fate => { + const { collets, tools, chosenTool, chosenCollet, filters } = input + if (!matchesFilters(holder, filters)) { + return { holder, gate: 'filters', why: 'a crib filter excludes it' } + } + if ( + chosenCollet !== null && + !(holderNeedsCollet(holder) && holder.colletSeries === chosenCollet.series) + ) { + return { holder, gate: 'fit', why: `does not take the chosen ${chosenCollet.series} collet` } + } + const wanted = chosenTool !== null ? [chosenTool] : tools + if (wanted !== null) { + const takes = wanted.some((tool) => + chosenCollet === null + ? holderMayTake(tool, holder, collets) + : holderCanTake(tool, holder, collets), + ) + if (!takes) { + return { + holder, + gate: 'fit', + why: holderNeedsCollet(holder) + ? colletWhy(holder, collets, shanks, chosenCollet) + : boreWhy(holder, shanks), + } + } + } + if (!input.canDraw(holder)) { + return { holder, gate: 'drawable', why: drawWhy(holder) } + } + const gapped = + holderNeedsCollet(holder) && + (wanted === null + ? seriesUnstocked(holder, collets) + : !wanted.some((tool) => holderCanTake(tool, holder, collets))) + if (gapped && !input.noCollet) { + return { + holder, + gate: 'collet', + why: `no stocked ${holder.colletSeries ?? ''} collet grips it — press "show with no collet" to see it`.replace( + ' ', + ' ', + ), + } + } + if (!input.inQuery(holder)) { + return { holder, gate: 'query', why: 'a table column filter excludes it' } + } + return { holder, gate: null, why: '' } +} + +const CLAMPINGS: ReadonlyArray = ['collet', 'shrink', 'hydraulic', 'bore'] + +const clampingOf = (holder: Holder): string => + CLAMPINGS.includes(holder.clamping) ? holder.clamping : 'other' + +const pad = (text: string, width: number): string => text.padEnd(width, ' ') + +const padStart = (text: string, width: number): string => text.padStart(width, ' ') + +/** The funnel as a table: one row per gate, one column per clamping mode. */ +const funnel = (fates: ReadonlyArray): string => { + const modes = [...new Set(fates.map((fate) => clampingOf(fate.holder)))].sort() + const survives = (fate: Fate, upto: number): boolean => { + const fell = fate.gate === null ? GATES.length : GATES.indexOf(fate.gate) + return fell >= upto + } + const width = 26 + const cell = 11 + const header = + pad('stage', width) + padStart('all', cell) + modes.map((mode) => padStart(mode, cell)).join('') + const row = (label: string, upto: number): string => { + const kept = fates.filter((fate) => survives(fate, upto)) + return ( + pad(label, width) + + padStart(String(kept.length), cell) + + modes + .map((mode) => + padStart(String(kept.filter((fate) => clampingOf(fate.holder) === mode).length), cell), + ) + .join('') + ) + } + return [ + header, + row('in the crib', 0), + ...GATES.map((gate, index) => row(GATE_LABEL[gate], index + 1)), + ].join('\n') +} + +/** The reasons a mode lost holders, most common first, with an example each. */ +const reasons = (fates: ReadonlyArray): string => { + const lost = fates.filter((fate) => fate.gate !== null) + const modes = [...new Set(lost.map((fate) => clampingOf(fate.holder)))].sort() + return modes + .map((mode) => { + const mine = lost.filter((fate) => clampingOf(fate.holder) === mode) + const grouped = new Map>() + for (const fate of mine) { + const key = `${fate.gate}: ${fate.why}` + const had = grouped.get(key) + if (had === undefined) { + grouped.set(key, [fate]) + } else { + had.push(fate) + } + } + const lines = [...grouped.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 6) + .map(([why, group]) => { + const one = group[0].holder + return ` ${padStart(String(group.length), 4)} ${why}\n e.g. ${one.brand} ${one.catalogNumber} (${one.taper}, bore ${one.boreDiameter ?? '—'}, series ${one.colletSeries ?? '—'}, guid ${one.guid})` + }) + return ` ${mode} — ${mine.length} dropped\n${lines.join('\n')}` + }) + .join('\n') +} + +/** + * The whole report, as one string to paste. + * + * Everything the page decided, in the order it decided it, with the counts a + * screen cannot show and an example holder against every reason so the claim + * can be checked against the vendor's own sheet. + */ +export const holderReport = (input: HolderDebugInput): string => { + const asked = input.chosenTool !== null ? [input.chosenTool] : (input.tools ?? []) + const shanks = shanksOf(asked) + const fates = input.holders.map((holder) => fateOf(holder, input, shanks)) + const boresBy = (mode: string): string => + list( + [ + ...new Set( + input.holders + .filter((holder) => clampingOf(holder) === mode && holder.boreDiameter !== null) + .map((holder) => holder.boreDiameter as number), + ), + ].sort((a, b) => a - b), + ) + const filters = Object.entries(input.filters) + .filter(([, values]) => Array.isArray(values) && values.length > 0) + .map(([axis, values]) => `${axis}=[${(values as ReadonlyArray).join(', ')}]`) + return [ + '=== holder debug ===', + `when: ${new Date().toISOString()}`, + `about: ${input.about}`, + `dataset: built ${input.dataset}`, + `chosen: tool ${input.chosenTool === null ? '—' : `${input.chosenTool.brand} ${input.chosenTool.catalogNumber} (shank ${input.chosenTool.geometry.SFDM ?? '—'})`} · collet ${input.chosenCollet === null ? '—' : `${input.chosenCollet.catalogNumber} (${input.chosenCollet.series})`}`, + `asked: ${asked.length} tool(s), shanks ${list(shanks)}`, + `crib: ${input.holders.length} holders, ${input.collets.length} collets`, + `filters: ${filters.length === 0 ? 'none' : filters.join(' ')}`, + `no-collet press: ${input.noCollet ? 'on (widened rack)' : 'off (stocked rack)'}`, + '', + funnel(fates), + '', + 'bores published, by clamping mode:', + ` shrink: ${boresBy('shrink')}`, + ` hydraulic: ${boresBy('hydraulic')}`, + ` bore: ${boresBy('bore')}`, + '', + 'why they went:', + reasons(fates), + '=== end ===', + ].join('\n') +} diff --git a/apps/catalog/tests/on-the-part.spec.ts b/apps/catalog/tests/on-the-part.spec.ts index d80cf3b..8e3ed56 100644 --- a/apps/catalog/tests/on-the-part.spec.ts +++ b/apps/catalog/tests/on-the-part.spec.ts @@ -540,6 +540,19 @@ test('draws the material around the feature beside the tool being read', async ( await page.keyboard.press('Escape') await expect(field(page)).toBeHidden() await expect(page.locator('[data-part="material"]')).toHaveCount(0) + + /* + **And it offers no way to assemble the tool it is reading** (Paul, + 2026-09-11). This is the one state the tool assembly tree does not cover — + nothing asked is nothing to assemble, so `treeKey` is null and the panel + falls through to `` on its own — and a Holder dropdown and a + Collet dropdown survived here for three days after the flag took them off + every other surface. Two ways to fill one slot, and no rule saying which + won. The panel reads a tool; the tree assembles one. + */ + await expect(page.getByRole('img', { name: /drawn from its stated dimensions/ })).toBeVisible() + await expect(page.getByRole('combobox', { name: 'Holder' })).toHaveCount(0) + await expect(page.getByRole('combobox', { name: 'Collet' })).toHaveCount(0) }) /** @@ -3284,7 +3297,7 @@ test.describe('the tool assembly tree', () => { /** * **One sheet, whichever component is open.** Selecting a holder used to swap * the panel for a second drawing in a box of its own — which came out lying on - * its side, and took the Tool / Tool + holder switch away with it (Paul, + * its side, and took the zoom over the sheet away with it (Paul, * 2026-09-07). Only the column under the drawing changes now. */ test('keeps one drawing, standing up, when a holder is selected', async ({ page }) => { @@ -3298,14 +3311,54 @@ test.describe('the tool assembly tree', () => { await expect(page.getByRole('img', { name: /drawn from its stated dimensions/ })).toHaveCount(1) await upright(page) - // The switch that chooses what the sheet shows survives reading a holder. - await expect(page.getByRole('button', { name: 'Tool + holder' })).toBeVisible() + // The press that frames the sheet survives reading a holder. + await expect(page.getByRole('button', { name: 'Zoom to tool' })).toBeVisible() // And the column under it is the holder's, not the cutter's numbers. // Scoped to the definition list: the table's column header says this too. await expect(page.locator('dt').filter({ hasText: 'Collet series' })).toBeVisible() await expect(page.locator('dt').filter({ hasText: 'Corner radius' })).toHaveCount(0) }) + /** + * **The sheet takes the room, not 16 rem of it** (Paul, 2026-09-11: "be more + * aggressive about adjusting the viewer panel to match the available space"). + * + * The drawing's scale is the smaller of the two ratios that fit its box, so + * on a panel taller than it is wide the width is what binds — and the box was + * capped at a flat 16 rem while the panel was half as wide again. The stack + * came out a third of the height it had room for, with empty sheet above and + * below it. + * + * The rule now is a ratio rather than a number, so the assertion is the same + * ratio: the sheet is as wide as the room, or three quarters of the room's + * height, whichever is less. Both halves matter — the first is the width it + * gained, the second is what keeps it upright on a panel dragged wide. + */ + test('draws the sheet as wide as the room it is given', async ({ page }) => { + await ready(page) + await keepFeature(page) + await buildStack(page) + await upright(page) + + const sheet = page.getByRole('img', { name: /drawn from its stated dimensions/ }) + const room = page.locator('[data-sheet-room]') + + await expect + .poll( + async () => { + const drawn = await sheet.boundingBox() + const given = await room.boundingBox() + if (drawn === null || given === null || drawn.width === 0) { + return null + } + // A pixel of slack: a derived width lands on a fraction. + return Math.abs(drawn.width - Math.min(given.width, given.height * 0.75)) <= 1 + }, + { timeout: 15_000 }, + ) + .toBe(true) + }) + /** * **The sheet stands up at any panel width.** * diff --git a/docs/HOLDER-PROFILES.md b/docs/HOLDER-PROFILES.md index f3f2a12..c367576 100644 --- a/docs/HOLDER-PROFILES.md +++ b/docs/HOLDER-PROFILES.md @@ -159,7 +159,7 @@ holders at all. 379 of the 555 publish a `cadModelUrl` to measure. The remaining 176 have no silhouette from any source; `drawable` in `apps/catalog/app/shared/holder-choice.ts` -is what keeps them out of the holder dropdown, checked against the real +is what keeps them off the holder slot's table, checked against the real `assemblyOutline` in `holder-drawable.test.ts` rather than restating the package's gate. diff --git a/docs/TOOL-ASSEMBLY-TREE.md b/docs/TOOL-ASSEMBLY-TREE.md index 6f2b9be..f61f4f3 100644 --- a/docs/TOOL-ASSEMBLY-TREE.md +++ b/docs/TOOL-ASSEMBLY-TREE.md @@ -195,11 +195,19 @@ this assembly`, because a rack is narrowed to what fits a stack only while "I need to see the context aware changes I'm making, or I just need one button to confirm what is selected in the tool assembly." - **One drawing, whichever slot is open.** `` owns the sheet and - its Tool / Tool + holder switch; selecting a holder or a collet changes only - the column underneath it, through that panel's `details` prop. A second - drawing for the holder is what this replaces — it came out lying on its side, - because `orientationFor` reads the box it is given, and it took the switch - away the moment somebody looked at a holder. + the press that frames it — _Zoom to tool_ / _Zoom out_, which cuts the sheet + just above the holder nose rather than dropping the holder out of the + picture. Selecting a holder or a collet changes only the column underneath + it, through that panel's `details` prop. A second drawing for the holder is + what this replaces — it came out lying on its side, because `orientationFor` + reads the box it is given, and it took the press away the moment somebody + looked at a holder. + + **It used to be a Tool / Tool + holder switch** (replaced 2026-09-11). That + let the one picture on the page disagree with the tree about what was on the + tool — the tree with a holder in its slot, the sheet with a bare cutter. What + a reader wanted from the _Tool_ half was the working end drawn bigger, which + `@toolpath/tool-drawing`'s `zoom` prop now frames itself. **And an assembly need not answer a feature at all** (Paul, 2026-09-08). _+ Tool Assembly_, over the top-left of the part, makes a row of its own that holds @@ -878,6 +886,40 @@ the press under the stack makes the row and orders the assembly in one go, and an **X** in the top right of the box is the way out of all three. See `docs/FEATURE-LIST.md` § _The X in the corner_. +### The last two dropdowns, 2026-09-11 + +The flag's removal took the dropdowns off a **tool row**. It did not take them +off the **panel**, and nobody noticed for three days, because the tree covers +every state the panel is drawn in but one: a tool read with no feature selected. +`treeKey` is `null` when nothing is asked (`routes/part.tsx`, _"Nothing asked is +nothing to assemble"_), so `assemblies` is empty, `node` is `null`, and the +right-hand panel falls through to `` — a Holder +combobox, a Collet combobox, and the "_n_ more fit but have no model to draw" +note under them (Paul, 2026-09-11: _"I think the two dropdowns are not there any +more"_ — they were, in that one state, on `main` as well as on the branch). + +Two ways to fill one slot, and no rule saying which won: the dropdowns wrote a +`picked` map on the route, the tree writes the assembly, and nothing reconciled +them. Both dropdowns are gone, and with them: + +- `ToolDetails`'s `holding` prop. `stack` — what the tree put in the slots — is + now the panel's only source for a holder or a collet, so the picture cannot + disagree with the tree. +- The `holding` memo on the route, its `optionsFor` cache and `hasPicture`. +- The `picked` map, and the `pick` and `saveAssembly` callbacks that read it. + The dropdowns were its only writer, so it could only ever have been empty — + and an empty map still read like an unsaved answer at three call sites. +- `panelActions`'s `assemblyChanged`, which compared the dropdowns against the + ordered line to offer _Update_. The panel holds nothing to differ with now, + so it is `false`; reading the empty pick as a change would have offered + _Update_ on every tool ordered with a holder. +- `Holding`, `HoldingCell`, `isHolding` and the Holder and Collet columns on + `PartToolTable` — dead since 2026-09-08, drawing an em-dash per row. + +`OrderDialog` still carries a pair of its own and was **left standing**: it has +been unreachable since before any of this — `setAdding` is only ever called with +`null` — so removing it is a separate decision about a separate dialog. + What went with it that had no home under the tree: the **Show compatible end mills** press, which lived inside the drill tab and so had already been unreachable whenever the flag was on. The filter it pressed — @@ -956,12 +998,13 @@ crib, which is the claim. a bill, where `treeFromLines` puts the tap first however the lines are ordered, because a drill labelled `TAP` opens the tap list on a drill. - **`ComponentTable` is a sibling of `PartToolTable`, not a generalisation.** - That table carries the rules' marks, the holding comboboxes and the bill's - badge, all of which are about a _tool_; threading a row type through them would - have put every one behind a conditional to gain a shared shell. If a third - kind of component ever wants a table, extract then. -- **The tool table is handed no `holding`**, deliberately: the holder is a slot - of the stack with a table of its own, and a second way to set it from a - dropdown on the tool row is the defect the tree exists to remove. The prop is - still on `PartToolTable` — the component's own tests cover it — and the part - page passes it from nowhere. + That table carries the rules' marks and the bill's badge, both of which are + about a _tool_; threading a row type through them would have put every one + behind a conditional to gain a shared shell. If a third kind of component ever + wants a table, extract then. +- **Nothing but a slot fills a slot**, and as of 2026-09-11 there is no second + way left to try. `Holding`, `HoldingCell` and the `holding` prop are gone from + `PartToolTable`, and the Holder and Collet columns with them — the part page + had passed that prop from nowhere since the flag came out, so both columns + drew an em-dash on every row. `ToolDetails` lost its own pair the same day; + see § 6. From f7298acfb3d54bb6f774aa8551df399ddd9ce5c8 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Fri, 11 Sep 2026 14:28:11 -0400 Subject: [PATCH 4/6] Fix the two gates the debug modules tripped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `catalog-drawing.tsx` carried an `eslint-disable-next-line react-hooks/exhaustive-deps`. No `react-hooks` plugin is configured in this repository and no other file disables that rule, so the directive named a rule that does not exist and `pnpm lint` failed on it. The reason for the single-dependency array is kept as a comment. - `clearance-debug.test.ts` reached the sheet-clip branch with a wider curve, which does not reach it: `lastRise` is where the staircase's final step starts, so widening the curve moves the last corner without moving what the wall asks for — 13 mm against a 36.6 mm sheet edge. Narrowed the sheet instead, which is the case the branch is for: a flank that reserved no room. --- apps/catalog/app/components/catalog-drawing.tsx | 5 +++-- apps/catalog/app/shared/clearance-debug.test.ts | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx index 0587214..52ab076 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -286,8 +286,9 @@ const ClearanceProbe = ({ return () => { delete (window as unknown as { __clearanceDebug?: () => ClearanceCase }).__clearanceDebug } - // The signature is what identifies a drawing; the rest is read through it. - // eslint-disable-next-line react-hooks/exhaustive-deps + // The signature is what identifies a drawing; the rest is read through it, + // so it is the whole dependency. (No `react-hooks` plugin is configured in + // this repository, so there is no exhaustive-deps directive to disable.) }, [signature]) return } diff --git a/apps/catalog/app/shared/clearance-debug.test.ts b/apps/catalog/app/shared/clearance-debug.test.ts index bf53de6..eba3542 100644 --- a/apps/catalog/app/shared/clearance-debug.test.ts +++ b/apps/catalog/app/shared/clearance-debug.test.ts @@ -55,9 +55,22 @@ describe('clearanceReport', () => { expect(clearanceReport(input())).toContain('the curve is millimetre-sized against the stack') }) + /** + * The flank that reserved nothing, which is one of the four things a wall + * standing hard against the cut can mean and the only one the picture cannot + * distinguish. A wide curve does not reach this branch: `lastRise` is where + * the staircase's final step *starts*, so widening the curve moves the last + * corner without moving what the wall asks for. It is the sheet that has to + * be narrow — here the `+r` reserve is `null` and the stack is 2 mm across, + * which puts the edge at 3.6 mm under a wall wanting 13. + */ it('names the sheet when the flank grants less room than the wall wants', () => { - const wide: ReachCurve = { horizontalOffset: [0, 10, 40], verticalOffset: [12, 20, 30] } - const text = clearanceReport(input({ curve: wide, profile: materialProfile(wide, 3) })) + const text = clearanceReport( + input({ + extent: { height: 60, radius: 2 }, + granted: { padding: { minus: 16, plus: 16, along: 16 }, reserve: null }, + }), + ) expect(text).toContain('clipped by: the sheet') }) From d79b8d25c72a5c888df35d3fbd3bffdf67981c47 Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Fri, 11 Sep 2026 14:31:57 -0400 Subject: [PATCH 5/6] Take the published packages, and drop both link: overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `@toolpath` package the template needs is on npm as of today, and the `ui-packages` working tree is clean for all of them — so the two `link:` overrides onto the sibling checkout have nothing left to serve. They were the reason `pnpm install --frozen-lockfile` could not run anywhere but one machine. - `@toolpath/tool-drawing` 0.3.2 → 1.0.0 in `apps/catalog`. This is what the branch was blocked on: `Zoom`, the `zoom` prop, `DrawingContext.extent` and `Frame.reserve` are all in 1.0.0, and against 0.3.2 the catalog failed `tsc` with five errors naming them. - `@toolpath/tool-scraper` 2.5.0 → 3.0.0 in `packages/catalog-data`. - The root `pnpm.overrides` block, and the lockfile `overrides:` entry it wrote. Both are major bumps and neither needed a source change here. `check-style`, `lint`, `build` and `check-types` all pass off the registry. **One test is knowingly red**: `catalog-drawing.test.tsx` § *draws the tool larger when the caller keeps less of the sheet for the material*. `materialRoom` no longer changes the framing — both viewBox widths come back 96.1094. It passes on 0.3.2 and fails on 1.0.0, so the change came in with that release rather than from this repo. Left failing rather than deleted: that test is why `materialRoom` exists, and whether 1.0.0 retired the caller's padding deliberately is a question for the drawing package. --- apps/catalog/package.json | 2 +- package.json | 5 -- packages/catalog-data/package.json | 2 +- pnpm-lock.yaml | 80 ++++++++++++++++++++++++++---- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 1ae16de..46494ce 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -23,7 +23,7 @@ "@toolpath/part-client": "workspace:*", "@toolpath/part-contracts": "workspace:*", "@toolpath/part-server": "workspace:*", - "@toolpath/tool-drawing": "0.3.2", + "@toolpath/tool-drawing": "1.0.0", "@toolpath/tool-support": "0.3.0", "@toolpath/ui": "1.0.0", "@toolpath/viewer": "1.1.1", diff --git a/package.json b/package.json index db73f84..3c41a68 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,5 @@ "prettier": "3.6.2", "turbo": "2.10.10", "typescript-eslint": "8.68.0" - }, - "pnpm": { - "overrides": { - "@toolpath/tool-scraper": "link:../toolpath-ui-packages/packages/tool-scraper" - } } } diff --git a/packages/catalog-data/package.json b/packages/catalog-data/package.json index b9c0202..6742be0 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": "2.5.0", + "@toolpath/tool-scraper": "3.0.0", "@toolpath/tool-support": "0.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 112d2c2..b6ac662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@toolpath/tool-scraper': link:../toolpath-ui-packages/packages/tool-scraper - importers: .: @@ -72,8 +69,8 @@ importers: specifier: workspace:* version: link:../../packages/part-server '@toolpath/tool-drawing': - specifier: 0.3.2 - version: 0.3.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 1.0.0 + version: 1.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@toolpath/tool-support': specifier: 0.3.0 version: 0.3.0 @@ -281,8 +278,8 @@ importers: specifier: workspace:* version: link:../part-contracts '@toolpath/tool-scraper': - specifier: link:../../../toolpath-ui-packages/packages/tool-scraper - version: link:../../../toolpath-ui-packages/packages/tool-scraper + specifier: 3.0.0 + version: 3.0.0 '@toolpath/tool-support': specifier: 0.3.0 version: 0.3.0 @@ -1311,17 +1308,26 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 - '@toolpath/tool-drawing@0.3.2': - resolution: {integrity: sha512-viH7w9xnkU/2lsENgLq2bBsr8BQr6lIrkzD1+V+aLRX9WWq2hIWG+LbxpFIq6BLbwC9SXCbFV9w1SHrLbYgGoA==} + '@toolpath/tool-drawing@1.0.0': + resolution: {integrity: sha512-ppLG270AACmRAT/hoUl8scHDtsgQ7RIjTROMc0+drLa5IADsAAZ/97CWXn/YEVoeIfrNAYudnwnH8RoR9ocPrA==} engines: {node: '>=20'} peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 + '@toolpath/tool-scraper@3.0.0': + resolution: {integrity: sha512-Uzoc3fdDpdQeOE0DUnKc+fnERbbGHyrjBWxIidkPTVlGgKBDg1qnmP9OmWuMgKUPJCgfJ/wFNFFbEtRiusZerA==} + engines: {node: '>=20'} + hasBin: true + '@toolpath/tool-support@0.3.0': resolution: {integrity: sha512-7sv9UMTgEyOIBKTUhlbgOtUJ5tOA10G61crfRZcO8BILooWzfw/V2cUTOt35M15WyLnxMu/uPX1pBY1eO8nziw==} engines: {node: '>=20'} + '@toolpath/tool-support@0.3.1': + resolution: {integrity: sha512-X2sTebw0liETcmbypaz2I/KYeO2N4oQCN/pHhgkMzNfbeSaCwMkJy5UiukbksAjWJfeH56AcfZrjLMX/aMSCig==} + engines: {node: '>=20'} + '@toolpath/ui@1.0.0': resolution: {integrity: sha512-5SM/z0uKaA2cfeQDOUK+gVmBbVKQY06VzKP335HAXi5So0tZ0BnGQoEO+oMfPgvAebs6fwsLmdK/4nENBaX6Zg==} engines: {node: '>=20'} @@ -1873,6 +1879,22 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} @@ -2137,6 +2159,10 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -3954,14 +3980,21 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@toolpath/tool-drawing@0.3.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@toolpath/tool-drawing@1.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@toolpath/tool-support': 0.3.0 + '@toolpath/tool-support': 0.3.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@toolpath/tool-scraper@3.0.0': + dependencies: + '@toolpath/tool-support': 0.3.1 + htmlparser2: 12.0.0 + '@toolpath/tool-support@0.3.0': {} + '@toolpath/tool-support@0.3.1': {} + '@toolpath/ui@1.0.0(@emotion/react@11.14.0(@types/react@19.2.18)(react@19.2.0))(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(tailwindcss@4.3.3)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -4492,6 +4525,24 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@3.0.0: {} + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + draco3d@1.5.7: {} electron-to-chromium@1.5.411: {} @@ -4782,6 +4833,13 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + husky@9.1.7: {} ieee754@1.2.1: {} From 08b539c1102b5b99e387614dcd8743fa8a348d5a Mon Sep 17 00:00:00 2001 From: Justin Gray Date: Fri, 11 Sep 2026 14:41:54 -0400 Subject: [PATCH 6/6] Take 1.0.0's reservation semantics, and retire materialRoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@toolpath/tool-drawing` 1.0.0 retired caller-supplied padding as a margin. `padding` is now a reservation, granted out of room the drawing itself cannot use: asking for more than the panel has costs the drawing nothing, and a narrow sheet grants what it has. The package's guidance is to ask for as much as the widest sheet could use. That is one number rather than a per-caller one, so the workaround this application carried for it comes off: - `materialRoom` off `CatalogDrawing`, which now asks for `MATERIAL_ROOM` always. - `PANEL_MATERIAL_ROOM` off `tool-details.tsx`. It existed only to dodge the 0.6 clamp that crushed the assembly into the top third of the panel on 2026-09-03, and there is no clamp to dodge now. The test inverted rather than being deleted. It pinned that asking for less left the tool more of the sheet — the workaround's own symptom, and the one assertion failing on 1.0.0, because there is nothing left to trade. What it pins now is the guarantee that made removing the prop safe: the only reservation the component still varies is whether there is an overlay to reserve for at all, and the tool has to be framed the same either way. Verified as a sensor rather than a restatement: run against 0.3.2 it fails, 97.39 reserved against 90.55 bare — the reservation coming out of the drawing is exactly the regression it is there to catch. `pnpm check` is green. --- .../app/components/catalog-drawing.test.tsx | 56 ++++++++++--------- .../app/components/catalog-drawing.tsx | 36 ++++++------ apps/catalog/app/components/tool-details.tsx | 14 ----- docs/TOOL-DRAWING-PLAN.md | 39 +++++++++---- 4 files changed, 74 insertions(+), 71 deletions(-) diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx index 8219d26..73564eb 100644 --- a/apps/catalog/app/components/catalog-drawing.test.tsx +++ b/apps/catalog/app/components/catalog-drawing.test.tsx @@ -303,39 +303,43 @@ describe('the overlay this application draws', () => { }) /** - * **The room for the material is the caller's**, because only the caller - * knows how much sheet there is. + * **Reserving room for the material costs the tool none of the sheet.** * - * `` is told its padding before it has measured its panel, so - * it cannot take a share of an axis it has not seen; it only clamps an - * over-large request to 0.6 of the axis and scales the dimension bands back - * with it. On the part page's tool panel — a column beside the part, not a - * full-width card — 240 px *was* that whole 0.6, and the assembly was - * crushed into the top third (2026-09-03). Asking for less has to leave the - * tool more of the sheet, or the prop is decoration. + * This replaces the inverse test, and the inversion is the point. + * `padding` used to be priced as a *margin*: the package clamped an + * over-large request to 0.6 of the axis and paid for it out of the scale, so + * `MATERIAL_ROOM` on the part page's ~400 px panel took the whole allowance + * and the assembly was crushed into the top third (2026-09-03). The old test + * pinned the workaround — that asking for less left the tool more sheet — and + * the application carried a `materialRoom` prop so a narrow panel could ask + * for less. + * + * `@toolpath/tool-drawing` 1.0.0 retired that: the reservation is granted out + * of room the drawing cannot use, so the guess no longer comes out of the + * scale and the caller is told to ask for as much as the widest sheet could + * use. The prop went with it, and this drawing now asks for one figure always. + * + * So what is pinned is the guarantee that made deleting the prop safe. The + * only reservation this component still varies is whether there is an overlay + * to reserve for at all — with a curve it asks for `MATERIAL_ROOM` on the + * `+r` flank, without one it asks for nothing — and the tool has to be framed + * the same either way. Re-pricing padding as a margin upstream would shrink + * the first against the second, which is the regression this drawing cannot + * see for itself. */ - it('draws the tool larger when the caller keeps less of the sheet for the material', () => { - const wide = drawn( + it('frames the tool the same whether or not it reserves room for the material', () => { + const reserved = drawn( , ) - const generous = wide.querySelector('svg')?.getAttribute('viewBox') StubResizeObserver.all = [] - const narrow = drawn( - , - ) - const tight = narrow.querySelector('svg')?.getAttribute('viewBox') + const bare = drawn() + + const across = (container: HTMLElement) => + Number((container.querySelector('svg')?.getAttribute('viewBox') ?? '0 0 0 0').split(' ')[2]) - const across = (viewBox: string | null | undefined) => - Number((viewBox ?? '0 0 0 0').split(' ')[2]) - expect(across(tight)).toBeGreaterThan(0) - expect(across(tight)).toBeLessThan(across(generous)) + expect(across(bare)).toBeGreaterThan(0) + expect(across(reserved)).toBe(across(bare)) }) /** diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx index 52ab076..c1f481b 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -57,16 +57,22 @@ import { useTheme } from 'shared/use-theme' * Room reserved on the `+r` flank for the material, in pixels. * * A number, because `` is told its padding before it has measured - * anything. The drawing this replaces gave the material whatever the panel had - * spare, which it could do only because it did its own framing; the package - * clamps an over-large request back to `MOST_OF_A_PANEL` — 0.6 of the axis — - * and scales the dimension bands back with it. + * anything — the caller has to guess, and the guess is the same on every sheet + * this application draws. * - * That clamp is a guard, not a layout: on the part page's tool panel, 240 px - * of a 400 px-tall sheet *is* the whole 0.6, so the assembly was crushed into - * the top third and the dimension bands with it (2026-09-03). So the room is a - * prop, and this is the wide card's default rather than everybody's — see - * {@link CatalogDrawingProps.materialRoom}. + * **One figure, asked generously** (2026-09-11). It used to be a prop, because + * `padding` was priced as a *margin*: the package clamped an over-large request + * to 0.6 of the axis and paid for it out of the scale, so 240 px on the part + * page's ~400 px panel was the whole allowance and the assembly came out + * crushed into the top third (2026-09-03). The wide card asked for 240 and the + * panel had to ask for 130 to dodge that. + * + * `@toolpath/tool-drawing` 1.0.0 retired those semantics: `padding` is now a + * *reservation*, granted out of room the drawing itself cannot use, so asking + * for more than the panel has costs the drawing nothing and a narrow sheet + * simply grants what it has. The package's own guidance is to ask for as much + * as the widest sheet could use — which is one number, not a per-caller one, so + * the prop and the panel's smaller figure both came off. */ export const MATERIAL_ROOM = 240 @@ -168,15 +174,6 @@ export interface CatalogDrawingProps { * beside a list has no button and takes the whole stack. */ readonly zoom?: Zoom - /** - * Room reserved on the `+r` flank for the material, in pixels. - * - * The caller's, because only the caller knows how much sheet there is: the - * package measures its panel *after* it has been told its padding, so it - * cannot ask for a share of an axis it has not seen yet. A narrow panel - * passes less; {@link MATERIAL_ROOM} is what a full-width card wants. - */ - readonly materialRoom?: number } /** @@ -305,7 +302,6 @@ export const CatalogDrawing = ({ onDimensionHover, measured = true, zoom = 'assembly', - materialRoom = MATERIAL_ROOM, }: CatalogDrawingProps) => { const [theme] = useTheme() const format = (millimetres: number) => formatLength(millimetres, unit) @@ -339,7 +335,7 @@ export const CatalogDrawing = ({ : null const overlaid = profile !== null && gaps !== null && outline !== null - const padding: Partial = overlaid ? { plus: materialRoom } : {} + const padding: Partial = overlaid ? { plus: MATERIAL_ROOM } : {} return ( = { */ const UNLETTERED: ReadonlySet = new Set(['LD', 'NOF']) -/** - * Room for the material on this panel's sheet, in pixels. - * - * The drawing card's own figure is `MATERIAL_ROOM`, 240, which is right on a - * full-width `h-96` card and wrong here: this panel is a column beside the - * part, `minSize={280}` wide and around 400 tall, and the package caps every - * flank at 0.6 of the axis — so 240 was the whole allowance, taken from the - * assembly and from the dimension bands that share it (2026-09-03). About a - * third of the short axis leaves the tool the sheet and the material a band - * wide enough to read. - */ -const PANEL_MATERIAL_ROOM = 130 - export interface ToolDetailsProps { readonly tool: CatalogTool readonly unit: UnitSystem @@ -441,7 +428,6 @@ export const ToolDetails = ({ unit={unit} curve={curve} margins={margins} - materialRoom={PANEL_MATERIAL_ROOM} dimensions dimensionSides="both" highlight={pointed} diff --git a/docs/TOOL-DRAWING-PLAN.md b/docs/TOOL-DRAWING-PLAN.md index fcbcbcc..f0cce48 100644 --- a/docs/TOOL-DRAWING-PLAN.md +++ b/docs/TOOL-DRAWING-PLAN.md @@ -1011,17 +1011,34 @@ the whole allowance: the clamp scaled the dimension bands back proportionally with it and the assembly was crushed into the top third of the sheet. The clamp is a guard against nonsense, not a layout. -The fix stayed on this side: `MATERIAL_ROOM` is now the default of a -`materialRoom` prop on `CatalogDrawing`, the drawing card keeps 240, and -`tool-details.tsx` passes `PANEL_MATERIAL_ROOM = 130`. -`catalog-drawing.test.tsx` pins that asking for less leaves the tool more of -the sheet, so the prop cannot quietly become decoration. - -**The package-side fix is still the better one and is still not taken**: teach -`Padding` to accept a fraction of the panel as well as pixels, since the caller -cannot know the panel width and the package measures it. That is a -`@toolpath/tool-drawing` release and a version bump across two repositories -(§ 12), which is why a prop was enough for now. +The fix stayed on this side at the time: `MATERIAL_ROOM` became the default of a +`materialRoom` prop on `CatalogDrawing`, the drawing card kept 240, and +`tool-details.tsx` passed `PANEL_MATERIAL_ROOM = 130`. + +**The package-side fix was taken in 1.0.0 — 2026-09-11.** Not as the fraction +this section proposed, but by changing what `padding` _means_: it is a +**reservation** rather than a margin, granted out of room the drawing itself +cannot use. Asking for more than the panel has now costs the drawing nothing, +and a narrow sheet grants what it has. The package's guidance is to ask for as +much as the widest sheet could use — which is one number rather than a +per-caller one, so the workaround came off with it: + +- `materialRoom` is gone from `CatalogDrawing`, which asks for `MATERIAL_ROOM` + always. +- `PANEL_MATERIAL_ROOM` is gone from `tool-details.tsx`. It only ever existed to + dodge the clamp. +- The test inverted. It pinned that asking for _less_ left the tool _more_ sheet + — the workaround's own symptom, and it fails on 1.0.0 because there is no + longer anything to trade. `catalog-drawing.test.tsx` § _frames the tool the + same whether or not it reserves room for the material_ now pins the guarantee + that made deleting the prop safe: the only reservation the component still + varies is whether there is an overlay to reserve for, and the tool must be + framed identically either way. Checked against 0.3.2, where it fails + (97.39 reserved against 90.55 bare), so it is a sensor rather than a + restatement. + +The upgrade is the reason this repository no longer carries a `link:` override +onto the sibling checkout — see § 12. ### Phase 7 — Publish