From f0d12edf588af2f9eba70c4245adaa9fa760f0e9 Mon Sep 17 00:00:00 2001 From: Paul Clauss Date: Wed, 26 Aug 2026 09:40:00 -0400 Subject: [PATCH 01/42] feat(dfm): map features to directions, and plan the ways up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the directions and mapping work from the sandbox: 67 new files, 38 merged into files that had moved here, and four conflicts resolved by hand. A part can now be mapped by feature — click a face, see every feature that owns it grouped by way up — or by direction, holding an arrow and painting the faces it reaches. Readings are mapped rough, finish or both; a face is cut once per pass, and giving it up is recorded. Identical holes collapse into one row. What is not cut yet is a list of faces, biggest gap first, opening onto the candidates that would cut them. The ways up themselves can be generated five ways, previewed on the part before being accepted, reordered into run order, and locked. Rules judge only what is mapped, on the band scale, and a band opens onto the features in it with the rules that cost them. Four files needed resolving rather than merging. Three were the DFM rename colliding with the Toolpath mark going in beside the name — the sandbox side already says DFM, so it wins. The fourth was rule-editor.tsx's import block, where this repo had gained a MatchRow editor the sandbox never saw: the imports are unioned, and rule-editor.test.tsx passes, which is the check that it went right. Not green yet, and cannot be until @toolpath/viewer 0.4.0 publishes (toolpath/toolpath#51). Five things wait on it, all the same cause: the zoomTo, onAdjacency and widened shownDirection props do not exist in 0.3.1, and two selection-colour tests assert the retuned direction palette that ships with it. Everything else — 795 of 797 unit tests — passes here today. --- apps/dfm/app/components/create-feature.tsx | 593 +++++ apps/dfm/app/components/cut-from.tsx | 61 + apps/dfm/app/components/face-count.tsx | 89 + apps/dfm/app/components/face-list.test.tsx | 568 +++++ apps/dfm/app/components/face-list.tsx | 1007 ++++++++ apps/dfm/app/components/feature-detail.tsx | 484 ++-- apps/dfm/app/components/feature-viewer.tsx | 205 +- apps/dfm/app/components/map-features.test.tsx | 653 +++++ apps/dfm/app/components/map-features.tsx | 1483 +++++++++++ apps/dfm/app/components/panel-button.tsx | 73 + apps/dfm/app/components/panel-icons.tsx | 139 ++ apps/dfm/app/components/part-inspector.tsx | 2204 ++++++++++++++++- apps/dfm/app/components/part-summary.tsx | 93 +- apps/dfm/app/components/pass-buttons.test.tsx | 99 + apps/dfm/app/components/pass-buttons.tsx | 94 + apps/dfm/app/components/plan-choices.test.tsx | 103 + apps/dfm/app/components/plan-choices.tsx | 252 ++ apps/dfm/app/components/reading-row.test.tsx | 62 + apps/dfm/app/components/reading-row.tsx | 67 + apps/dfm/app/components/rule-editor.tsx | 147 +- apps/dfm/app/components/rules-panel.tsx | 143 ++ apps/dfm/app/components/rules-summary.tsx | 192 +- apps/dfm/app/components/setup-chooser.tsx | 270 ++ apps/dfm/app/components/setups-panel.tsx | 733 ++++++ apps/dfm/app/routes/home.tsx | 10 +- apps/dfm/app/shared/arrows.test.ts | 132 +- apps/dfm/app/shared/arrows.ts | 111 +- apps/dfm/app/shared/bands.test.ts | 39 + apps/dfm/app/shared/best-reading.test.ts | 1042 ++++++++ apps/dfm/app/shared/best-reading.ts | 1190 +++++++++ apps/dfm/app/shared/direction-rows.test.ts | 44 + apps/dfm/app/shared/direction-rows.ts | 80 + apps/dfm/app/shared/directions.test.ts | 163 ++ apps/dfm/app/shared/directions.ts | 237 ++ apps/dfm/app/shared/escape.test.ts | 52 +- apps/dfm/app/shared/escape.ts | 39 +- apps/dfm/app/shared/expression.ts | 5 - apps/dfm/app/shared/faces.test.ts | 488 ++++ apps/dfm/app/shared/faces.ts | 428 ++++ apps/dfm/app/shared/feature-score.ts | 2 - apps/dfm/app/shared/generate.test.ts | 297 +++ apps/dfm/app/shared/generate.ts | 541 ++++ apps/dfm/app/shared/highlighting.test.ts | 112 +- apps/dfm/app/shared/highlighting.ts | 95 + apps/dfm/app/shared/hole-groups.test.ts | 179 ++ apps/dfm/app/shared/hole-groups.ts | 203 ++ apps/dfm/app/shared/infer.test.ts | 213 ++ apps/dfm/app/shared/infer.ts | 256 ++ apps/dfm/app/shared/keys.test.ts | 50 + apps/dfm/app/shared/keys.ts | 54 + apps/dfm/app/shared/list-keys.ts | 16 +- apps/dfm/app/shared/make-feature.test.ts | 455 ++++ apps/dfm/app/shared/make-feature.ts | 589 +++++ apps/dfm/app/shared/map-features.test.ts | 121 + apps/dfm/app/shared/map-features.ts | 118 + apps/dfm/app/shared/measurements.test.ts | 201 ++ apps/dfm/app/shared/metrics.ts | 162 +- apps/dfm/app/shared/paint.test.ts | 108 +- apps/dfm/app/shared/paint.ts | 151 +- apps/dfm/app/shared/part-summary.test.ts | 32 +- apps/dfm/app/shared/part-summary.ts | 34 +- apps/dfm/app/shared/perf.test.ts | 85 + apps/dfm/app/shared/pick-mode.test.ts | 137 + apps/dfm/app/shared/pick-mode.ts | 118 + apps/dfm/app/shared/picks.test.ts | 78 +- apps/dfm/app/shared/picks.ts | 77 + apps/dfm/app/shared/plan-actions.test.ts | 369 +++ apps/dfm/app/shared/plan-actions.ts | 284 +++ apps/dfm/app/shared/plan-summary.test.ts | 291 +++ apps/dfm/app/shared/plan-summary.ts | 340 +++ apps/dfm/app/shared/proposal.test.ts | 122 + apps/dfm/app/shared/proposal.ts | 159 ++ apps/dfm/app/shared/report.ts | 14 + apps/dfm/app/shared/reported-regions.test.ts | 107 + apps/dfm/app/shared/rule-presets.test.ts | 26 +- apps/dfm/app/shared/rule-presets.ts | 199 +- apps/dfm/app/shared/rule-text.ts | 2 + apps/dfm/app/shared/rules-summary.test.ts | 22 + apps/dfm/app/shared/rules-summary.ts | 93 +- apps/dfm/app/shared/rules.test.ts | 116 +- apps/dfm/app/shared/rules.ts | 310 ++- apps/dfm/app/shared/selection-colors.test.ts | 148 ++ apps/dfm/app/shared/selection-colors.ts | 94 +- apps/dfm/app/shared/selection.test.ts | 91 +- apps/dfm/app/shared/selection.ts | 132 +- apps/dfm/app/shared/setup-offers.test.ts | 83 + apps/dfm/app/shared/setup-offers.ts | 119 + apps/dfm/app/shared/setups.test.ts | 422 ++++ apps/dfm/app/shared/setups.ts | 763 ++++++ apps/dfm/app/shared/test-part.ts | 75 + apps/dfm/app/shared/units.ts | 2 - apps/dfm/app/shared/worst-case.test.ts | 157 ++ apps/dfm/app/shared/worst-case.ts | 256 ++ apps/dfm/app/shared/zoom-to.test.ts | 38 + apps/dfm/app/shared/zoom-to.ts | 22 + apps/dfm/app/styles.css | 11 + apps/dfm/docs/README.md | 142 +- apps/dfm/docs/directions-parity-findings.md | 2083 ++++++++++++++++ apps/dfm/docs/directions-parity-plan.md | 829 +++++++ apps/dfm/docs/directions-replay.md | 554 +++++ apps/dfm/docs/highlighting.md | 8 + apps/dfm/docs/interactions.md | 1234 ++++++++- apps/dfm/docs/migration.md | 492 ++++ apps/dfm/tests/cube-fixture.ts | 59 + apps/dfm/tests/dfm.spec.ts | 4 +- apps/dfm/tests/hole-groups.spec.ts | 119 + apps/dfm/tests/mapping.spec.ts | 1609 ++++++++++++ apps/dfm/tests/on-the-part.spec.ts | 638 +++++ apps/dfm/tests/part-fixture.ts | 143 ++ apps/dfm/tests/viewport-reach.spec.ts | 237 +- 110 files changed, 30718 insertions(+), 658 deletions(-) create mode 100644 apps/dfm/app/components/create-feature.tsx create mode 100644 apps/dfm/app/components/cut-from.tsx create mode 100644 apps/dfm/app/components/face-count.tsx create mode 100644 apps/dfm/app/components/face-list.test.tsx create mode 100644 apps/dfm/app/components/face-list.tsx create mode 100644 apps/dfm/app/components/map-features.test.tsx create mode 100644 apps/dfm/app/components/map-features.tsx create mode 100644 apps/dfm/app/components/panel-button.tsx create mode 100644 apps/dfm/app/components/panel-icons.tsx create mode 100644 apps/dfm/app/components/pass-buttons.test.tsx create mode 100644 apps/dfm/app/components/pass-buttons.tsx create mode 100644 apps/dfm/app/components/plan-choices.test.tsx create mode 100644 apps/dfm/app/components/plan-choices.tsx create mode 100644 apps/dfm/app/components/reading-row.test.tsx create mode 100644 apps/dfm/app/components/reading-row.tsx create mode 100644 apps/dfm/app/components/setup-chooser.tsx create mode 100644 apps/dfm/app/components/setups-panel.tsx create mode 100644 apps/dfm/app/shared/bands.test.ts create mode 100644 apps/dfm/app/shared/best-reading.test.ts create mode 100644 apps/dfm/app/shared/best-reading.ts create mode 100644 apps/dfm/app/shared/direction-rows.test.ts create mode 100644 apps/dfm/app/shared/direction-rows.ts create mode 100644 apps/dfm/app/shared/directions.test.ts create mode 100644 apps/dfm/app/shared/directions.ts create mode 100644 apps/dfm/app/shared/faces.test.ts create mode 100644 apps/dfm/app/shared/faces.ts create mode 100644 apps/dfm/app/shared/generate.test.ts create mode 100644 apps/dfm/app/shared/generate.ts create mode 100644 apps/dfm/app/shared/hole-groups.test.ts create mode 100644 apps/dfm/app/shared/hole-groups.ts create mode 100644 apps/dfm/app/shared/infer.test.ts create mode 100644 apps/dfm/app/shared/infer.ts create mode 100644 apps/dfm/app/shared/keys.test.ts create mode 100644 apps/dfm/app/shared/keys.ts create mode 100644 apps/dfm/app/shared/make-feature.test.ts create mode 100644 apps/dfm/app/shared/make-feature.ts create mode 100644 apps/dfm/app/shared/map-features.test.ts create mode 100644 apps/dfm/app/shared/map-features.ts create mode 100644 apps/dfm/app/shared/perf.test.ts create mode 100644 apps/dfm/app/shared/pick-mode.test.ts create mode 100644 apps/dfm/app/shared/pick-mode.ts create mode 100644 apps/dfm/app/shared/plan-actions.test.ts create mode 100644 apps/dfm/app/shared/plan-actions.ts create mode 100644 apps/dfm/app/shared/plan-summary.test.ts create mode 100644 apps/dfm/app/shared/plan-summary.ts create mode 100644 apps/dfm/app/shared/proposal.test.ts create mode 100644 apps/dfm/app/shared/proposal.ts create mode 100644 apps/dfm/app/shared/reported-regions.test.ts create mode 100644 apps/dfm/app/shared/selection-colors.test.ts create mode 100644 apps/dfm/app/shared/setup-offers.test.ts create mode 100644 apps/dfm/app/shared/setup-offers.ts create mode 100644 apps/dfm/app/shared/setups.test.ts create mode 100644 apps/dfm/app/shared/setups.ts create mode 100644 apps/dfm/app/shared/test-part.ts create mode 100644 apps/dfm/app/shared/worst-case.test.ts create mode 100644 apps/dfm/app/shared/worst-case.ts create mode 100644 apps/dfm/app/shared/zoom-to.test.ts create mode 100644 apps/dfm/app/shared/zoom-to.ts create mode 100644 apps/dfm/docs/directions-parity-findings.md create mode 100644 apps/dfm/docs/directions-parity-plan.md create mode 100644 apps/dfm/docs/directions-replay.md create mode 100644 apps/dfm/docs/migration.md create mode 100644 apps/dfm/tests/cube-fixture.ts create mode 100644 apps/dfm/tests/hole-groups.spec.ts create mode 100644 apps/dfm/tests/mapping.spec.ts create mode 100644 apps/dfm/tests/on-the-part.spec.ts create mode 100644 apps/dfm/tests/part-fixture.ts diff --git a/apps/dfm/app/components/create-feature.tsx b/apps/dfm/app/components/create-feature.tsx new file mode 100644 index 0000000..373083d --- /dev/null +++ b/apps/dfm/app/components/create-feature.tsx @@ -0,0 +1,593 @@ +import type { Vec3 } from '@toolpath/api' +import { Button } from '@toolpath/ui' + +import { KindIcon } from './feature-icons' +import { CutFrom } from './cut-from' +import { PassButtons } from './pass-buttons' +import { setupForReading } from '../shared/plan-actions' +import { PASSES, cutState, cutsFace, type Pass, type SetupPlan } from '../shared/setups' +import { ScoreBadge } from './score-badge' +import { directionCss } from '../shared/direction-colors' +import { directionLabel, kindOf } from '../shared/report' +import { typeLabel } from '../shared/part-summary' +import { + coveringAll, + growRun, + perimeterFrom, + readsAs, + relationTo, + runsIn, + type Draft, + type Touching, +} from '../shared/make-feature' +import type { PartFeature } from '../shared/contracts' +import type { PartFaces } from '../shared/setups' +import type { FeatureScore } from '../shared/feature-score' +import { formatArea, type Unit } from '../shared/units' + +/** + * Making a reading the Engine did not report. + * + * The Engine recognises features **per direction**, and on most parts that is + * everything a plan needs. Where it is not — four faces a shop intends to run + * as one operation, which no reported feature covers exactly — this is how they + * say so. + * + * Three questions, in the order they can actually be answered: which way up, + * which faces, and only then what it is. The type is last because it is the one + * the app can guess, and it cannot guess before there are faces to look at. + */ +const Step = ({ + n, + title, + done, + children, +}: { + n: number + title: string + done: boolean + children: React.ReactNode +}) => ( +
+

