@toolpath/plan 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toolpath
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,259 @@
1
- # Temporary Holding Version
1
+ # @toolpath/plan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Plan mode for Toolpath: the plan a person states for a part, every edit they can make to it, and the
4
+ plan Toolpath makes from it. A person sets up the ways the part is held, groups work in folders,
5
+ pins features where they want them cut and ignores faces that need no work. These are plain
6
+ functions over plain data, with no React, no DOM and no database, so a server and a browser can
7
+ read the same rules. Use it in any app that plans parts with Toolpath: a quoting app, a planning
8
+ app or a CAM tool.
9
+
10
+ ```sh
11
+ pnpm add @toolpath/plan
12
+ ```
13
+
14
+ ```ts
15
+ import { actionsFor, applyDraftAction, classifyFaces, statementOf } from '@toolpath/plan'
16
+ ```
17
+
18
+ The package is logic only: no React, no DOM and no CSS. Its one dependency is `@toolpath/tool-support`, for the inch. A view over these rules
19
+ belongs in `@toolpath/ui`.
20
+
21
+ ## Your app's own fields
22
+
23
+ The draft holds only what Toolpath plans from. Your app keeps its own fields beside it, such as
24
+ when it was saved, who changed it, or the material picked. `applyDraftAction` takes any object that
25
+ holds the constraints and gives it back with your fields as they were:
26
+
27
+ ```ts
28
+ import type { Constraints } from '@toolpath/plan'
29
+
30
+ interface MyDraft extends Constraints {
31
+ savedAt: string | null
32
+ }
33
+ ```
34
+
35
+ For example, `@toolpath/quoting/plan` is the quoting app's draft, made this way.
36
+
37
+ ## Two layers
38
+
39
+ A page holds the person's edits until they choose to send them: that step is called Apply in
40
+ these docs, and Discard throws the edits away.
41
+
42
+ Plan mode has two layers:
43
+
44
+ - **The draft** (`Constraints`): what a person asks for. It holds setups, the folders inside them,
45
+ the features pinned to each folder, and the faces ignored. It starts empty: with nothing stated,
46
+ Toolpath plans the part itself.
47
+ - **The plan** (`EnginePlan`): what Toolpath planned. It holds setups, each with its direction and
48
+ its actions, and the issues it could not reach.
49
+
50
+ The draft goes to Toolpath, and the plan comes back. A person can start a draft from the plan's
51
+ setups, each read as a `PlannedSetup`: its directions and the features it takes:
52
+
53
+ ```
54
+ draft ──statementOf──▶ job's setupPlan ──▶ Toolpath ──▶ EnginePlan
55
+ ▲ │
56
+ └──────── planFromResults / foldersFromPlan ◀── PlannedSetup
57
+ ```
58
+
59
+ Each edit is a function from the draft as it is to the draft as it will be. These always answer a draft:
60
+ `addFolder`, `renameFolder`, `removeFolder`, `moveSetup`, `moveWithin`, `unpinFeature`, and, for
61
+ ignored faces, `withIgnored` and `withoutIgnored`. These return `{ refused }` with a reason to show
62
+ when the edit cannot be made: `setSetupProcess`, `setToolOrientation`, `moveFolder`,
63
+ `moveFeature`, `pinInto` and `pinFrom`. Nothing here throws on typed input; read a stored draft
64
+ through `parseDraftSetups` first.
65
+ `parseDraftSetups` and `parseStock` check stored data before it is used.
66
+
67
+ `classifyFaces` reads what the draft and the plan leave of each face: pinned, ignored, open,
68
+ machined or not machined (`COVERAGE_KINDS`). A viewer paints faces from it.
69
+
70
+ ## Edits: one menu, one function, one question
71
+
72
+ A page edits the draft through three things, and nothing else:
73
+
74
+ - `DraftAction`: every edit a person can make, as one closed list. Each action carries all it
75
+ needs, so the same action on the same draft gives the same plan: a new id for anything it makes,
76
+ which must not be in the draft yet, and for a feature the direction it was read from.
77
+ - `applyDraftAction(draft, action, rules, saved)`: carries out any action. It answers `{ draft }`,
78
+ with `facts` for the status line and, where Toolpath's own app opens a folder after it, that
79
+ folder's `folderId`; or `{ refused }`, the reason to show the person. An action that changes
80
+ nothing answers the draft as it was. `saved`, the plan as applied, is what a pick among a click's
81
+ readings puts back; the session passes it.
82
+ - `actionsFor(draft, target, rules)`: what can be done here. The target is the part, a folder, a
83
+ feature read from a direction, or faces picked on the part. A page offers these kinds of action,
84
+ and no others. Three follow other controls: the two picks among a click's readings, offered
85
+ where a click read more than one feature, and `fromPlan`, offered while the plan holds no folders
86
+ and Toolpath has planned the part.
87
+
88
+ A drag says why a drop can't be made while it hovers: try the drop with `applyDraftAction` and show
89
+ its `refused`. The tree's top row (the part) is no folder, so a page says "Rows go into a folder."
90
+ for a feature's row dropped there itself. A folder dropped where it already is changes nothing, and the page
91
+ opens nothing for it.
92
+
93
+ After a pick among an Ignore click's readings, the pick is the click: send the next pick with
94
+ `click: { tag, regions: rules.regionsOf(tag) }` for the feature picked.
95
+
96
+ `rules` (`PlanRules`) reads the part: the faces of each feature, and the kernel that numbers them.
97
+
98
+ ```ts
99
+ import { actionsFor, applyDraftAction, type DraftAction } from '@toolpath/plan'
100
+
101
+ const offered = actionsFor(draft, { folder: 'holes' }, rules) // show these as buttons
102
+
103
+ const press = (action: DraftAction) => {
104
+ const result = applyDraftAction(draft, action, rules)
105
+ if ('refused' in result) {
106
+ showMessage(result.refused)
107
+ } else {
108
+ setDraft(result.draft)
109
+ }
110
+ }
111
+ ```
112
+
113
+ | Kind | What it does |
114
+ | --------------------- | ------------------------------------------------------------------------------------------------- |
115
+ | `addFolder` | A folder inside another, at the end |
116
+ | `renameFolder` | A new name |
117
+ | `remove` | What Delete removes: folders (setups too) with all they hold, and pins; every setup is Remove all |
118
+ | `moveFolder` | A folder dropped in the tree: reordered, into a folder, or to the top |
119
+ | `moveWithin` | A folder moved up or down beside its neighbors |
120
+ | `addSetup` | A setup facing a direction nothing faces yet |
121
+ | `setProcess` | Hold a setup 3 axis, 3+1 or 3+2 |
122
+ | `setToolOrientation` | A folder in an indexed setup tilted to a direction of its own, or not |
123
+ | `pinInto` | In an open folder: a left click pins the feature there, a right click takes out one pinned there |
124
+ | `pinFrom` | The feature pinned in the folder facing its direction, or a new setup; let go if pinned that way |
125
+ | `moveFeature` | A feature's row dropped on a folder: pinned there |
126
+ | `unpin` | The feature let go: Toolpath decides where it is machined |
127
+ | `ignore` | Faces ignored; a pin whose every face is ignored is let go |
128
+ | `unignore` | Faces no longer ignored |
129
+ | `choosePinReading` | After a click in a folder read several features: another of them meant instead |
130
+ | `chooseIgnoreReading` | After an Ignore click read several features: another of them ignored instead |
131
+ | `fromPlan` | Start from Toolpath's plan: its setups as folders, and with `pins` its features pinned |
132
+
133
+ Each kind makes the plan Toolpath's own app makes for the same click; the package's tests hold each
134
+ to the app's own helper. A pin,
135
+ of any kind, does what pinning does there: the person's pins whose every face it covers are let go,
136
+ and its own faces are no longer ignored. Pinning a feature where it is pinned already lets it go, as
137
+ a right click does. A left click on a feature in the open folder already sends nothing: it stays,
138
+ and a pick among its readings (`choosePinReading`, `target: 'add'`) pins another beside it. With
139
+ Ignore open, `ignoreClick` says what a click does: a left click ignores, a right click lets go.
140
+
141
+ `facts` say what the action did beyond the draft, for the status line: the pins a pin `replaced`,
142
+ the faces it `unignored`, the pins an ignore `unpinned`, whether a pin `added` a setup, whether it
143
+ was a second click that `letGo`, and the folder (`home`) it is pinned in or let go from. Toolpath's
144
+ own app says nothing of the faces Clear gives back, and a pick among an Ignore click's readings
145
+ says only what it ignores (its `unignored` is empty).
146
+
147
+ A page that asks before adding a setup tries the action first, with the same draft, `saved` and
148
+ ids, and sends it only once the person says yes: `applyDraftAction` changes nothing itself.
149
+
150
+ ```ts
151
+ const pin: DraftAction = { kind: 'pinFrom', tag, direction, newId: crypto.randomUUID() }
152
+ const tried = applyDraftAction(session.draft, pin, rules, session.saved)
153
+ if ('refused' in tried || !tried.facts.added || (await confirm('Add a setup for it?'))) {
154
+ send({ type: 'act', action: pin })
155
+ }
156
+ ```
157
+
158
+ ## Undo, Redo, Discard: the session
159
+
160
+ A page holds the person's edits until Apply in a `PlanSession`. One pure function, `planSession`,
161
+ moves it on, so a React page gets Undo, Redo, Discard and the history from one `useReducer`:
162
+
163
+ ```ts
164
+ import {
165
+ canUndo,
166
+ isHeld,
167
+ planSession,
168
+ startSession,
169
+ type PlanSession,
170
+ type SessionEvent,
171
+ } from '@toolpath/plan'
172
+
173
+ const [session, send] = useReducer(
174
+ (now: PlanSession<MyDraft>, event: SessionEvent<MyDraft>) => planSession(now, event, rules),
175
+ saved, // the draft as applied, read from your server
176
+ startSession,
177
+ )
178
+
179
+ send({ type: 'act', action: { kind: 'addSetup', direction: up, id: crypto.randomUUID() } })
180
+ send({ type: 'undo' })
181
+ ```
182
+
183
+ | Event | What it does |
184
+ | --------- | ------------------------------------------------------------------------------------- |
185
+ | `act` | A `DraftAction`, as one step. Refused, the draft stays and `session.refused` says why |
186
+ | `edit` | Your app's own fields (a material, a stock), as one step. Never the constraints |
187
+ | `undo` | One step back |
188
+ | `redo` | One step forward |
189
+ | `discard` | The draft as applied again: one step, which Undo takes back |
190
+ | `applied` | Your Apply saved this draft: it is now the draft as applied |
191
+ | `read` | The draft as applied, read again; held edits stay on it |
192
+
193
+ Edits with the same `coalesce` key, one after another, are one step: use one key while a dialog is
194
+ open. Undo reaches back `HISTORY` (100) steps. `isHeld` says whether there is anything to Apply or
195
+ Discard; `canUndo` and `canRedo` say whether the buttons work. `session.refused` says why the last
196
+ action was refused, `session.folderId` names the folder to open after it, and `session.facts` says
197
+ what else it did; the next event clears all three. Copy what they say into the page's own state:
198
+ the status line keeps its note after the next event, Undo included.
199
+
200
+ The session saves nothing. Your app sends `session.draft` on Apply and tells the session how it
201
+ went, with `applied`, or with `read` when the plan was applied elsewhere first. Keep save stamps,
202
+ such as a version, out of the session's draft, or Undo puts an old one back. Pass the version on
203
+ `startSession`, `applied` and `read` instead: a read older than the session's is not taken.
204
+
205
+ - Start the session once the draft as applied is read, and a new one for each part: in React,
206
+ `key={partId}` on the page that holds it. Edits kept from before a reload start held:
207
+ `startSession(saved, { held: kept, version })`.
208
+ - `rules` is `PlanRules` (the part's faces and kernel), and three optional fields. `locked`: the
209
+ plan can't be changed now, so no edit, Undo, Redo or Discard changes it. `same`: whether two
210
+ drafts are the same plan; the default compares values, whatever the order of each object's
211
+ keys. `rebase`: how held edits go onto a plan read again; by default the constraints stay as held,
212
+ and each of your own fields stays as held where it was edited here and follows the plan read
213
+ where it was not.
214
+ - `edit` takes a function of the draft as it is now, and answers the fields it changes
215
+ (`AppFields`). It must be pure, because React may call it twice. Setups, folders, pins and
216
+ ignored faces change only through `act`, where every change is checked: any `edit` answers for
217
+ them is passed over.
218
+
219
+ ## Every "no", and why
220
+
221
+ The package says no only where it must. Each refusal is one of these:
222
+
223
+ | Refusal | Kind | Why |
224
+ | ------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------- |
225
+ | "… is cut from that direction already." | Engine | Toolpath takes each direction once |
226
+ | "A plan holds at most 64 setups." | Engine | The most setups Toolpath takes in a stated plan |
227
+ | "It is cut from …’s direction, a setup already." | Engine | As a setup it would face its setup's direction twice |
228
+ | "No folder in … is cut from that direction." | Our choice | Could instead pin the same faces as read from a direction a folder in it faces |
229
+ | "That folder is gone." / "Only a setup has a hold." | Bad input | The action names something that is not there, or not a setup |
230
+ | "That id is taken: each folder needs an id of its own." | Bad input | Two folders with one id could not be told apart |
231
+ | "A folder moves a whole number of places." / "That is not a plan action." | Bad input | The action is not one the list holds |
232
+ | "A folder can’t go inside itself." | Not possible | The tree would loop |
233
+ | "This needs the plan as applied." | Bad input | A pick among readings puts back what the plan as applied had |
234
+ | "Start from Toolpath’s plan while the plan holds no folders." | Our choice | Could instead add Toolpath's setups after the folders there |
235
+ | "Folders sit at most 6 below their setup." | Our choice | `MAX_DEPTH` keeps the tree readable |
236
+ | "Move or delete its tool orientations first." | Our choice | Could instead make the tilts plain folders, or setups of their own |
237
+ | "Only a 3+1 or 3+2 setup has tool orientations." | Our choice | Could instead index the setup, as dropping a folder from another direction does |
238
+ | "Rows stay in folders cut from their own direction." | Our choice | Could instead pin that direction's reading of the same faces |
239
+ | "Only a folder inside a setup can be a tool orientation." | Our choice | A setup's direction is fixed when it is made |
240
+
241
+ An "our choice" refusal can become a yes in a later release. Until then, it stays as it is.
242
+
243
+ ## Clicks on the part, and what it shows
244
+
245
+ These are the helpers behind the Plan page of Toolpath's own app. Each is a plain function of plain data:
246
+
247
+ - Pins: `pinEdit`, `withPin`, `folderClick`, `chooseReading`, `takeBack` and `replacedBy`. Ignore:
248
+ `ignoreEdit`, `unignoreEdit`, `ignoreClick` and `ignoreSelection`. `readingsFor` lists the
249
+ features a click on a face can mean.
250
+ - The tree: `draftView` lays out the draft, `planView` and `resultsView` the plan. The setups of
251
+ `resultsView` can be passed to `planFromResults`. `flattenFolders`, `projectFolder` and `gapAt`
252
+ place a folder being dragged, and say where it lands as a `DropAt`.
253
+ - Paint: `planPaint` and `planHatch` color each face from `classifyFaces`
254
+ (`DONE_COLOR`, `UNMACHINED_FACE_COLOR`, `OTHER_FOLDER_COLOR`).
255
+ - Before Apply: `planChecks` and `confirmChecks` list what the person should check.
256
+ `directionAnalysis` says whether a direction has been analyzed.
257
+
258
+ `tests/sdk-contract.test.ts` checks these types against `@toolpath/api`'s. The type checker
259
+ (`pnpm check-types`) runs that check; `vitest` alone does not.