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
20 changes: 16 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,14 @@ application unless that application says otherwise.
chosen first and narrows the rest. The table is whichever list the open
slot asks for — three buttons in its chrome switch it, each counting its own
list, and with no feature they read the three catalogs — and picking a row
there fills that slot without walking on to the next one. **Each assembly carries one context-aware button** — _Add to order list_, _Change holder from
A to B_, _Remove from order list_ — covering every stack of it, so a threaded
hole's tap and the drill under it are one press — and that press is the only way a component
there fills that slot without walking on to the next one. **One context-aware
button sits under the whole box** (Paul, 2026-09-11) — _Add to order list_,
_Change holder from A to B_, _Remove from order list_ — covering every stack
in it, so a threaded hole's tap and the drill under it, and a pocket's rougher
and finisher, are one press — and that press is the only way a component
reaches the bill: picking a row in a table selects it into the stack and
nothing more. `docs/TOOL-ASSEMBLY-TREE.md` is the spec, including
nothing more. _Add assembly_ is a row of the tree above that press, on the
last card only. `docs/TOOL-ASSEMBLY-TREE.md` is the spec, including
_Where the rules live_ and _Not built_, and § 6 for what the flag's removal
took with it. `openCube` in the Playwright fixture takes no flags — there is
one shape, so a spec says nothing and gets it.
Expand Down Expand Up @@ -265,25 +268,34 @@ application unless that application says otherwise.
| ---------------------------------------------------- | ---------------------------------------------------- |
| what the list holds, its names, ids, storage | `app/shared/feature-list.ts` |
| which key a row's lines reach the bill under | `sheetKeysOf`, same file |
| which of a row's lines one stack of it wrote | `lineId`, `app/shared/setup-sheet.ts` |
| 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 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` |
| the heading face and the small-capitals label | `app/shared/type.ts` |
| how tall the tool list opens | `TABLE_OPENS_AT`, `components/part-tool-table.tsx` |
| the columns a list opens with, and their order | `TOOL_COLUMNS`, `components/part-tool-table.tsx` |
| the two columns the list turns on for itself | `app/shared/auto-columns.ts` |
| a row's answer, and what it opens to | `app/shared/recommendations.ts` |
| what a press on a row of the order list opens | `pressRow`, `app/routes/part.tsx` |
| whose stacks the tree beside an open box shows | `editedItem` / `treeKey`, same file |
| what the panel offers for the tool it shows | `app/shared/tool-actions.ts` |
| what fills the tool table, and the cache | `app/shared/catalog-matcher.ts` |
| a stored guid turned back into a record | `getTool`/`getHolder`/`getCollet`, `catalog.ts` |
| what overruling the rules offers, per column | `overridableTools`, `app/shared/tool-fit.ts` |
| the note and press a changed filter raises | `OverrideNotice`, `app/components/column-filter.tsx` |
| whether a value is inside a filter's bound | `withinRange`, `app/shared/filter.ts` |
| what a number box takes besides a number | `app/shared/range-entry.ts` |
| which bounds are somebody's own, not the geometry's | `ownBounds`, `app/shared/filter.ts` |
| which columns' rules an emptied box releases | `releasedBounds`, `app/shared/filter.ts` |
| what a narrowed axis says an unticked value brings | `facetCounts`, `app/shared/catalog-matcher.ts` |
| the same work, off the UI thread | `app/client/catalog-matcher.worker.ts` |
| what a click on the part means | `app/shared/part-interaction.ts` |
| which layer one press of Escape or Enter reaches | `app/shared/use-escape.ts` |
| which list the arrows move through, and the focus | `app/shared/arrow-target.ts` |
| how tall a menu or the column picker is, which way | `menuRoom`, `app/components/column-filter.tsx` |
| whether an open filter survives the list under it | `FilterMenu`, `app/components/column-filter.tsx` |
| a feature's assemblies, its slots, its storage | `app/shared/assembly-tree.ts` |
Expand Down
1 change: 1 addition & 0 deletions apps/catalog/app/client/catalog-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const context: MatchContext = {
margins: { radial: 0, axial: 0 },
thresholds: thresholdsFrom(),
overrides: [],
ownRanges: {},
}

afterEach(() => {
Expand Down
146 changes: 136 additions & 10 deletions apps/catalog/app/client/catalog-matcher.worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { CatalogTool } from '@toolpath/catalog-data'
import { collets, holders, allTools } from '../shared/catalog'
import {
detailedMatch,
facetCountsFor,
facetPool,
matchKey,
prepareMatch,
recommendationMatch,
Expand All @@ -10,6 +13,7 @@ import {
type MatchResponse,
type RecommendationResult,
} from '../shared/catalog-matcher'
import { facetsNarrowing, withoutFacets } from '../shared/filter'

const catalog = { tools: allTools, holders, collets }

Expand Down Expand Up @@ -44,6 +48,18 @@ class Lru<Value> {
// and recur for every feature and one-each draft, so they get the larger cache.
const tables = new Lru<ReadonlyArray<DetailedResult>>(4)
const recommendations = new Lru<RecommendationResult>(256)
/**
* The widened pools the facet counts are measured over, by the question with
* every facet cleared.
*
* **A tick must not cost a judging pass.** The counts beside the Vendor
* checkboxes are measured over the question *without* the vendor, so that pool
* is identical for every vendor, every family and every type ticked on the same
* feature — which is exactly the sequence somebody makes while narrowing. Keyed
* on the facet-free question so the cache hits across all of them, and never
* sent anywhere: only the counts cross the boundary.
*/
const pools = new Lru<ReadonlyArray<CatalogTool>>(4)

/**
* The part this worker is answering about, kept between requests.
Expand All @@ -56,6 +72,93 @@ const recommendations = new Lru<RecommendationResult>(256)
*/
let held: { readonly key: string; readonly features: MatchContext['features'] } | null = null

/** The widened pool for one demand, judged once and kept for the next tick. */
const poolFor = (
context: MatchContext,
demand: MatchRequest['demands'][number],
): ReadonlyArray<CatalogTool> => {
const widened = { ...context, query: withoutFacets(context.query) }
/*
**The stack is left out of the key, deliberately.** What it narrows is the
counts taken *over* this pool, never the pool itself — `facetCountsFor` in
`shared/catalog-matcher.ts` is where it is applied — so a holder or a collet
picked in the tree must not evict a judging pass that would come back
identical. `stable` drops an undefined value, so this is the key a demand
with no stack writes.

The `form` axis is out of it too, because `withoutFacets` clears it: one
pool now serves every type ticked, the predrill button and a thread being
chosen, where each of those used to rebuild it.
*/
const key = matchKey('table', widened, [{ ...demand, stack: undefined }])
const cached = pools.get(key)
if (cached !== undefined) {
return cached
}
const built = facetPool(context, demand, catalog)
pools.set(key, built)
return built
}

/**
* The table request whose counts have still to be worked out.
*
* **The counts are not the answer, and must not be paid for like one** (Paul,
* 2026-09-10). Widening the pool past the `form` axis is what makes a threaded
* hole's Type column say how many end mills work — and it is a judging pass
* over the whole catalog rather than the sixteen thousand drills and taps the
* filter admits: measured at 502 ms an answer against 170 ms without it. So the
* rows are posted first and this is what comes after them, a task later so a
* newer question can overtake it.
*
* A newer table request clears it, because counts for the question before last
* are numbers beside somebody else's checkboxes.
*/
let counting: number | null = null

const countLater = (request: MatchRequest, answered: ReadonlyArray<DetailedResult>): void => {
counting = request.requestId
setTimeout(() => {
if (counting !== request.requestId) {
return
}
counting = null
try {
// Aligned by construction: `answered` is `request.demands` mapped.
const results = answered.map((already, at) => {
const demand = request.demands[at]
return demand === undefined
? already
: {
...already,
facetCounts: facetCountsFor(
request.context,
demand,
catalog,
poolFor(request.context, demand),
),
}
})
tables.set(request.key, results)
const response: MatchResponse = {
requestId: request.requestId,
kind: 'table',
key: request.key,
results,
}
self.postMessage(response)
} catch {
/*
**A count that fails is not an answer that failed.** The rows are on
screen already and this task cannot take them away — reporting an error
here would replace a good table with a message about the numbers beside
its checkboxes. The page falls back to counting its own rows, which is
what it does whenever the worker has nothing to say.
*/
}
}, 0)
}

self.onmessage = (event: MessageEvent<MatchRequest>) => {
const incoming = event.data
if (incoming.context.features.length > 0) {
Expand All @@ -81,24 +184,47 @@ self.onmessage = (event: MessageEvent<MatchRequest>) => {
}
try {
if (request.kind === 'table') {
// A question already answered has its counts with it, so nothing is left
// to work out and nothing is scheduled.
const cached = tables.get(request.key)
const results =
cached ??
(() => {
const prepared = prepareMatch(request.context, catalog)
const matched = request.demands.map((demand) =>
detailedMatch(request.context, demand, catalog, prepared),
)
tables.set(request.key, matched)
return matched
})()
if (cached !== undefined) {
counting = null
const response: MatchResponse = {
requestId: request.requestId,
kind: 'table',
key: request.key,
results: cached,
}
self.postMessage(response)
return
}
const prepared = prepareMatch(request.context, catalog)
// `null` is the pool this pass does not build — `detailedMatch` says why.
const results = request.demands.map((demand) =>
detailedMatch(request.context, demand, catalog, prepared, null),
)
const response: MatchResponse = {
requestId: request.requestId,
kind: 'table',
key: request.key,
results,
}
self.postMessage(response)
/*
**Only a finished answer is cached.** An answer still waiting for its
counts would be handed back whole on the next ask of the same question,
and the counts would never be worked out at all — the cache hit returns
before anything is scheduled. So the entry is written by `countLater`,
and the one case with nothing to wait for is written here. The cost of
that is re-answering a question asked twice inside one task, which the
client's own key dedupe already makes hard to do.
*/
if (facetsNarrowing(request.context.query)) {
countLater(request, results)
} else {
counting = null
tables.set(request.key, results)
}
return
}

Expand Down
5 changes: 4 additions & 1 deletion apps/catalog/app/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { NavLink, useNavigate, useParams, useSearchParams } from 'react-router'
import { Badge, IconButton, cn } from '@toolpath/ui'
import { Chip, ChipGroup } from 'components/chip'
import { ToolpathLogo } from 'components/toolpath-logo'
import { UNIT_ABBREVIATION, UNIT_SYSTEMS, type UnitSystem } from '@toolpath/tool-support'
import { MoonIcon, SunIcon, UploadSimpleIcon } from '@phosphor-icons/react'
import { forgetPart, openPart, orderListHref, partHref, usePartSession } from 'shared/part-session'
import { useTheme } from 'shared/use-theme'
import { HEADING } from 'shared/type'

const tabClass = ({ isActive }: { isActive: boolean }) =>
cn(
Expand Down Expand Up @@ -37,7 +39,8 @@ export const AppHeader = ({ unit, onUnit, toolCount, onUploadPart }: AppHeaderPr
return (
<header className="border-b border-zinc-800 bg-zinc-950">
<div className="flex items-center gap-3 px-6 pt-4 pb-2">
<h1 className="font-heading text-lg font-bold text-zinc-100">Tool catalog</h1>
<ToolpathLogo className="size-6 shrink-0" />
<h1 className={HEADING}>Toolpath Tool Catalog</h1>
<Badge variant="secondary">{toolCount} tools</Badge>
{/* A new part is always one press away. When another part is already
loaded, return to its viewer and open the uploader there rather than
Expand Down
70 changes: 69 additions & 1 deletion apps/catalog/app/components/assembly-tree-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const panel = (
actionsFor={() => []}
onAdd={() => undefined}
onRemove={() => undefined}
title="Cuts the pocket"
{...over}
/>
)
Expand Down Expand Up @@ -366,3 +365,72 @@ describe('a component standing in two stacks of one tree', () => {
expect(screen.queryByText(/used in Assembly/)).not.toBeInTheDocument()
})
})

/**
* **One press for everything in the box** (Paul, 2026-09-11: "the button should
* be below BOTH of them, and add both assemblies to the order list — we only
* need one button and it just adds everything on the list").
*
* It used to be drawn inside every card, so a rougher and a finisher were two
* presses and ordering the pair meant pressing both — while Enter on the same
* box had written all of them since 2026-09-10. The two disagreed about what
* finishing the box means, and the button was the one that was wrong.
*/
describe('the press under the whole box', () => {
const two = addAssembly(defaultAssemblies(false))

it('is asked once, about every stack in the tree', () => {
const asked: Array<number> = []
draw(two, {
actionsFor: (stacks) => {
asked.push(stacks.length)
return []
},
})

// Once, over both stacks — not once per card.
expect(asked).toEqual([2])
})

it('draws one button for two assemblies, not one each', () => {
draw(two, {
actionsFor: () => [
{ key: 'add', kind: 'add' as const, label: 'Add to order list', onClick: () => undefined },
],
})

expect(screen.getAllByRole('button', { name: 'Add to order list' })).toHaveLength(1)
})

/**
* **And the press for another assembly is a row above it** (Paul, 2026-09-11:
* "add assembly should be above the add feature to list or add to order list
* buttons — like another row with a plus button in the list of components").
* It is drawn on the last card only: it makes a card rather than a row in
* this one, so one per card would be the same press drawn as many times as
* there are stacks.
*/
it('offers another assembly once, under the last stack and over the press', () => {
draw(two, {
actionsFor: () => [
{ key: 'add', kind: 'add' as const, label: 'Add to order list', onClick: () => undefined },
],
})

const add = screen.getAllByRole('button', { name: 'Add assembly' })
expect(add).toHaveLength(1)

/*
Asserted as document order rather than as a parent: what the rule is about
is where somebody's eye lands — after the last stack's rows, before the
press that finishes the box — and the boxes around it are layout.
*/
const order = (element: Element): number =>
Array.from(document.querySelectorAll('button')).indexOf(element as HTMLButtonElement)
const tools = screen.getAllByRole('button', { name: /^TOOL for/ })
const press = screen.getByRole('button', { name: 'Add to order list' })

expect(order(add[0] as Element)).toBeGreaterThan(order(tools[1] as Element))
expect(order(add[0] as Element)).toBeLessThan(order(press))
})
})
Loading