diff --git a/AGENTS.md b/AGENTS.md index 7912a49..77ada26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,6 +304,7 @@ application unless that application says otherwise. | whether the rack shows them at all, and how many | `holdersToOffer`, same file | | the press that shows them, hidden to begin with | `app/components/no-collet-toggle.tsx` | | a group's worst case, and whose it is | `app/shared/group-geometry.ts` | +| the material a whole question has to clear | `askedCurve`, same file | | how far below the holder a stack has to stand | `belowHolder`, `app/shared/drawn-assembly.ts` | | which slots were filled against the rules | `overrides`, `app/shared/assembly-tree.ts` | | what a stack offers, and its button's words | `app/shared/assembly-actions.ts` | diff --git a/apps/catalog/app/components/catalog-drawing.test.tsx b/apps/catalog/app/components/catalog-drawing.test.tsx index 73564eb..e594d5f 100644 --- a/apps/catalog/app/components/catalog-drawing.test.tsx +++ b/apps/catalog/app/components/catalog-drawing.test.tsx @@ -406,3 +406,68 @@ describe('the overlay this application draws', () => { ) }) }) + +/** + * **The wall stands beside the cut, never on the centreline.** + * + * A reach curve is measured out from the cut, so every radius the overlay draws + * is `cuttingRadius + offset` and the wall's inner face *is* the cutting + * radius. `(DC ?? 0) / 2` behind a `DC !== undefined` guard put that face at + * `r = 0` for any tool stating no cutting diameter — `null` and `0` are not + * `undefined` — so the hatch was drawn from the centreline outward, straight + * through the tool it was supposed to stand clear of (Paul, 2026-09-11). + * + * The two halves are the rule: a tool that states a diameter gets a wall at its + * flank, and one that does not gets no wall at all rather than one drawn from a + * radius nobody stated. + */ +describe('where the material is drawn', () => { + /** Every coordinate the material path visits, in the frame's own units. */ + const points = (container: Element): Array<{ readonly along: number; readonly across: number }> => + [ + ...(container.querySelector('[data-part="material"]')?.getAttribute('d') ?? '').matchAll( + /(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g, + ), + ].map((found) => ({ along: Number(found[1]), across: Number(found[2]) })) + + it('stands the wall off the cut by the tool’s own radius', () => { + const container = drawn() + const across = points(container).map((point) => point.across) + + expect(across.length).toBeGreaterThan(0) + // The inner face is the cutting radius — DC 3 mm — and not the centreline. + expect(Math.min(...across)).toBeCloseTo(tool.geometry.DC / 2, 6) + expect(Math.min(...across)).toBeGreaterThan(0) + }) + + it('draws no wall for a tool that states no cutting diameter', () => { + const { DC: _dropped, ...rest } = tool.geometry + const undiametered: CatalogTool = { ...tool, geometry: rest } + expect( + drawn().querySelector( + '[data-part="material"]', + ), + ).toBeNull() + }) + + /** + * The case the old guard was written for and missed. The type says + * `Record`, but a catalog built from a vendor that published + * no cutting diameter carries `null` through the JSON, and `null !== + * undefined` — so this is the tool that drew its wall from the centreline. + */ + it('draws no wall for a tool whose diameter is null or zero', () => { + for (const stated of [null, 0]) { + StubResizeObserver.all = [] + const broken = { + ...tool, + geometry: { ...tool.geometry, DC: stated as unknown as number }, + } satisfies CatalogTool + expect( + drawn().querySelector( + '[data-part="material"]', + ), + ).toBeNull() + } + }) +}) diff --git a/apps/catalog/app/components/catalog-drawing.tsx b/apps/catalog/app/components/catalog-drawing.tsx index c1f481b..1ac09df 100644 --- a/apps/catalog/app/components/catalog-drawing.tsx +++ b/apps/catalog/app/components/catalog-drawing.tsx @@ -326,11 +326,35 @@ export const CatalogDrawing = ({ */ const outline = curve === null ? null : assemblyOutline(viewer) const verdict = curve !== null && assembly !== null ? clearance(assembly, curve, margins) : null - const cuttingRadius = (tool.geometry.DC ?? 0) / 2 + /** + * The flank the material stands beside, and `null` where the tool has none. + * + * **A reach curve is measured out from the cut**, so every r the overlay + * draws is `cuttingRadius + offset` and the wall's inner face *is* the + * cutting radius. `(DC ?? 0) / 2` put that face on the centreline for any + * tool stating no cutting diameter, and the guard beside it — + * `DC !== undefined` — let one through: a `DC` of `null` or `0` is not + * `undefined`, so the wall was drawn, from `r = 0`, straight through the + * tool it was supposed to stand clear of (Paul, 2026-09-11). The hatch + * covered the tool's whole `+r` flank and the drawing said the cutter was + * buried in the part. + * + * `typeof` rather than `!== undefined`, because the type says + * `Record` and a catalog built from a vendor that published + * no cutting diameter carries `null` at runtime — which is the case the old + * guard was written for and the one it missed. + * + * A tool with no cutting diameter has no flank, so there is nothing to + * measure a gap from either: `gaps` goes with it, `overlaid` turns off, and + * the sheet is the tool on its own. That is the honest picture — the + * alternative is a wall drawn from a radius nobody stated. + */ + const stated = tool.geometry.DC + const cuttingRadius = typeof stated === 'number' && stated > 0 ? stated / 2 : null const profile = - curve !== null && tool.geometry.DC !== undefined ? materialProfile(curve, cuttingRadius) : null + curve !== null && cuttingRadius !== null ? materialProfile(curve, cuttingRadius) : null const gaps = - curve !== null && outline !== null + curve !== null && outline !== null && cuttingRadius !== null ? tightestGaps(outline.segments, curve, cuttingRadius, margins) : null @@ -359,7 +383,11 @@ export const CatalogDrawing = ({ } className="size-full" > - {overlaid && profile !== null && gaps !== null && outline !== null ? ( + {overlaid && + profile !== null && + gaps !== null && + outline !== null && + cuttingRadius !== null ? ( <> (reading ? (sectionOf(reading, report.features)?.curve ?? null) : null), - [reading, report.features], + () => askedCurve(askedNow.results, selectedFeatures, reading, report.features), + [askedNow.results, selectedFeatures, reading, report.features], ) const thresholds = useMemo(() => thresholdsFrom(), []) diff --git a/apps/catalog/app/shared/group-geometry.test.ts b/apps/catalog/app/shared/group-geometry.test.ts index 0774055..a2fd936 100644 --- a/apps/catalog/app/shared/group-geometry.test.ts +++ b/apps/catalog/app/shared/group-geometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { PartFeature } from '@toolpath/part-contracts' -import { groupReadings, sharedHoleDiameter } from './group-geometry' +import type { PartFeature, ReachCurve } from '@toolpath/part-contracts' +import { heightAt } from '@toolpath/catalog-data' +import { askedCurve, groupCurve, groupReadings, sharedHoleDiameter } from './group-geometry' /** * A feature as the kernel reports one, cut straight down. @@ -167,3 +168,106 @@ describe('the bore a group shares', () => { expect(sharedHoleDiameter([])).toBeNull() }) }) + +/** + * **One tool goes into all of them**, so the material it has to get past is + * every feature's at once. The curve was read off the face clicked last, which + * made the drawing and the verdict under it depend on the order a group was + * picked in (Paul, 2026-09-11). + */ +describe('the material a group has to clear', () => { + const walled = (tag: string, curve: ReachCurve): PartFeature => + feature(tag, 'Pocket', { + zMin: -10, + facts: { kind: 'Pocket', cd: { ignore: { min: 6 } } }, + reachCurve: curve, + }) + + /** What every reader of a curve agrees it means, asked across a span of offsets. */ + const sampled = (curve: ReachCurve | null): Array | null => + curve === null + ? null + : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 20].map((offset) => heightAt(curve, offset)) + + it('takes the taller of the two at every offset, not the one clicked last', () => { + const shallow: ReachCurve = { horizontalOffset: [2, 8], verticalOffset: [4, 6] } + const deep: ReachCurve = { horizontalOffset: [5, 10], verticalOffset: [12, 30] } + const union = groupCurve([walled('shallow', shallow), walled('deep', deep)]) + + const tallest = sampled(shallow)?.map((height, index) => + Math.max(height, sampled(deep)?.[index] ?? 0), + ) + expect(sampled(union)).toEqual(tallest) + // Every input knot is considered, and the two the run after them repeats + // come back out: the deep wall is over the shallow one's head throughout, + // so the answer is a staircase of two rather than of four. + expect(union).toEqual({ horizontalOffset: [5, 10], verticalOffset: [12, 30] }) + }) + + it('answers the same whichever order the group was picked in', () => { + const a = walled('a', { horizontalOffset: [3, 9], verticalOffset: [7, 11] }) + const b = walled('b', { horizontalOffset: [4], verticalOffset: [25] }) + expect(groupCurve([a, b])).toEqual(groupCurve([b, a])) + }) + + it('drops a knot whose height the run after it repeats', () => { + const low = walled('low', { horizontalOffset: [1, 2, 3], verticalOffset: [5, 5, 5] }) + const high = walled('high', { horizontalOffset: [3], verticalOffset: [9] }) + + expect(groupCurve([low, high])).toEqual({ horizontalOffset: [3], verticalOffset: [9] }) + }) + + it("is one feature's own curve, unchanged, when it is the only one", () => { + const curve: ReachCurve = { horizontalOffset: [2, 8], verticalOffset: [4, 6] } + expect(groupCurve([walled('one', curve)])).toEqual(curve) + }) + + it('ignores a feature that states no curve rather than reading it as flat', () => { + const curve: ReachCurve = { horizontalOffset: [2, 8], verticalOffset: [4, 6] } + expect(groupCurve([walled('one', curve), hole('bare', 20, 8)])).toEqual(curve) + }) + + it('answers nothing where no feature states one', () => { + expect(groupCurve([hole('a', 20, 8), hole('b', 20, 8)])).toBeNull() + expect(groupCurve([])).toBeNull() + }) +}) + +/** + * Which features the curve is folded over: what the page is being asked, and + * not one feature of it. The drawing was reading the face clicked last while + * the tool list beside it was judged against the whole question. + */ +describe('the scope the material is folded over', () => { + const walled = (tag: string, height: number): PartFeature => + feature(tag, 'Pocket', { + zMin: -10, + facts: { kind: 'Pocket', cd: { ignore: { min: 6 } } }, + reachCurve: { horizontalOffset: [4], verticalOffset: [height] }, + }) + + const shallow = walled('shallow', 4) + const deep = walled('deep', 40) + + it('folds every feature asked about, whichever of them is being read', () => { + expect(askedCurve('all', [shallow, deep], shallow, [shallow, deep])).toEqual({ + horizontalOffset: [4], + verticalOffset: [40], + }) + }) + + it('reads the feature in front of it for a group answered one tool each', () => { + expect(askedCurve('each', [shallow, deep], shallow, [shallow, deep])).toEqual({ + horizontalOffset: [4], + verticalOffset: [4], + }) + }) + + it('reads the feature in front of it when nothing is being asked', () => { + expect(askedCurve('all', [], deep, [shallow, deep])).toEqual({ + horizontalOffset: [4], + verticalOffset: [40], + }) + expect(askedCurve('all', [], null, [shallow, deep])).toBeNull() + }) +}) diff --git a/apps/catalog/app/shared/group-geometry.ts b/apps/catalog/app/shared/group-geometry.ts index b07254d..9bdf007 100644 --- a/apps/catalog/app/shared/group-geometry.ts +++ b/apps/catalog/app/shared/group-geometry.ts @@ -1,6 +1,9 @@ -import type { PartFeature } from '@toolpath/part-contracts' +import type { PartFeature, ReachCurve } from '@toolpath/part-contracts' +import { heightAt } from '@toolpath/catalog-data' import { FIELDS, defaultsFor, readingsFor, type GroupBound, type Unit } from './feature-defaults' +import type { Results } from './feature-list' import { boreOf } from './hole-mode' +import { sectionOf } from './section-of' /** * What a group measures, when the group is asked as one question. @@ -168,3 +171,80 @@ export const sharedHoleDiameter = (features: ReadonlyArray): number } return held } + +/** + * The material a group's tool has to get past: **every feature's, at once**. + * + * A reach curve is a staircase of material heights by offset out from the cut, + * read from the bottom of the feature — which is where the tool's tip is when + * it is in that feature. So a group's curve is the pointwise maximum of its + * features': one tool goes into all of them, and a stack that clears the + * shallowest pocket and fouls the deepest wall does not clear the group. + * + * It was the *focused* feature's curve alone, which is the last face clicked. + * A group of a shallow open pocket and a deep slot therefore drew, swept and + * judged against whichever of the two happened to be clicked last, and the + * verdict under the drawing changed when nothing about the question had + * (Paul, 2026-09-11). + * + * **The rise comes at the start of each run**, which is the one thing every + * reader of a curve has to agree about — `heightAt` in `@toolpath/tool-support` + * is the reader, so the fold asks it rather than re-deriving the staircase. + * Every input knot is a knot of the answer: between two of them no input + * changes, so the maximum does not either. A knot whose height the next knot + * repeats is dropped, since it says nothing the run after it does not. + * + * `null` where no feature in the scope states one — the honest answer, and the + * one that draws a tool with no material beside it rather than a wall of zero + * height. + */ +export const groupCurve = ( + features: ReadonlyArray, + all: ReadonlyArray = features, +): ReachCurve | null => { + const curves = features.flatMap((feature) => { + const curve = sectionOf(feature, all)?.curve + return curve ? [curve] : [] + }) + const first = curves[0] + if (first === undefined) { + return null + } + if (curves.length === 1) { + return first + } + + const knots = [...new Set(curves.flatMap((curve) => curve.horizontalOffset))].sort( + (a, b) => a - b, + ) + const heights = knots.map((offset) => + curves.reduce((tallest, curve) => Math.max(tallest, heightAt(curve, offset)), 0), + ) + const kept = knots.flatMap((offset, index) => + index === knots.length - 1 || heights[index] !== heights[index + 1] ? [index] : [], + ) + return { + horizontalOffset: kept.map((index) => knots[index] as number), + verticalOffset: kept.map((index) => heights[index] as number), + } +} + +/** + * {@link groupCurve} over whatever the page is being asked. + * + * The scope is `asked()`'s answer and not one feature of it, so the wall under + * the drawing and the sweep that judged the list beside it are about the same + * thing. Two cases read the feature in front of them instead: + * + * - a group asked for one tool **each** is not one question — every feature + * gets its own tool, so there is nothing to fold; + * - nothing asked at all, where a reading hovered over the part is the whole + * question and the list below speaks for itself. + */ +export const askedCurve = ( + results: Results, + asked: ReadonlyArray, + reading: PartFeature | null, + all: ReadonlyArray, +): ReachCurve | null => + groupCurve(results === 'all' && asked.length > 0 ? asked : reading === null ? [] : [reading], all) diff --git a/docs/FEATURE-LIST.md b/docs/FEATURE-LIST.md index f97ac3a..2ea11f3 100644 --- a/docs/FEATURE-LIST.md +++ b/docs/FEATURE-LIST.md @@ -449,6 +449,21 @@ Opens the group editor, seeded with whatever is already clicked. was folded from, deduplicated, so a group of thirty-nine identical holes says its line once — and `featureTag` on the fold's answer still says it for anything that wants to. + - **The material around it folds the same way**, and is the one fold that is + not a number on the strip: a reach curve is a staircase of material heights + by offset out from the cut, read from the bottom of the feature — which is + where the tool's tip is when it is in that feature — so a group's curve is + the pointwise maximum of its features'. `groupCurve` in + `shared/group-geometry.ts`, over whatever `asked()` says is being asked — + `askedCurve` beside it is that scope — and `heightAt` in + `@toolpath/tool-support` is the reader it folds + through so the drawing and the sweep cannot disagree about where a rise + comes. It was the _focused_ feature's alone — the face clicked last — so a + group of a shallow pocket and a deep slot was drawn, swept and judged + against whichever of the two that happened to be, and the verdict under the + drawing changed when nothing about the question had (Paul, 2026-09-11). A + group asked for one tool **each** is exempt: every feature gets its own + tool, so there is nothing to fold. - **There is no confirm on the box** (Paul, 2026-09-09). _Create group and add tool_ and the **Cancel** beside it are gone: the group is created and put on the order list by the press under the stack that answers it, in one press. @@ -1009,6 +1024,7 @@ true of the work the worker does: | the list on screen | `app/components/feature-list-panel.tsx` | | building a group | `app/components/group-editor.tsx` | | a group's worst case, and whose it is | `app/shared/group-geometry.ts` | +| the material a whole question has to clear | `askedCurve`, same file | | the one bore a group shares | `sharedHoleDiameter`, same file | | what a click on the part means, group or not | `Interaction.collecting`, `part-interaction.ts` | | where identical holes are grouped, once | `{ type: 'group' }`, `part-interaction.ts` |