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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
65 changes: 65 additions & 0 deletions apps/catalog/app/components/catalog-drawing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CatalogDrawing tool={tool} unit="millimeters" curve={curve} />)
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(<CatalogDrawing tool={undiametered} unit="millimeters" curve={curve} />).querySelector(
'[data-part="material"]',
),
).toBeNull()
})

/**
* The case the old guard was written for and missed. The type says
* `Record<string, number>`, 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(<CatalogDrawing tool={broken} unit="millimeters" curve={curve} />).querySelector(
'[data-part="material"]',
),
).toBeNull()
}
})
})
36 changes: 32 additions & 4 deletions apps/catalog/app/components/catalog-drawing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>` 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

Expand Down Expand Up @@ -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 ? (
<>
<ClearanceOverlay
profile={profile}
Expand Down
25 changes: 20 additions & 5 deletions apps/catalog/app/routes/part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import {
orderAssemblies,
type ComponentSort,
} from 'shared/order-list'
import { groupReadings, sharedHoleDiameter } from 'shared/group-geometry'
import { askedCurve, groupReadings, sharedHoleDiameter } from 'shared/group-geometry'
import { groupOffer } from 'shared/group-offer'
import { usedElsewhere, usesByGuid } from 'shared/component-usage'
import { toolActionLabel, toolActions, type ToolAction } from 'shared/tool-actions'
Expand Down Expand Up @@ -210,7 +210,6 @@ import {
splitHolding,
tapers,
} from 'shared/holding'
import { sectionOf } from 'shared/section-of'
import { belowHolder, type BelowHolder } from 'shared/drawn-assembly'
import { drawable, holdable, holderOptions, policyOf, thresholdsFrom } from 'shared/holder-choice'
import { closestMisses, closestPerForm, type Format } from 'shared/judge'
Expand Down Expand Up @@ -1515,10 +1514,26 @@ const Inspecting = ({ report, jobId }: { report: PublicInspectionReport; jobId:
return bore ?? holeDiameter
}, [threadSpec, holeChoice.mode, holeDiameter])

/** The reach curve the holders are swept over, read off the feature. */
/**
* The reach curve the holders are swept over: **the whole question's**.
*
* It was read off `reading`, the face clicked last, so a group of six was
* drawn, swept and judged against whichever of the six that happened to be
* (Paul, 2026-09-11). One tool for all of them has to get past all of their
* material, so the curve is the union of theirs — `groupCurve` in
* `shared/group-geometry`, beside the fold that answers every other number a
* group is chosen against, and `askedCurve` is the scope it is folded over.
*
* The scope is what the page is being asked, and not one feature of it: the
* same `asked()` answer the tool list is judged against, so the verdict under
* the drawing and the column beside it are about the same thing. A group
* asked for one tool **each** is not one question — every feature gets its
* own tool — so that case reads the feature in front of it, as does a reading
* hovered while the list speaks for itself.
*/
const curve = useMemo(
() => (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(), [])
Expand Down
108 changes: 106 additions & 2 deletions apps/catalog/app/shared/group-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<number> | 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()
})
})
82 changes: 81 additions & 1 deletion apps/catalog/app/shared/group-geometry.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -168,3 +171,80 @@ export const sharedHoleDiameter = (features: ReadonlyArray<PartFeature>): 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<PartFeature>,
all: ReadonlyArray<PartFeature> = 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<PartFeature>,
reading: PartFeature | null,
all: ReadonlyArray<PartFeature>,
): ReachCurve | null =>
groupCurve(results === 'all' && asked.length > 0 ? asked : reading === null ? [] : [reading], all)
Loading