+ {/* + Numbered because this genuinely is a sequence — the way up decides what + the faces read as, and the faces decide what the type is guessed to be. + Anywhere else in this app a number would be decoration. + */} + + {done ? '✓' : n} + + {title} +

+ {children} +
+) + +export const CreateFeature = ({ + made, + draft, + directions, + report, + touching, + types, + scores, + unit, + plan, + onDraft, + onChoose, + onHover, + onHoverFace, + onConfirm, + onDiscard, + onSetPass, + onAgain, + onDone, + onDelete, + onCutFrom, +}: { + /** The reading just created, waiting to be mapped. */ + made: PartFeature | null + draft: Draft + directions: readonly Vec3[] + /** The part, for what covers these faces and what each of them is. */ + report: PartFaces & { features: ReadonlyArray } + /** Which faces touch which, for chaining and continuity. */ + touching: Touching + /** The types this part has, so a made reading is named like the rest. */ + types: readonly string[] + scores: ReadonlyMap + unit: Unit + /** The mapping so far, so the presses below say what they already hold. */ + plan: SetupPlan + onDraft: (draft: Draft) => void + onChoose: (featureTag: string) => void + onHover: (tags: string[]) => void + /** Light one chosen face on the part on its own. */ + onHoverFace: (region: number | null) => void + onConfirm: () => void + onDiscard: () => void + /** Map the reading just created, in the passes named. */ + onSetPass: (feature: PartFeature, passes: ReadonlyArray) => void + /** Take it off the part again. */ + onDelete: (featureTag: string) => void + /** Point the made reading at another candidate way up, and re-read it there. */ + onCutFrom: (featureTag: string, direction: number) => void + /** Put it down and start another. */ + onAgain: () => void + /** Leave the mode entirely. */ + onDone: () => void +}) => { + const vector = draft.direction === null ? null : directions[draft.direction] + /** + * Which of the chosen faces the plan is already cutting, and from where. + * + * By way up rather than by reading, because that is the thing somebody is + * deciding between: "this face is already cut from −Y" is the sentence, and + * which of −Y's readings holds it is a level of detail below the question. + */ + const cutNow = new Map() + for (const face of draft.faces) { + for (const other of report.features) { + if (!PASSES.some((pass) => cutsFace(plan, other, pass, face))) continue + cutNow.set(face, directionLabel(other.machiningDirection)) + break + } + } + + const already = coveringAll(report.features, draft.faces) + const sameWayUp = vector + ? already.filter((each) => relationTo(vector, each.machiningDirection) === 'same') + : [] + const guesses = vector ? readsAs(report.features, vector, draft.faces) : [] + const byIdx = new Map(report.regions.map((region) => [region.idx, region])) + const perimeter = vector ? perimeterFrom(report.features, vector) : [] + const runs = vector ? runsIn(report.features, vector, draft.faces, touching) : [] + /* + * Profile, when the Engine has a contour from here — and the run the chosen + * faces sit in when it does not, which on a real part is most ways up. + */ + const grown = vector ? growRun(report.features, vector, draft.faces, touching) : [] + /* + * The Engine's own contour where it has one, and otherwise the run the chosen + * faces sit in. + * + * A part reports two contours across seven ways up, so on five of them the + * Engine's answer is silence — and a button that is only ever unavailable is + * one nobody learns the meaning of. Following the surface from a face + * somebody has already pointed at is the same question asked of the mesh + * instead. + */ + const profile = perimeter.length > 0 ? perimeter : grown + const ready = + draft.direction !== null && + draft.featureType !== null && + draft.faces.length > 0 && + // One piece. A reading drawn from two unconnected groups is one no toolpath + // could follow, so it is refused rather than made and left to be found. + runs.length === 1 + + /* + * Made, and not yet mapped. + * + * A reading is only half of a decision — the other half is which way up cuts + * it, and somebody who has just drawn one is about to say so. Sending them + * off to find it in another list to press the same three buttons is the panel + * dropping the thread it was holding. + */ + if (made !== null) { + const setup = setupForReading(plan, directions, made) + + return ( +
+
+

+ Made. It is a reading like any other now — say where it is cut from. +

+ {/* + Offered here as well as in the editor, because the moment after + drawing one is when the way up is most likely to be wrong — the + faces were picked looking at the part, the arrow before looking. + */} +
+ onCutFrom(made.featureTag, index)} + /> +
+
+ + + + + {typeLabel(made.featureType)} + + made + + {made.regionIdxs.length}f + + {directionLabel(made.machiningDirection)} + + + onSetPass(made, passes)} + /> +
+
+
+ {/* + A thing that can be made and not unmade is a trap, and the moment + after making one is when somebody is most likely to want it gone. + */} + + + + {/* + Its own way out, not the toggle's. + + Done used to call the same handler the Create button does, which + *toggles* — and with the draft already put down by the confirm, it + started a fresh drawing instead of leaving. Finishing and starting + again are two things. + */} + + +
+
+ ) + } + + return ( +
onHoverFace(null)}> + +

