@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 +866 -94
- package/dist/{chunk-4BU4QZCL.js → chunk-HWAEUJ4H.js} +636 -277
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/index.d.ts +620 -191
- package/dist/index.js +1015 -13
- package/dist/{normalize-OULo7e0F.d.ts → normalize-DHOSNiuO.d.ts} +221 -139
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,131 +1,903 @@
|
|
|
1
1
|
# `@toolpath/viewer`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
it
|
|
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
|
-
##
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
Pass a part report from the Toolpath API to `<EnginePart>`:
|
|
11
29
|
|
|
12
30
|
```tsx
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
+
### Selecting from a list
|
|
37
347
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
+
Clicking the same face repeatedly can step through its matching features, like most CAD tools do.
|
|
385
|
+
`focusForPick` handles this:
|
|
68
386
|
|
|
69
|
-
|
|
70
|
-
|
|
387
|
+
```tsx
|
|
388
|
+
const [focused, setFocused] = useState<string | null>(null)
|
|
389
|
+
const [lastRegion, setLastRegion] = useState<number | null>(null)
|
|
71
390
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|