@toolpath/viewer 1.1.0 → 1.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/README.md CHANGED
@@ -1,131 +1,903 @@
1
1
  # `@toolpath/viewer`
2
2
 
3
- React Three Fiber components for exploring Toolpath Engine part meshes. The package is client-side:
4
- it can be imported by an SSR application, but render `<Viewer>` from a client component.
3
+ Show a machined part in 3D in your React app. Users can spin it, zoom in, click a surface to see
4
+ which features own it, cut it open with a section plane, and jump between standard views with a
5
+ view cube.
6
+
7
+ It is built on [React Three Fiber](https://r3f.docs.pmnd.rs) and draws parts analyzed by the
8
+ Toolpath Engine. You can also give it your own geometry.
9
+
10
+ - [Install](#install)
11
+ - [Quick start](#quick-start)
12
+ - [How it works](#how-it-works)
13
+ - [Components](#components)
14
+ - [Recipes](#recipes)
15
+ - [Errors](#errors)
16
+ - [Working with the Toolpath API](#working-with-the-toolpath-api)
17
+ - [Using it without the Engine](#using-it-without-the-engine)
18
+ - [Reference](#reference)
19
+
20
+ ## Install
5
21
 
6
22
  ```bash
7
23
  npm install @toolpath/viewer react react-dom three @react-three/fiber @react-three/drei
8
24
  ```
9
25
 
10
- ## Toolpath Engine reports
26
+ ## Quick start
27
+
28
+ Pass a part report from the Toolpath API to `<EnginePart>`:
11
29
 
12
30
  ```tsx
13
- import { Axes, DirectionArrows, Grid, ViewCube, Viewer } from '@toolpath/viewer'
31
+ 'use client'
32
+
33
+ import { Suspense, useState } from 'react'
34
+ import { Axes, Grid, ViewCube, Viewer } from '@toolpath/viewer'
14
35
  import { EnginePart } from '@toolpath/viewer/engine'
15
- ;<Suspense fallback={<p>Loading mesh…</p>}>
16
- <Viewer style={{ height: 500 }}>
17
- <EnginePart report={report} selection={selection} onPick={(pick) => setPick(pick)} />
18
- <Grid />
19
- <Axes />
20
- <ViewCube />
21
- </Viewer>
22
- </Suspense>
36
+ import type { PartResponse } from '@toolpath/api'
37
+
38
+ export function PartViewer({ report }: { report: PartResponse }) {
39
+ const [selection, setSelection] = useState<string[]>([])
40
+
41
+ return (
42
+ <ErrorBoundary fallback={<p>This part could not be displayed.</p>}>
43
+ <Suspense fallback={<p>Loading part…</p>}>
44
+ <Viewer style={{ height: 500 }} onPointerMissed={() => setSelection([])}>
45
+ <EnginePart
46
+ report={report}
47
+ selection={selection}
48
+ onPick={(pick) => setSelection(pick.best ? [pick.best] : [])}
49
+ />
50
+ <Grid />
51
+ <Axes />
52
+ <ViewCube />
53
+ </Viewer>
54
+ </Suspense>
55
+ </ErrorBoundary>
56
+ )
57
+ }
58
+ ```
59
+
60
+ This renders the part with a grid, axes, and a view cube. Clicking a surface selects its most
61
+ likely feature and paints it orange. Clicking empty space clears the selection.
62
+
63
+ What each wrapper is for:
64
+
65
+ - **`<Suspense>`**: `EnginePart` downloads the part's mesh and shows the fallback until it
66
+ arrives.
67
+ - **Error boundary**: an expired mesh URL or a bad report is thrown as an error, not shown as an
68
+ empty viewer. Use any error boundary you like, such as
69
+ [`react-error-boundary`](https://github.com/bvaughn/react-error-boundary). See [Errors](#errors).
70
+ - **Height**: `<Viewer>` fills its parent, so give it or its parent a height.
71
+
72
+ ## How it works
73
+
74
+ ```text
75
+ <Viewer> canvas, camera, lights, mouse controls
76
+ ├─ <EnginePart> validates the report, loads the mesh, then renders <PartMesh>
77
+ │ └─ <PartMesh> draws the part and handles hover, click, colours, section cuts
78
+ ├─ <DirectionArrows> arrows for the directions the part can be machined from
79
+ ├─ <SectionTool> optional: click a face or a plane to cut the part open
80
+ ├─ <MeasureTool> optional: click two points for a distance, three for an angle
81
+ ├─ <Grid> <Axes> reference geometry, sized to the part
82
+ └─ <ViewCube> the clickable orientation cube in the corner
83
+ ```
84
+
85
+ 1. **`<Viewer>`** sets up the 3D scene. Put everything else inside it. It frames the camera on
86
+ whatever you put inside once that content appears. It only redraws when something changes, so
87
+ a viewer that isn't being used costs nothing.
88
+ 2. **`<EnginePart>`** checks the report, downloads the mesh (GLB first, STL if there is no GLB),
89
+ and makes sure the mesh matches the report. Meshes are cached, so showing the same part twice
90
+ downloads it once.
91
+ 3. **`<PartMesh>`** draws the part as one mesh. The Engine splits the part's surface into
92
+ **regions** (faces) and groups regions into **features** (holes, pockets, walls, and so on).
93
+ When the pointer is over the part, `PartMesh` works out which region it is on and which
94
+ features own that region.
95
+ 4. **You own the state.** The viewer tells you what was clicked (`onPick`), and you tell it what to
96
+ highlight (`selection`, `highlights`, …). It never changes your selection by itself.
97
+
98
+ Parts are in **millimetres** with **Z pointing up**. The camera uses the same convention.
99
+
100
+ ### A click usually matches several features
101
+
102
+ The same face is usually owned by **5–8 features at once**, even on a plain cube: it can be a
103
+ `face` when cut from one direction, a `wall` when cut from another, and part of every `profile`
104
+ around it. So a click gives you every match, and you decide which to use:
105
+
106
+ | Field | What it contains |
107
+ | ---------------- | ------------------------------------------------------------------- |
108
+ | `pick.best` | The most likely feature, or `null` |
109
+ | `pick.ranked` | Every matching feature, most likely first |
110
+ | `pick.owners` | Every matching feature, in report order |
111
+ | `pick.region` | The index of the face that was clicked |
112
+ | `pick.point` | Where the click hit, as `[x, y, z]` |
113
+ | `pick.normal` | The direction the clicked surface faces, as `[x, y, z]` |
114
+ | `pick.modifiers` | `{ alt, ctrl, meta, shift, secondary }`: keys held, and right-click |
115
+ | `pick.doubled` | `true` if this click was the second half of a double-click |
116
+
117
+ The ranking puts specific features first: holes, then pockets and bosses, then chamfers and
118
+ fillets, then walls and faces, then profiles. Among features of the same kind, the one whose
119
+ machining direction points most toward the camera wins.
120
+
121
+ ## Components
122
+
123
+ ### `<Viewer>`
124
+
125
+ The canvas that holds everything. Import it from `@toolpath/viewer`.
126
+
127
+ | Prop | Default | What it does |
128
+ | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
129
+ | `projection` | `'orthographic'` | `'orthographic'` keeps parallel lines parallel. `'perspective'` gives depth, which helps with deep pockets. |
130
+ | `controls` | `'toolpath'` | Mouse mapping. See [Mouse controls](#mouse-controls). |
131
+ | `zoomTo` | `'cursor'` | Zoom toward the pointer, or toward `'centre'` (often easier on a trackpad). |
132
+ | `freeOrbit` | `true` | Let the view keep rotating past straight-up and straight-down. |
133
+ | `retargetOnDoubleClick` | `true` | Double-click the part to rotate around that point. |
134
+ | `recentreOnDoubleClick` | `true` | Double-click the middle mouse button to fit the whole part again. |
135
+ | `showOrbitTarget` | `false` | Show a small marker at the point the view rotates around. |
136
+ | `theme` | — | Override lighting and colours. See [Theming](#theming). |
137
+ | `onPointerMissed` | — | Called when a left-click hits nothing. Clear your selection here. |
138
+ | `className`, `style` | — | Applied to the wrapping `<div>`. It fills its parent by default. |
139
+ | `ref` | — | A `ViewerHandle` for moving the camera. See [Camera buttons](#camera-buttons). |
140
+
141
+ Changing `projection` rebuilds the canvas and returns the camera to its starting view.
142
+
143
+ The canvas is transparent. To set a background colour, style the wrapper or its parent.
144
+
145
+ #### Mouse controls
146
+
147
+ | Action | `controls="toolpath"` (default) | `controls="fusion"` (like Fusion 360) |
148
+ | --------------------- | ------------------------------- | ------------------------------------------ |
149
+ | Rotate | Left-drag | Shift + middle-drag, or Shift + scroll |
150
+ | Pan | Right-drag or middle-drag | Middle-drag, or scroll / two-finger scroll |
151
+ | Zoom | Scroll wheel | Trackpad pinch |
152
+ | Rotate around a point | Double-click the part | Double-click the part |
153
+ | Fit whole part | Double middle-click | Double middle-click |
154
+
155
+ In `fusion` mode, left-drag doesn't move the camera, and the scroll wheel pans instead of zooming.
156
+ On touch screens, one finger rotates and two fingers pinch and pan.
157
+
158
+ Dragging never selects anything. A click only counts if the pointer barely moved.
159
+
160
+ ### `<EnginePart>`
161
+
162
+ Draws a Toolpath Engine part report. Import it from `@toolpath/viewer/engine`.
163
+
164
+ It takes one prop of its own, `report`: the `PartResponse` from `@toolpath/api`, unchanged. All
165
+ other props are the same as `<PartMesh>`'s, except `model` and `geometry`, which it creates for
166
+ you.
167
+
168
+ Mesh URLs in a report expire **15 minutes** after the report was created. If a part may be shown
169
+ later than that, fetch a fresh report first. Don't save the URLs.
170
+
171
+ ### `<PartMesh>`
172
+
173
+ Draws the part and handles clicks. You only use it directly when you
174
+ [don't use the Engine](#using-it-without-the-engine). All of these props also work on
175
+ `<EnginePart>`.
176
+
177
+ **Colours**
178
+
179
+ | Prop | Type | What it does |
180
+ | ------------------- | ---------------------- | -------------------------------------------------------------- |
181
+ | `selection` | `string[]` | Feature tags to highlight as selected (orange). |
182
+ | `highlights` | `FeatureHighlight[]` | Your own colour per feature, e.g. difficulty or setup. |
183
+ | `regionHighlights` | `RegionHighlight[]` | Your own colour per face. |
184
+ | `candidates` | `string[]` | Other possible matches, faintly tinted by machining direction. |
185
+ | `pickedRegions` | `number[]` | Faces to mark as just clicked. |
186
+ | `hoveredFeatureIds` | `string[]` | Features to show as hovered, e.g. when hovering a list row. |
187
+ | `showEdges` | `boolean` (`true`) | Draw outlines between faces. |
188
+ | `theme` | `Partial<ViewerTheme>` | Override the part's colours. |
189
+
190
+ **Interaction**
191
+
192
+ | Prop | Type | What it does |
193
+ | ----------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------- |
194
+ | `onPick` | `(pick: PartPick) => void` | Left- or right-click on the part. |
195
+ | `onHover` | `(pick: PartPick \| null) => void` | The pointer moved onto a different face, or off the part (`null`). |
196
+ | `activeDirection` | `number \| null` | Only match features machined from this direction (an index into `candidateDirections`). |
197
+ | `focusFeature` | `string \| null` | Zoom to this feature. The camera moves each time the value changes. |
198
+ | `section` | `SectionOptions` | Cut the part open. Omit it to follow the viewer's own cut. See [Section view](#section-view). |
199
+ | `onSectionChange` | `(state: SectionState) => void` | Called when the cut moves or goes away. With `section`, passing it also shows the drag handle. |
200
+ | `onAdjacency` | `(map) => void` | Called once per mesh with which faces touch which. |
201
+
202
+ Hovering over the part is handled for you. You only need `onHover` if you want to show the hovered
203
+ feature elsewhere in your UI.
204
+
205
+ ### `<ViewCube>`
206
+
207
+ The orientation cube in the corner. It has 26 clickable areas: 6 faces, 12 edges, and 8 corners,
208
+ so you can get an isometric view with one click.
209
+
210
+ ```tsx
211
+ <ViewCube alignment="bottom-right" margin={[60, 60]} onViewChange={(view) => track(view)} />
212
+ ```
213
+
214
+ | Prop | Default | |
215
+ | -------------- | ------------- | -------------------------------------------------------------- |
216
+ | `alignment` | `'top-right'` | `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'` |
217
+ | `margin` | `[80, 80]` | Distance from the corner, in pixels |
218
+ | `onViewChange` | — | Called with the view name, e.g. `'top-front-right'` |
219
+ | `theme` | — | Cube colours |
220
+
221
+ ### `<DirectionArrows>`
222
+
223
+ An arrow for each direction the part can be machined from. Each direction has its own colour, and
224
+ features machined from that direction use the same colour. The arrows point **at** the part,
225
+ because that is the direction the tool comes from.
226
+
227
+ ```tsx
228
+ <DirectionArrows
229
+ directions={model.candidateDirections}
230
+ activeDirection={direction}
231
+ onPickDirection={(index) => setDirection(index === direction ? null : index)}
232
+ />
233
+ ```
234
+
235
+ | Prop | What it does |
236
+ | ------------------ | ------------------------------------------------------------------------------------ |
237
+ | `directions` | The report's `candidateDirections`. |
238
+ | `activeDirection` | Show only this arrow. |
239
+ | `shownDirection` | Which arrows to show: `null` for all, an index, a list of indices, or `-1` for none. |
240
+ | `previewDirection` | An extra arrow drawn on top of the part, e.g. while the user aims a new direction. |
241
+ | `namedDirections` | Extra directions that aren't candidates, each with its own colour. |
242
+ | `onPickDirection` | Called with an arrow's index when it is clicked. |
243
+ | `visible` | Hide all arrows. |
244
+
245
+ Clicking an arrow does not trigger `onPointerMissed`, so it won't clear your selection.
246
+
247
+ With the Engine, get the directions from the model:
248
+ `const model = useMemo(() => normalizePartReport(report), [report])`.
249
+
250
+ ### `<SectionTool>`
251
+
252
+ Cut the part open from inside the viewport. Optional: mount it next to the part when the user
253
+ enters section mode, and unmount it when they leave.
254
+
255
+ ```tsx
256
+ {
257
+ sectioning ? <SectionTool /> : null
258
+ }
259
+ ```
260
+
261
+ While it is up, hovering the part previews a cut through the face under the pointer and a click
262
+ places it; three coloured planes behind the part cut along an axis instead. Once there is a cut,
263
+ the part's own arrow drags it, an outlined sheet shows the cutting plane, and Escape clears it.
264
+
265
+ | Prop | What it does |
266
+ | ------- | ---------------------------------------------------------------------- |
267
+ | `theme` | Override its colours — `sectionOutline` for the sheet and the preview. |
268
+
269
+ The cut it places is the viewer's own, and `<PartMesh>` follows it when given no `section` prop.
270
+ While the tool is mounted the part reports no hovers or picks. Details and the controlled
271
+ alternative are under [Section view](#section-view).
272
+
273
+ ### `<MeasureTool>`
274
+
275
+ Measure the part from inside the viewport. Optional: mount it next to the part when the user
276
+ enters measure mode, and unmount it when they leave.
277
+
278
+ ```tsx
279
+ {
280
+ measuring ? <MeasureTool mode="distance" onChange={setMeasurements} /> : null
281
+ }
282
+ ```
283
+
284
+ The pointer snaps to what is under it — a corner first, then an edge's midpoint, then the edge,
285
+ then the face — and a marker shows where the click will land. Two clicks measure a distance, three
286
+ an angle (one arm's end, the vertex, the other arm's end), with a live readout between clicks.
287
+ Finished measurements stay drawn over the part, each with a label, until Delete removes the last
288
+ one or the tool is unmounted. Escape drops the points of one in progress, and Shift holds the next
289
+ point to an axis through the last.
290
+
291
+ | Prop | What it does |
292
+ | ---------------- | --------------------------------------------------------------------------------------------- |
293
+ | `mode` | `'distance'` (default) or `'angle'`. Changing it drops any points already placed. |
294
+ | `measurements` | Own the list. Omit it and the tool keeps its own. |
295
+ | `onChange` | The list changed: a measurement finished, or Delete removed the last one. |
296
+ | `showDeltas` | Show a distance's X, Y and Z parts as dashed legs in the axis colours. On by default. |
297
+ | `format` | Write a length. Millimetres to two places by default; pass your own to show inches. |
298
+ | `labelClassName` | Added to every label, beside `toolpath-measure-label`. |
299
+ | `theme` | Override its colours — `measure` for lines and markers, `measureSnap` for the snap indicator. |
300
+
301
+ Labels are DOM elements laid over the canvas and come **unstyled**: the tool is headless, and
302
+ what a label looks like is your stylesheet's, through the `toolpath-measure-label` class and the
303
+ data attributes on each one — see [Measuring](#measuring) for a starting point. While the tool is
304
+ mounted the part reports no hovers or picks, as with `<SectionTool>`.
305
+
306
+ ### `<Grid>` and `<Axes>`
307
+
308
+ Reference geometry. The camera ignores both when it frames the part.
309
+
310
+ - **`<Grid>`** sits under the part and sizes itself to it: 5 mm squares under a 50 mm cube, 50 mm
311
+ squares under a 900 mm plate. Props: `step` (square size in mm), `extent`, `color`,
312
+ `opacity` (default `0.35`).
313
+ - **`<Axes>`** draws X (red), Y (green), and Z (blue) from the origin. Prop: `size` (default `25`).
314
+
315
+ ## Recipes
316
+
317
+ ### Camera buttons
318
+
319
+ Use a ref to move the camera from buttons outside the viewer:
320
+
321
+ ```tsx
322
+ import { useRef } from 'react'
323
+ import { Viewer, type ViewerHandle } from '@toolpath/viewer'
324
+
325
+ const viewer = useRef<ViewerHandle>(null)
326
+
327
+ <button onClick={() => viewer.current?.fit()}>Fit</button>
328
+ <button onClick={() => viewer.current?.reset()}>Reset</button>
329
+ <button onClick={() => viewer.current?.setView('top')}>Top</button>
330
+ <button onClick={() => viewer.current?.setView('isometric')}>Iso</button>
331
+
332
+ <Viewer ref={viewer}>…</Viewer>
23
333
  ```
24
334
 
25
- `EnginePart` takes a `PartResponse` exactly as `@toolpath/api` returns it and validates it:
26
- a malformed report throws `PartReportFormatError` carrying every problem it found, and one from a
27
- kernel older than `0.3.0` — before `regions[]` and `featureTag` existed — throws
28
- `UnsupportedKernelVersionError`. It fetches `meshGlbUrl`, falls back to `meshStlUrl`, and refuses a
29
- mesh whose triangle count does not match the report, because region ranges index that buffer
30
- directly and a mismatch would quietly highlight the wrong surface.
335
+ | Method | What it does |
336
+ | --------------------------- | ------------------------------------------------------------------------------ |
337
+ | `fit()` | Fit the whole part, keeping the current angle. |
338
+ | `reset()` | Go back to the starting view. |
339
+ | `setView(name)` | `'top'`, `'bottom'`, `'front'`, `'back'`, `'left'`, `'right'`, `'isometric'` |
340
+ | `setViewDirection({x,y,z})` | Look at the part from any direction (a vector pointing toward the camera). |
341
+ | `frameBox(box)` | Zoom to a `THREE.Box3`, keeping the current angle. |
342
+ | `setSection(options)` | Set or clear (`null`) the viewer's own cut. See [Section view](#section-view). |
31
343
 
32
- Wrap the tree in an error boundary: an expired mesh URL, a malformed report, and an old kernel all
33
- arrive as thrown errors rather than as empty states. The viewer does not parse STEP — the Engine
34
- analyzes STEP and emits tessellated GLB/STL for display.
344
+ Components rendered **inside** `<Viewer>` can get the same methods with `useViewerControls()`.
35
345
 
36
- ## A click means several things at once
346
+ ### Selecting from a list
37
347
 
38
- A region on the mesh is owned by **five to eight features at once** — measured on a cube, not
39
- estimated. The same physical face is a `face` cut from one direction and a `wall` from others, and
40
- every direction's `profile` overlaps the surfaces it traces. Nothing can reduce that to one answer,
41
- so `onPick` hands over the whole set:
348
+ Keep the selection in your own state and share it between a feature list and the part. Hovering a
349
+ list row highlights the feature on the part, and clicking the row zooms to it:
42
350
 
43
351
  ```tsx
44
- <Viewer onPointerMissed={clearSelection}>
352
+ const [selected, setSelected] = useState<string | null>(null)
353
+ const [hovered, setHovered] = useState<string | null>(null)
354
+
355
+ <ul>
356
+ {model.features.map((feature) => (
357
+ <li
358
+ key={feature.tag}
359
+ onMouseEnter={() => setHovered(feature.tag)}
360
+ onMouseLeave={() => setHovered(null)}
361
+ onClick={() => setSelected(feature.tag)}
362
+ >
363
+ {feature.featureType}
364
+ </li>
365
+ ))}
366
+ </ul>
367
+
368
+ <Viewer onPointerMissed={() => setSelected(null)}>
45
369
  <EnginePart
46
370
  report={report}
47
- onPick={(pick) => {
48
- setCandidates(pick.ranked) // every reading, best first
49
- setFocused(focusForPick(pick, lastRegion, focused)) // clicking again walks them
50
- setLastRegion(pick.region)
51
- }}
371
+ selection={selected ? [selected] : []}
372
+ hoveredFeatureIds={hovered ? [hovered] : []}
373
+ focusFeature={selected}
374
+ onPick={(pick) => setSelected(pick.best)}
52
375
  />
53
376
  </Viewer>
54
377
  ```
55
378
 
56
- Putting the selection down belongs to `<Viewer onPointerMissed>` rather than to
57
- `onPick`: a mesh's own missed event fires whenever _that mesh_ was not hit,
58
- including when the click landed on a direction arrow or a section handle, so
59
- reporting it from the part made pressing an arrow clear the selection.
379
+ `feature.tag` is the feature's ID. Always use it to refer to a feature, never the feature's
380
+ position in the array.
60
381
 
61
- `ranked` orders the owners by type specificity — a hole beats the wall it is bored through, and a
62
- `profile` never wins the surface it traces — then by which reading faces the camera. `best` is the
63
- first of them. Pass `activeDirection` to scope a pick to one machining direction, which **filters**
64
- rather than reorders: a face that direction cannot reach picks to nothing, which is a real answer
65
- rather than a missed click.
382
+ ### Clicking the same face again to go through its matches
66
383
 
67
- ## Colouring the part
384
+ Clicking the same face repeatedly can step through its matching features, like most CAD tools do.
385
+ `focusForPick` handles this:
68
386
 
69
- A face can only be one colour, so the layers are painted weakest first and each overwrites what is
70
- under it:
387
+ ```tsx
388
+ const [focused, setFocused] = useState<string | null>(null)
389
+ const [lastRegion, setLastRegion] = useState<number | null>(null)
71
390
 
72
- | Layer | Weight | What it says |
73
- | ------------------- | ------ | --------------------------------------------- |
74
- | `highlights` | 0.7 | your own meaning — a difficulty band, a setup |
75
- | `regionHighlights` | 0.7 | the same, on named faces rather than features |
76
- | `candidates` | 0.4 | what a click could have meant, per direction |
77
- | `selection` | 1.0 | the features being read |
78
- | `hoveredFeatureIds` | 0.85 | a list row under the pointer |
79
- | the hovered face | 0.85 | what the pointer is on, tracked for you |
391
+ <EnginePart
392
+ report={report}
393
+ selection={focused ? [focused] : []}
394
+ onPick={(pick) => {
395
+ setFocused(focusForPick(pick, lastRegion, focused))
396
+ setLastRegion(pick.region)
397
+ }}
398
+ />
399
+ ```
80
400
 
81
- Highlighting is a texture write rather than a material change, so lighting every feature of a part
82
- costs one mesh, one material, and one draw call.
401
+ A click on a different face starts again from that face's best match. To show the other matches
402
+ faintly, pass `pick.ranked` as `candidates`.
83
403
 
84
- ## Sectioning
404
+ ### Selecting several features with a modifier key
405
+
406
+ The viewer tells you which keys were held and leaves the behaviour to you:
85
407
 
86
408
  ```tsx
409
+ onPick={(pick) => {
410
+ const tag = pick.best
411
+ if (!tag) return
412
+ if (pick.modifiers.shift) setSelection((current) => [...current, tag])
413
+ else setSelection([tag])
414
+ }}
415
+ ```
416
+
417
+ A double-click on the part also re-centres the view. The second click still calls `onPick`, with
418
+ `pick.doubled` set to `true`. Ignore those clicks if a double-click shouldn't change your selection.
419
+
420
+ ### Showing only one machining direction
421
+
422
+ Pass the same index to the arrows and the part. Clicks then only match features machined from that
423
+ direction. A face that can't be reached from that direction gives `pick.best === null`.
424
+
425
+ ```tsx
426
+ <EnginePart report={report} activeDirection={direction} onPick={onPick} />
427
+ <DirectionArrows
428
+ directions={model.candidateDirections}
429
+ activeDirection={direction}
430
+ onPickDirection={setDirection}
431
+ />
432
+ ```
433
+
434
+ To list features by direction, use `groupByDirection(model)`. To label a direction, use
435
+ `directionLabel(direction)`, which gives `+Z`, `−X`, or `0.707, 0, 0.707`. To colour a direction
436
+ the same way as its arrow, use `directionColor(index)`.
437
+
438
+ ### Custom colours
439
+
440
+ Use `highlights` for colours that mean something in your app, such as difficulty, setup, or cost.
441
+ Colours are hex numbers:
442
+
443
+ ```tsx
444
+ const DIFFICULTY = { easy: 0x22c55e, medium: 0xeab308, hard: 0xef4444 }
445
+
87
446
  <EnginePart
88
447
  report={report}
89
- section={{ enabled: true, normal: { x: 0, y: 0, z: 1 }, offset }}
448
+ highlights={features.map((f) => ({ tag: f.tag, color: DIFFICULTY[f.difficulty] }))}
449
+ selection={selection}
450
+ />
451
+ ```
452
+
453
+ A face shows only one colour. When several layers apply, the one lower in this list wins:
454
+
455
+ 1. `highlights` (your colours, per feature)
456
+ 2. `regionHighlights` (your colours, per face)
457
+ 3. `candidates` (faint, per direction)
458
+ 4. `selection` (orange)
459
+ 5. `pickedRegions`
460
+ 6. `hoveredFeatureIds`
461
+ 7. the face under the pointer
462
+
463
+ `weight` (`0`–`1`, default `0.7`) sets how strongly a highlight covers the part.
464
+
465
+ Changing colours is fast, even when every feature on a large part is highlighted.
466
+
467
+ ### Section view
468
+
469
+ The quickest way is `<SectionTool>`. Put it inside `<Viewer>` next to the part and give `PartMesh`
470
+ no `section` prop:
471
+
472
+ ```tsx
473
+ <Viewer ref={viewer}>
474
+ <EnginePart report={report} />
475
+ {sectioning ? <SectionTool /> : null}
476
+ </Viewer>
477
+ ```
478
+
479
+ Hovering the part previews a cut through the face under the pointer; clicking places it. Three
480
+ coloured planes stand behind the part — click one to cut along that axis, in from your side. Once
481
+ there is a cut, an arrow drags it, an outlined sheet shows the cutting plane, and Escape clears it.
482
+ The cut belongs to the viewer: `viewer.current.setSection(null)` clears it from a button outside
483
+ the canvas, `setSection({ enabled: true, normal, offset })` sets one, and `onSectionChange` on
484
+ `PartMesh` still reports every move.
485
+
486
+ While the tool is mounted, the part reports no hovers or picks — clicking a face cuts through it
487
+ without also selecting it. Unmount the tool, as above, and they come back.
488
+
489
+ To drive the cut yourself instead, pass `section`. `normal` points toward the half you keep.
490
+ `offset` goes from `0` (whole part) to `1` (fully cut away).
491
+
492
+ ```tsx
493
+ const [cutting, setCutting] = useState(false)
494
+ const [offset, setOffset] = useState(0.5)
495
+
496
+ <input type="checkbox" checked={cutting} onChange={(e) => setCutting(e.target.checked)} />
497
+ <input type="range" min={0} max={1} step={0.01} value={offset}
498
+ onChange={(e) => setOffset(Number(e.target.value))} />
499
+
500
+ <EnginePart
501
+ report={report}
502
+ section={{ enabled: cutting, normal: { x: 0, y: 0, z: 1 }, offset }}
90
503
  onSectionChange={(state) => setOffset(state.offset)}
91
504
  />
92
505
  ```
93
506
 
94
- `normal` points into the half that stays and `offset` runs 0 (whole) to 1 (gone). To cut from a
95
- surface instead, turn a pick into a placement with `sectionFromPick(pick)` and pass it as `plane`,
96
- moving it with `depth` in model units. The cut is capped rather than left hollow, and its arrow can
97
- be dragged; `onSectionChange` reports every move, including the drag's.
98
-
99
- ## Camera and controls
100
-
101
- Use a ref or `useViewerControls()` beneath a viewer to call `fit`, `reset`, `setView`, and
102
- `setViewDirection`. The part data is Z-up millimetres and the camera says so.
103
-
104
- - `controls="toolpath"` (default) — left-drag orbits, right-drag pans.
105
- - `controls="fusion"` — middle-drag and two-finger scroll pan, shift orbits, pinch zooms.
106
- - `projection` — `"orthographic"` by default, because it is what a machinist reads a part in:
107
- parallel edges stay parallel, so two features the same size measure the same size wherever they
108
- sit. `"perspective"` is the better answer for reading a deep pocket as depth.
109
- - `freeOrbit` (default on) — orbiting past a pole keeps going instead of sticking there.
110
- - `retargetOnDoubleClick` (default on) — a double **left** click on the part orbits about what was
111
- clicked from then on. It moves to the middle of the view at the same size and angle, and the move
112
- is immediate — the damping these controls ship with settles a transition inside one frame. Turn
113
- on `showOrbitTarget` if the pivot moving needs to be visible. The click is still a click: its pick
114
- carries `doubled: true`, so what a second click means to the part is yours to decide. Double
115
- **middle** click re-frames the whole part, which is the way back.
116
- - `showOrbitTarget` (default **off**) — a dot inside a ring at the point the view turns and zooms
117
- about. Up while a gesture is running, flashed when the pivot moves on its own, then faded. It is
118
- what makes zooming to the cursor legible: the pivot moves with it, and nothing else reports that.
119
-
120
- `<ViewCube>` offers all 26 standard views: six faces, twelve edge chamfers, and eight corners, so
121
- an isometric is a click rather than a drag. `<Grid>` sizes itself from the part — 5 mm cells under a
122
- 50 mm cube, 50 mm under a 900 mm plate — and sits on the part's underside. `<DirectionArrows>`
123
- draws the directions a part can be held in, each in its own colour, aimed inward because a
124
- machining direction is the direction the tool comes from.
125
-
126
- ## Driving it without the Engine
127
-
128
- `PartMesh` takes a `PartModel` and a `BufferGeometry` directly, so the viewer can be driven from a
129
- file, a fixture, or geometry you built yourself — see `examples/react-viewer`. The mesh must be
130
- non-indexed: highlighting is a per-vertex region attribute, and a vertex shared between two regions
131
- has no single value to carry.
507
+ The cut surface is filled in with a hatch and outlined, so the part doesn't look hollow and the cut
508
+ reads as a cut. Passing `onSectionChange` also shows an arrow handle users can drag. The handler is
509
+ called on every drag and only when the cut actually changes, so it's safe to store the value in
510
+ state; a cut going away is reported once, with `enabled: false`.
511
+
512
+ **Cutting at a clicked surface.** `sectionFromPick` places the cut at the surface the user clicked.
513
+ `depth` moves it into the part, in millimetres:
514
+
515
+ ```tsx
516
+ const [plane, setPlane] = useState<SectionPlacement | null>(null)
517
+
518
+ <EnginePart
519
+ report={report}
520
+ section={{ enabled: plane !== null, plane, depth: 1 }}
521
+ onPick={({ point: [x, y, z], normal: [nx, ny, nz] }) =>
522
+ setPlane(sectionFromPick({ point: { x, y, z }, normal: { x: nx, y: ny, z: nz } }))
523
+ }
524
+ />
525
+ ```
526
+
527
+ ### Measuring
528
+
529
+ Mount `<MeasureTool>` next to the part. It needs nothing else:
530
+
531
+ ```tsx
532
+ const [measuring, setMeasuring] = useState(false)
533
+ const [mode, setMode] = useState<MeasureMode>('distance')
534
+ const [measurements, setMeasurements] = useState<readonly Measurement[]>([])
535
+
536
+ <Viewer>
537
+ <EnginePart report={report} />
538
+ {measuring ? <MeasureTool mode={mode} onChange={setMeasurements} /> : null}
539
+ </Viewer>
540
+ ```
541
+
542
+ Hover the part and a marker shows where a click will land: a corner, an edge's midpoint, a point
543
+ on an edge, or a point on the face, in that order of preference. It snaps to the edges the part
544
+ draws — the boundaries between analytic surfaces — so a corner is a corner of the part, not of a
545
+ mesh triangle. Click twice for a distance or three times for an angle. The line follows the pointer
546
+ between clicks with a live readout.
547
+
548
+ A distance is labelled with its length and, unless it already runs along an axis, broken into its
549
+ X, Y and Z parts as dashed legs in the axis colours with a label each. `showDeltas={false}` turns
550
+ the legs off. An angle draws its two arms and an arc, labelled in degrees.
551
+
552
+ `onChange` reports the list whenever it changes. Each entry is a `Measurement`: `kind`, an `id`,
553
+ and its `points` — two for a distance, three for an angle with the vertex in the middle. To own the
554
+ list, pass `measurements` too; to clear it, unmount the tool or pass `[]`. `measurementLabel(m)`
555
+ writes an entry the way the tool does, and `distanceBetween`, `deltaBetween` and `angleAt` are the
556
+ arithmetic behind it.
557
+
558
+ **Styling the labels.** Every label is a `<div class="toolpath-measure-label">` with
559
+ `data-measure-label` set to `distance`, `angle`, `delta` (one leg of a distance's X, Y, Z breakdown,
560
+ with `data-axis`) or `live` (the readout that follows the pointer), and `data-measurement-id` on
561
+ anything belonging to a finished measurement. Nothing else is set: no font, no colour, no
562
+ background. `labelClassName` adds your own class, for Tailwind or a CSS module. A stylesheet to
563
+ start from, which is what the example uses:
564
+
565
+ ```css
566
+ .toolpath-measure-label {
567
+ white-space: nowrap;
568
+ font:
569
+ 600 12px/1.2 system-ui,
570
+ sans-serif;
571
+ color: #fff;
572
+ background: rgba(20, 23, 33, 0.85);
573
+ border: 1px solid #4f8ef7;
574
+ border-radius: 4px;
575
+ padding: 2px 6px;
576
+ transform: translateY(-16px);
577
+ }
578
+ .toolpath-measure-label[data-measure-label='delta'] {
579
+ font-size: 11px;
580
+ transform: translateY(14px);
581
+ }
582
+ .toolpath-measure-label[data-axis='x'] {
583
+ border-color: #ff6b6b;
584
+ }
585
+ ```
586
+
587
+ Labels take no pointer events, so one lying over the part never takes a click meant for the face
588
+ under it. The lines, markers and arc are drawn on the canvas and themed through `measure`.
589
+
590
+ Lengths are written in millimetres by default. The part is in millimetres and the viewer does not
591
+ convert, so pass `format` to show anything else:
592
+
593
+ ```tsx
594
+ <MeasureTool format={(mm) => `${(mm / MM_PER_INCH).toFixed(3)} in`} />
595
+ ```
596
+
597
+ Hold Shift to hold the next point to the X, Y or Z line through the last one — whichever axis the
598
+ pointer is furthest along — so a length along an edge is measured square rather than slightly across
599
+ it. The line wears that axis's colour while it is held, and the finished distance has no legs to
600
+ show. Delete or Backspace removes the last measurement, unless focus is in a field. Escape drops the
601
+ points of a measurement in progress. While the tool is mounted the part reports no hovers or picks;
602
+ unmounting it hands the pointer back.
603
+
604
+ ### Theming
605
+
606
+ ```tsx
607
+ <Viewer theme={{ ambientIntensity: 1.5 }}>
608
+ <EnginePart report={report} theme={{ part: 0xe5e7eb, highlight: 0x2563eb, hover: 0x93c5fd }} />
609
+ <ViewCube theme={{ cube: 0x1f2937, cubeLabel: 0xffffff }} />
610
+ </Viewer>
611
+ ```
612
+
613
+ Colours are hex numbers. Lighting goes on `<Viewer>`, part colours go on the part, and cube colours
614
+ go on the cube. See `DEFAULT_THEME` for every key. The default part colours are tuned for the
615
+ default lighting, so if you change one, check the other.
616
+
617
+ The section cut is themed on the part too: `sectionCap` and `sectionHatch` are the cap's fill and
618
+ hatch lines, `sectionHandle` the drag arrow, and `sectionOutline` the cutting-plane sheet and
619
+ preview that `<SectionTool>` draws. `measure` and `measureSnap` are `<MeasureTool>`'s lines and its
620
+ snap indicator; a distance's X, Y, Z legs take `AXIS_COLORS`, the same hues as the section tool's
621
+ global planes.
622
+
623
+ `HIGHLIGHT_COLORS` has the standard selection colours (`default`, `toolIssue`, `geometryIssue`).
624
+ `DIRECTION_COLORS` has the 9 direction colours, which repeat for parts with more than 9 directions.
625
+
626
+ ### Drawing your own objects in the scene
627
+
628
+ Anything React Three Fiber can draw can go inside `<Viewer>`, and the camera includes it when it
629
+ frames the scene. To leave an object out of framing, as `<Grid>` does, set
630
+ `userData={{ [EXCLUDE_FROM_FRAME]: true }}`.
631
+
632
+ Use `useContentBox()` inside the viewer to get the part's bounding box, for example to size your own
633
+ object.
634
+
635
+ ## Errors
636
+
637
+ These are all thrown, so an error boundary catches them.
638
+
639
+ | Error | Import from | When |
640
+ | ------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
641
+ | `PartReportFormatError` | `@toolpath/viewer` | The report is malformed. `error.issues` lists every problem. |
642
+ | `UnsupportedKernelVersionError` | `@toolpath/viewer` | The report is from an Engine kernel older than `0.3.0`. Re-analyze the part. |
643
+ | `PartMeshError` | `@toolpath/viewer/engine` | The mesh download failed (usually an expired URL: fetch the report again), or the mesh doesn't match the report. |
644
+ | `AggregateError` | — | Both the GLB and the STL failed. `error.errors` has one error per file. |
645
+
646
+ ```tsx
647
+ import { PartReportFormatError, UnsupportedKernelVersionError } from '@toolpath/viewer'
648
+
649
+ function PartError({ error }: { error: Error }) {
650
+ if (error instanceof UnsupportedKernelVersionError) return <p>Re-analyze this part to view it.</p>
651
+ if (error instanceof PartReportFormatError)
652
+ return <p>Invalid report: {error.issues.join(', ')}</p>
653
+ return <p>Could not load this part. Refresh to try again.</p>
654
+ }
655
+ ```
656
+
657
+ The viewer does not read STEP files. The Engine analyzes STEP files and produces the mesh the
658
+ viewer displays.
659
+
660
+ ## Working with the Toolpath API
661
+
662
+ Use [`@toolpath/api`](https://www.npmjs.com/package/@toolpath/api) to get parts. Call it **from your
663
+ server**, so your API key stays private, and pass the result to the browser.
664
+
665
+ The API can give you two things to show:
666
+
667
+ - **An analyzed part** (`getPart`): faces and features. Use it with `<EnginePart>`.
668
+ - **A display mesh** (`getPartMesh`): faces only, but much faster, because the part isn't
669
+ analyzed. Use it with `<PartMesh>`.
670
+
671
+ Both need a part whose CAD file is already uploaded. Their mesh URLs expire after 15 minutes, so
672
+ don't cache the responses.
673
+
674
+ ### Analyzed part
675
+
676
+ ```ts
677
+ // server
678
+ import { createToolpathClient } from '@toolpath/api'
679
+
680
+ const api = createToolpathClient({ apiKey: process.env.TOOLPATH_API_KEY! })
681
+
682
+ export const getReport = (partId: string) => api.parts.getPart({ id: partId })
683
+ ```
684
+
685
+ ```tsx
686
+ // browser
687
+ <Viewer>
688
+ <EnginePart report={report} />
689
+ </Viewer>
690
+ ```
691
+
692
+ ### Display mesh
693
+
694
+ On the server, start a mesh job, wait for it, then get the mesh:
695
+
696
+ ```ts
697
+ // server
698
+ export async function getMesh(partId: string) {
699
+ const { jobId } = await api.parts.createPartMesh({ id: partId })
700
+
701
+ let job = await api.jobs.getJob({ id: jobId })
702
+ while (job.status === 'queued' || job.status === 'running') {
703
+ await new Promise((resolve) => setTimeout(resolve, 1000))
704
+ job = await api.jobs.getJob({ id: jobId })
705
+ }
706
+ if (job.status === 'failed') throw new Error(job.error ?? 'Meshing failed')
707
+
708
+ return api.parts.getPartMesh({ id: partId, jobId })
709
+ }
710
+ ```
711
+
712
+ If the part already has a display mesh, skip the job and call `api.parts.getPartMesh({ id })`.
713
+
714
+ In the browser, turn the response into a model. `faceTriangleCounts` says how many triangles each
715
+ face has, so each count becomes one face:
716
+
717
+ ```tsx
718
+ // browser
719
+ import { useEffect, useMemo, useState } from 'react'
720
+ import type { BufferGeometry } from 'three'
721
+ import type { PartMeshResponse } from '@toolpath/api'
722
+ import { PartMesh, Viewer, buildRegionIndex, type PartModel } from '@toolpath/viewer'
723
+ import { loadPartMesh } from '@toolpath/viewer/engine'
724
+
725
+ function toModel(mesh: PartMeshResponse): PartModel {
726
+ let start = 0
727
+ const regions = (mesh.faceTriangleCounts ?? [mesh.meshTriangleCount]).map((count, idx) => {
728
+ const triangles = { start, end: start + count }
729
+ start += count
730
+ return { idx, splitOrigin: idx, shapeKind: 'Unknown', area: 0, triangles }
731
+ })
732
+ const triangleCount = mesh.meshTriangleCount
733
+
734
+ return {
735
+ partId: mesh.partId,
736
+ kernelVersion: '',
737
+ features: [],
738
+ regions,
739
+ candidateDirections: [],
740
+ mesh: {
741
+ pointCount: mesh.meshPointCount,
742
+ triangleCount,
743
+ glbUrl: mesh.meshGlbUrl,
744
+ stlUrl: null,
745
+ thumbnailUrl: null,
746
+ },
747
+ regionIndex: buildRegionIndex({ regions, features: [], triangleCount }),
748
+ warnings: [],
749
+ }
750
+ }
751
+
752
+ export function MeshViewer({ mesh }: { mesh: PartMeshResponse }) {
753
+ const model = useMemo(() => toModel(mesh), [mesh])
754
+ const [geometry, setGeometry] = useState<BufferGeometry | null>(null)
755
+
756
+ useEffect(() => {
757
+ loadPartMesh(model.mesh).then(setGeometry)
758
+ }, [model])
759
+
760
+ if (!geometry) return <p>Loading…</p>
761
+
762
+ return (
763
+ <Viewer style={{ height: 500 }}>
764
+ <PartMesh model={model} geometry={geometry} onPick={(pick) => console.log(pick.region)} />
765
+ </Viewer>
766
+ )
767
+ }
768
+ ```
769
+
770
+ In a real app, also handle a failed download and call `geometry.dispose()` when you're done.
771
+
772
+ A display mesh has no features, so a click gives you only the face (`pick.region`), and
773
+ `pick.best` is always `null`. Use `pickedRegions` and `regionHighlights` to colour faces. Don't pass
774
+ a display mesh to `<EnginePart>`: its faces are numbered differently from an analyzed part's.
775
+
776
+ ## Using it without the Engine
777
+
778
+ `<PartMesh>` takes a `PartModel` (the part's faces and features) and a three.js `BufferGeometry`
779
+ (the mesh). Use it to show a local file, a test fixture, or geometry you created yourself.
780
+
781
+ ```tsx
782
+ import * as THREE from 'three'
783
+ import { PartMesh, Viewer, buildRegionIndex, type PartModel } from '@toolpath/viewer'
784
+
785
+ // A 20 mm cube. Each face is a region made of 2 triangles.
786
+ const geometry = new THREE.BoxGeometry(20, 20, 20).toNonIndexed()
787
+
788
+ const regions = [0, 1, 2, 3, 4, 5].map((idx) => ({
789
+ idx,
790
+ splitOrigin: idx,
791
+ shapeKind: 'Plane',
792
+ area: 400,
793
+ triangles: { start: idx * 2, end: idx * 2 + 2 }, // [start, end)
794
+ }))
795
+
796
+ const features = [
797
+ {
798
+ tag: 'top',
799
+ featureType: 'face',
800
+ machiningDirection: { x: 0, y: 0, z: 1 },
801
+ axis: null,
802
+ regionIdxs: [4],
803
+ },
804
+ {
805
+ tag: 'sides',
806
+ featureType: 'wall',
807
+ machiningDirection: { x: 0, y: 0, z: 1 },
808
+ axis: null,
809
+ regionIdxs: [0, 1, 2, 3],
810
+ },
811
+ ]
812
+
813
+ const model: PartModel = {
814
+ partId: 'cube',
815
+ kernelVersion: '0.3.0',
816
+ features,
817
+ regions,
818
+ candidateDirections: [{ x: 0, y: 0, z: 1 }],
819
+ mesh: { pointCount: 36, triangleCount: 12, glbUrl: null, stlUrl: null, thumbnailUrl: null },
820
+ regionIndex: buildRegionIndex({ regions, features, triangleCount: 12 }),
821
+ warnings: [],
822
+ }
823
+
824
+ export const Cube = () => (
825
+ <Viewer style={{ height: 400 }}>
826
+ <PartMesh model={model} geometry={geometry} onPick={(pick) => console.log(pick.ranked)} />
827
+ </Viewer>
828
+ )
829
+ ```
830
+
831
+ Requirements:
832
+
833
+ - **The mesh must be non-indexed** (call `.toNonIndexed()`). Each vertex can belong to only one
834
+ face.
835
+ - **Regions must cover every triangle, with no gaps or overlaps.** `buildRegionIndex` throws if
836
+ they don't.
837
+ - **Create the geometry once** (with `useMemo` or at module level). `PartMesh` doesn't dispose of
838
+ it, so you're responsible for that.
839
+
840
+ If you have a report and want to load the mesh yourself, for example through your own backend,
841
+ use the functions in `@toolpath/viewer/engine`:
842
+
843
+ ```ts
844
+ import { loadPartMesh, normalizePartReport, smoothRegionNormals } from '@toolpath/viewer/engine'
845
+
846
+ const model = normalizePartReport(report) // throws on a bad report
847
+ const geometry = await loadPartMesh(model.mesh, { fetch: myFetch, signal })
848
+ smoothRegionNormals(geometry, model.regions) // curved faces look smooth, edges stay sharp
849
+ ```
850
+
851
+ [`examples/react-viewer`](https://github.com/toolpath/ui-packages/tree/main/examples/react-viewer)
852
+ is a complete app built this way, with no API key needed.
853
+
854
+ ## Reference
855
+
856
+ ### Entry points
857
+
858
+ | Import | Contents |
859
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
860
+ | `@toolpath/viewer` | All components, hooks, types, and helpers. Also re-exports `EnginePart`, `normalizePartReport`, and `smoothRegionNormals`. |
861
+ | `@toolpath/viewer/engine` | Loading Engine reports and meshes: `EnginePart`, `loadPartMesh`, `loadPartGeometry`, `parsePartGeometry`, `PartMeshError`, `engineGeometryCache`, `createEngineGeometryCache`, `MIN_KERNEL_VERSION`. |
862
+
863
+ ### Hooks
864
+
865
+ | Hook | Use inside `<Viewer>` to… |
866
+ | --------------------- | -------------------------------------------------------------------------------- |
867
+ | `useViewerControls()` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. |
868
+ | `useSectionStore()` | Read, set, or subscribe to the viewer's own cut. |
869
+ | `useContentBox()` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). |
870
+ | `useTapGuard()` | Check whether a pointer event was a click and not a drag. |
871
+
872
+ ### Helpers
873
+
874
+ | Helper | What it does |
875
+ | -------------------------------------------------------- | ----------------------------------------------------------- |
876
+ | `normalizePartReport(report)` | Checks an API report and converts it to a `PartModel`. |
877
+ | `buildRegionIndex({ regions, features, triangleCount })` | Builds the lookups between triangles, faces, and features. |
878
+ | `model.regionIndex.featuresForRegion(i)` | The features that own a face. |
879
+ | `model.regionIndex.regionsForFeature(tag)` | The faces that make up a feature. |
880
+ | `focusForPick(pick, lastRegion, lastFocus)` | Steps through a face's matches on repeated clicks. |
881
+ | `rankOwners` / `bestOwner` | The ranking `onPick` uses, if you want to run it yourself. |
882
+ | `groupByDirection(model)` | Features grouped by machining direction. |
883
+ | `directionLabel(v)` / `directionColor(i)` | A direction's label and colour. |
884
+ | `sectionFromPick({ point, normal })` | Turns a clicked surface into a section plane. |
885
+ | `measurementLabel(measurement, format?)` | Writes a measurement the way `<MeasureTool>` labels it. |
886
+ | `snapAt(hit, edges, camera, viewport)` | Where a point on the part snaps to, for a tool of your own. |
887
+ | `engineGeometryCache.clear()` | Frees every cached mesh. |
888
+
889
+ The package also exports lower-level geometry, camera, and view-cube functions that the components
890
+ use. They're listed in `dist/index.d.ts`.
891
+
892
+ ### Types
893
+
894
+ `ViewerProps`, `ViewerHandle`, `ViewerView`, `Projection`, `ControlScheme`, `EnginePartProps`,
895
+ `PartMeshProps`, `PartPick`, `PickModifiers`, `PartModel`, `PartModelFeature`, `PartModelRegion`,
896
+ `FeatureTag`, `FeatureType`, `Vec3`, `FeatureHighlight`, `RegionHighlight`, `SectionOptions`,
897
+ `SectionState`, `SectionPlacement`, `SectionToolProps`, `SectionStore`, `MeasureToolProps`,
898
+ `MeasureMode`, `Measurement`, `DistanceMeasurement`, `AngleMeasurement`, `Snap`, `SnapKind`,
899
+ `ViewerTheme`, `ViewName`, `DirectionArrowsProps`, `NamedDirection`, `GridProps`, `AxesProps`,
900
+ `ViewCubeProps`.
901
+
902
+ `FeatureType` and `ShapeKind` accept any string, because newer Engine versions add new values.
903
+ Handle values you don't recognize.