+ Press an arrow on the part, or pick one here. +

+
+ {directions.map((direction, index) => ( + + ))} +
+
+ + 0}> + {draft.direction === null ? ( +

Choose a way up first.

+ ) : ( + <> +
+ {/* + Chaining off by default: on it, a stray click adds a run rather + than a face — a bigger mistake to notice and a bigger one to + undo. + */} + + {/* + A profile *is* the boundary contour of its direction — that is + what the Engine means by the word — so this reads its own + contours rather than walking the part. + */} + + {draft.faces.length > 0 ? ( + + ) : null} +
+

+ {draft.chaining + ? 'Click a face, then another, to take the run between them.' + : 'Click faces on the part to add them, and again to take them off.'} + {perimeter.length === 0 && draft.faces.length === 0 ? ( + // Said rather than left as a grey button: a control nobody can + // press and nobody can find out why is one that reads as broken. + <> + {' '} + Profile follows the surface a chosen face + sits in, so choose one first. + + ) : null} +

+ {/* + A running list, because "four faces are chosen" is not something + anybody can check against the part — the far side of it is not on + screen, and a face behind another cannot be counted at all. + */} + {/* + A feature is one continuous piece of geometry: an operation runs + over faces that touch, and a reading drawn from two unconnected + groups is one no toolpath could follow. Said with the count of + pieces rather than a bare refusal, so somebody knows which face to + take off. + */} + {runs.length > 1 ? ( +

+ These are {runs.length} separate pieces —{' '} + {runs.map((run) => run.length).join(' and ')} faces. A feature has to be one. +

+ ) : null} + {draft.faces.length === 0 ? ( +

+ No faces yet. +

+ ) : ( +
    + {draft.faces.map((idx) => ( +
  • onHoverFace(idx)} + onMouseLeave={() => onHoverFace(null)} + > + Face {idx} + + {byIdx.get(idx)?.shapeKind ?? 'unknown'} + + + {formatArea(byIdx.get(idx)?.area ?? 0, unit)} + + +
  • + ))} +
+ )} + + )} +
+ + + {/* + Guessed from the faces, and it keeps guessing as they change — a type + filled in from three faces should not stick once there are five. + Naming one stops it, because disagreeing with the guess is the reason + the field is editable at all. + */} + + {guesses.length === 0 ? null : ( +

+ {draft.named ? 'These faces read as' : 'Guessed —'}{' '} + {guesses.slice(0, 3).map((guess, at) => ( + + {at > 0 ? ', ' : ''} + {' '} + + ({guess.faces} of {draft.faces.length}) + + + ))} + . +

+ )} +
+ + {/* + The useful half. + + Most of the time the Engine has already reported what somebody is about + to draw, and mapping the reported one is better than making a second + reading of the same geometry. So these are offered, and the list going + empty is the signal that this really is new. + */} + {draft.faces.length === 0 ? null : ( + + {/* + Faces already being machined, which is the more urgent half. + + "Nothing covers all of these, this is new" answers a question about + the **shape** — is the Engine already describing it. It says nothing + about the **plan**, and a face already cut from somewhere is one this + reading is about to take: cut once means the press that maps this + takes it off whatever holds it now. Somebody drawing over a mapped + wall should be told before, not find out from a coverage figure + afterwards. + */} + {cutNow.size === 0 ? null : ( +

+ {cutNow.size} of these {cutNow.size === 1 ? 'faces is' : 'faces are'} already machined + — {[...cutNow.values()].join(', ')}. Mapping this takes{' '} + {cutNow.size === 1 ? 'it' : 'them'} off whatever cuts{' '} + {cutNow.size === 1 ? 'it' : 'them'} now. +

+ )} + {already.length === 0 ? ( +

+ Nothing covers all {draft.faces.length} of these. This is new. +

+ ) : ( + <> + {/* + Which way up they are cut from, because it changes the advice + entirely. A reading covering the same faces from the **other + side of the part** is not the same operation, and offering it as + one is the panel giving bad advice. + */} +

+ {already.length} reading{already.length === 1 ? '' : 's'} already cover + {already.length === 1 ? 's' : ''} all of these + {sameWayUp.length > 0 + ? ` — ${String(sameWayUp.length)} from this way up, so map one instead of drawing a second.` + : '. None from this way up, so a made reading may still be the right answer.'} +

+
    + {already.map((feature) => ( +
  • onHover([feature.featureTag])} + onMouseLeave={() => onHover([])} + > + +
  • + ))} +
+ + )} +
+ )} + + {/* + Said while it is being drawn, not asked for afterwards. + + A reading is only half of a decision, and somebody drawing one already + knows what they mean to do with it — making them say it again in another + panel is asking the same question twice. Left unset it is made and not + yet mapped, which is a real state and the one the next screen offers to + fix. + */} +
+ Cut it + + onDraft({ + ...draft, + // The same three presses everywhere else: empty means take it off + // both, and pressing what it already holds is how that is said. + passes: + passes.length === 0 + ? [] + : passes.every((pass) => draft.passes.includes(pass)) + ? draft.passes.filter((pass) => !passes.includes(pass)) + : [...new Set([...draft.passes, ...passes])], + }) + } + /> + + + + +
+
+ ) +} diff --git a/apps/dfm/app/components/cut-from.tsx b/apps/dfm/app/components/cut-from.tsx new file mode 100644 index 0000000..4a9ea62 --- /dev/null +++ b/apps/dfm/app/components/cut-from.tsx @@ -0,0 +1,61 @@ +import { directionCss } from '../shared/direction-colors' +import type { Vec3 } from '@toolpath/api' +import { directionLabel } from '../shared/report' + +/** + * The other ways up a made reading could be cut from. + * + * Drawing one is two decisions — which faces, and from where — and the second + * is the one somebody changes their mind about: the faces are a fact about the + * part, the way up is a choice about the setup. Redrawing the faces to change + * it is asking them to redo the half that was right. + * + * Only made readings get this. A reported one is the Engine's answer to "what + * is cuttable from here", and pointing it elsewhere would be inventing an + * answer it never gave. + */ +export const CutFrom = ({ + directions, + current, + onCutFrom, +}: { + directions: ReadonlyArray + /** The way up it is cut from now, so that one reads as held rather than offered. */ + current: Vec3 + onCutFrom: (direction: number) => void +}) => { + const here = directionLabel(current) + + return ( +
+ + Cut from + + {directions.map((direction, index) => { + const holds = directionLabel(direction) === here + + return ( + + ) + })} +
+ ) +} diff --git a/apps/dfm/app/components/face-count.tsx b/apps/dfm/app/components/face-count.tsx new file mode 100644 index 0000000..7fc02c3 --- /dev/null +++ b/apps/dfm/app/components/face-count.tsx @@ -0,0 +1,89 @@ +/** + * How many faces a reading has, and a way into them. + * + * A control, not a caption. A face is what a plan is made of — cut once, + * counted by coverage, taken by a claim — so the count is the doorway to the + * level below, and it says so by looking pressable. + * + * One component because it appears in three places that must agree: the mapping + * lists, the confirmed directions, and the datasheet. Three copies of a number + * that means "how much of this is being cut" is three chances for one of them + * to still be counting what the reading *covers*. + */ + +/** The pencil. A picture of the verb, so the row does not have to spell it. */ +const Pencil = () => ( + +) + +export const FaceCount = ({ + faces, + cut, + onShow, +}: { + /** Every face the reading covers. */ + faces: number + /** How many of them it is cutting in the pass being shown. */ + cut: number + onShow: () => void +}) => { + const whole = cut === faces + + /* + * The words the button used to wear, moved into its name. + * + * It read `Edit Feature (14 regions)` on every row of a list where every row + * has one, which is eleven characters of the same sentence repeated down the + * panel — and on a part with long feature names it was the widest thing in + * the row. The pencil says *edit* and the number says *how many*; nobody + * needed to be told the number counts regions when the thing it opens is a + * list of them. + * + * An `aria-label` is right here where it was wrong before: the visible label + * is now a picture and a figure, so there is no text for it to disagree with + * — and "(14)" on its own is not a control anybody could name. + */ + const said = whole + ? `Edit feature, ${String(faces)} regions` + : `Edit feature, cutting ${String(cut)} of its ${String(faces)} regions — ${String( + faces - cut, + )} went to another reading` + + return ( + + ) +} diff --git a/apps/dfm/app/components/face-list.test.tsx b/apps/dfm/app/components/face-list.test.tsx new file mode 100644 index 0000000..4fef52c --- /dev/null +++ b/apps/dfm/app/components/face-list.test.tsx @@ -0,0 +1,568 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { FaceList } from './face-list' +import { EMPTY_PLAN, PASSES, type SetupPlan } from '../shared/setups' +import { setFaceCut } from '../shared/faces' +import { setPassFor } from '../shared/plan-actions' +import { TEST_DIRECTIONS, testFeature, testPart } from '../shared/test-part' + +/** + * The face editor. The clicks it arms happen on a mesh no fixture mounts (F51), + * so what the part does with them is pinned in `faces.test.ts` and what the + * panel says about them is pinned here. + */ +afterEach(cleanup) + +const profile = testFeature('profile', 'profile', TEST_DIRECTIONS[0]!, [0, 1]) +const wall = testFeature('wall', 'wall', TEST_DIRECTIONS[1]!, [2]) +const features = [profile, wall] +const report = { ...testPart(), features } + +const editor = (plan: SetupPlan = EMPTY_PLAN) => { + const onSetFace = vi.fn() + const onSelectAll = vi.fn() + const onSelectFree = vi.fn() + const onRetype = vi.fn() + const onHoverFace = vi.fn() + + const panel = (shown: SetupPlan) => ( + + ) + + const { rerender } = render(panel(plan)) + + return { + onSetFace, + onSelectAll, + onSelectFree, + onRetype, + onHoverFace, + rerender: (next: SetupPlan) => rerender(panel(next)), + } +} + +/** Face 2 handed to the profile, which the Engine never reported there. */ +const handed = setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, PASSES, 2, true) + +describe('adding a face to a reading', () => { + it('says what a click on the part will do, because that is where it is done', () => { + // Nothing to arm: in here a click has only one meaning. + editor() + + expect(screen.queryByRole('button', { name: 'Add a face' })).toBeNull() + expect(screen.getByRole('group', { name: 'Clicking a face' })).toBeTruthy() + expect(screen.getByText(/Puts it in or takes it out/)).toBeTruthy() + }) + + it('lists the handed face, so there is a row to take it back off', () => { + editor(handed) + + expect(screen.getByRole('checkbox', { name: /face 2 /i })).toBeTruthy() + }) + + it('marks it as added, because an unmarked row would read as the Engine own answer', () => { + editor(handed) + + expect(screen.getByText('added')).toBeTruthy() + }) + + it('counts it among the reading faces', () => { + // One of the profile's own two is cut, plus the one handed to it. + editor(handed) + + expect(screen.getByText('1 of 3 faces')).toBeTruthy() + }) +}) + +describe('what the list highlights', () => { + /* + * A highlight that is on everything points at nothing. Cut rows used to carry + * a fill of their own, and on a reading whose faces are all cut — which is + * most of them — that is every line lit the moment it opens. + */ + const rowFor = (label: RegExp) => + screen.getByRole('checkbox', { name: label }).closest('div')?.className ?? '' + + it('leaves a cut face unfilled, because the tick already says it is cut', () => { + editor(setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, PASSES, 0, true)) + + expect(screen.getByRole('checkbox', { name: /face 0 /i, checked: true })).toBeTruthy() + expect(rowFor(/face 0 /i)).not.toContain('bg-info') + }) + + it('fills the one being worked on, which is what the list cannot otherwise show', () => { + editor() + fireEvent.click(screen.getByRole('button', { name: /Show what else covers face 1/ })) + + expect(rowFor(/face 1 /i)).toContain('bg-info/20') + expect(rowFor(/face 0 /i)).not.toContain('bg-info') + }) +}) + +describe('what the tick says', () => { + /** Finished from its own way up, and roughed nowhere. */ + const finishedOnly: SetupPlan = { + setups: [{ id: 'a', directionIndex: 0, name: '+Z' }], + assigned: { profile: { finish: 'a' } }, + } + + it('is ticked for a face this reading finishes, with roughing on screen', () => { + /* + * Paul's screenshot: "0 of 12 faces" above an expanded row showing that + * very reading with F lit. The tick writes both passes and was reading one. + */ + editor(finishedOnly) + + expect(screen.getByRole('checkbox', { name: /face 0 /i })).toHaveProperty('checked', true) + expect(screen.getByText('2 faces')).toBeTruthy() + }) + + it('reads mixed when only one pass holds it, like the pass buttons do', () => { + editor(finishedOnly) + + expect(screen.getByRole('checkbox', { name: /face 0 /i })).toHaveProperty('indeterminate', true) + }) + + it('fills a half-cut face up rather than emptying it', () => { + // Pressing a dashed control takes the rest back — the rule R, F and Both + // already follow. + const { onSetFace } = editor(finishedOnly) + + fireEvent.click(screen.getByRole('checkbox', { name: /face 0 /i })) + expect(onSetFace).toHaveBeenCalledWith(profile, 0, true) + }) +}) + +describe('the order the faces come in', () => { + const named = () => screen.getAllByRole('checkbox').map((box) => box.getAttribute('aria-label')) + + /* + * Scoped to the list, because the four names now appear twice on purpose. + * + * The headings say what this reading *has*; the key beside the switch says + * what the colours *mean*. Asking the page finds both, and the two are + * answering different questions. + */ + const heading = (label: string) => + within(screen.getByRole('list', { name: 'Faces' })).queryByText(label) + + it('groups them by what the plan does with each', () => { + /* + * The question the panel is opened with. A face roughed here and finished + * from the other side costs a second setup, and that fact was spread + * through a column of rows for the eye to gather. + */ + const roughed = setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, ['rough'], 1, true) + editor(roughed) + + expect(heading('Roughed only')).toBeTruthy() + expect(heading('Not cut here')).toBeTruthy() + }) + + it('puts the faces it cuts above the ones it does not', () => { + editor(setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, PASSES, 1, true)) + + expect(named()?.[0]).toMatch(/face 1 /i) + }) + + it('names no group it has no faces for', () => { + // An empty heading is a claim about the reading that is not true of it — + // which is a different statement from the colour key, where the same four + // words mean "this is what green would mean". + editor() + + expect(heading('Roughed only')).toBeNull() + expect(heading('Finished only')).toBeNull() + }) + + it('is the key to the part as well as a list', () => { + // The headings carry the swatch the model is painted in, so a row's group + // says what colour that face is wearing without a second lookup. + editor(setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, PASSES, 1, true)) + + expect(heading('Roughed and finished')).toBeTruthy() + }) +}) + +describe('a face handed to the reading being edited', () => { + /* + * Paul's case: a wall the Engine sees only from one direction, added to a + * group it sees from the other. Adding it means "this face is part of the + * feature I am editing", not "enable the reading the Engine reported". + */ + const handed = setFaceCut(EMPTY_PLAN, TEST_DIRECTIONS, features, profile, PASSES, 2, true) + + it('lists the reading being edited among the face readings, and says which it is', () => { + /* + * The Engine's own list is `regionIdxs`, and an added face is by definition + * not in it — so the row opened onto a list not containing the reading the + * face had just been added to. The only row was the other direction, with + * its passes off, and pressing it enabled the face there instead. + */ + editor(handed) + fireEvent.click(screen.getByRole('button', { name: /Show what else covers face 2/ })) + + expect(screen.getByText('this one')).toBeTruthy() + }) +}) + +describe('clicking a face row', () => { + it('opens it, rather than taking the face out of the reading', () => { + /* + * The whole row used to be a `