@toolpath/viewer 2.3.0 → 2.3.3

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,45 +1,103 @@
1
- # `@toolpath/viewer`
1
+ # Toolpath Viewer
2
2
 
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.
3
+ Display CAD meshes in a React application with feature picking, camera controls,
4
+ stock, section cuts, and measurements. Use `EnginePart` for an analyzed Toolpath
5
+ report, or `PartMesh` for geometry and region/feature data you supply yourself.
6
+ The viewer does not read STEP files, run analysis, or require an API key to render
7
+ local geometry.
9
8
 
10
9
  - [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)
10
+ - [Quick start: Engine report](#quick-start)
11
+ - [Use your own geometry](#using-it-without-the-engine)
12
+ - [Selection and hover](#selection-and-hover)
13
+ - [Components and camera controls](#components)
14
+ - [Toolbar, stock, section, and measurement recipes](#recipes)
15
+ - [Engine loading, caching, and errors](#working-with-the-toolpath-api)
18
16
  - [Reference](#reference)
17
+ - [Work on the package](#working-on-this-package)
19
18
 
20
19
  ## Install
21
20
 
22
- ```bash
23
- npm install @toolpath/viewer react react-dom three @react-three/fiber @react-three/drei
21
+ ```sh
22
+ npm install @toolpath/viewer
23
+ ```
24
+
25
+ Your application must provide compatible peers:
26
+
27
+ | Dependency | Declared range |
28
+ | -------------------- | -------------- |
29
+ | `react`, `react-dom` | `^19.0.0` |
30
+ | `three` | `>=0.159.0` |
31
+ | `@react-three/fiber` | `^9.7.0` |
32
+ | `@react-three/drei` | `^10.7.8` |
33
+
34
+ For a new application, this is the combination used by the repository's example:
35
+
36
+ ```sh
37
+ npm install @toolpath/viewer react@19.2.0 react-dom@19.2.0 three@0.185.1 @react-three/fiber@9.7.0 @react-three/drei@10.7.8
38
+ ```
39
+
40
+ TypeScript applications also need React and three.js types, for example
41
+ `@types/react`, `@types/react-dom`, and `@types/three`. Match the three.js types to
42
+ your installed three.js release. Keep one compatible copy of each renderer/peer
43
+ in your application; React Three Fiber 9 uses React 19.
44
+
45
+ This is an ESM package with included TypeScript declarations. Its declared Node
46
+ floor is 20; the tested three.js release needs a browser with WebGL2 support. Use your framework's
47
+ client component boundary for interactive React code and its normal bundler setup.
48
+ See the [React Three Fiber installation guide](https://r3f.docs.pmnd.rs/getting-started/installation)
49
+ for framework-specific setup.
50
+
51
+ **Current packaging limitation:** direct, unbundled Node ESM imports of both
52
+ JavaScript entries fail with `CameraControls.install is not a function`. Use an
53
+ application bundler; for SSR, verify the framework's server dependency bundling
54
+ as well as its client component boundary. The declared Node floor does not make
55
+ these entries standalone Node utilities.
56
+
57
+ ### Styles
58
+
59
+ The canvas does not need Tailwind or `@toolpath/ui`. Give the viewer a nonzero
60
+ height. It fills its wrapper, and its transparent canvas shows the background you
61
+ set on the wrapper or parent.
62
+
63
+ For the optional toolbar, import its plain CSS through your application's CSS
64
+ entry point or client entry:
65
+
66
+ ```css
67
+ @import '@toolpath/viewer/toolbar.css';
24
68
  ```
25
69
 
70
+ Measurement labels and hover-card content need your own styling; the toolbar
71
+ stylesheet does not style them. See [Measuring](#measuring) and [Theming](#theming).
72
+
26
73
  ## Quick start
27
74
 
28
- Pass a part report from the Toolpath API to `<EnginePart>`:
75
+ Pass a completed analysis report to `EnginePart`. It validates the report, loads
76
+ its mesh, and renders the result. It accepts `unknown`, so the viewer does not
77
+ require an SDK dependency just to display a report.
78
+
79
+ This example uses [react-error-boundary](https://github.com/bvaughn/react-error-boundary):
80
+
81
+ ```sh
82
+ npm install react-error-boundary
83
+ ```
29
84
 
30
85
  ```tsx
31
86
  'use client'
32
87
 
33
88
  import { Suspense, useState } from 'react'
34
- import { Axes, Grid, ViewCube, Viewer } from '@toolpath/viewer'
89
+ import { ErrorBoundary } from 'react-error-boundary'
90
+ import { Axes, Grid, ViewCube, Viewer, type FeatureTag } from '@toolpath/viewer'
35
91
  import { EnginePart } from '@toolpath/viewer/engine'
36
- import type { PartResponse } from '@toolpath/api'
37
92
 
38
- export function PartViewer({ report }: { report: PartResponse }) {
39
- const [selection, setSelection] = useState<string[]>([])
93
+ export const PartViewer = ({ report }: { report: unknown }) => {
94
+ const [selection, setSelection] = useState<Array<FeatureTag>>([])
40
95
 
41
96
  return (
42
- <ErrorBoundary fallback={<p>This part could not be displayed.</p>}>
97
+ <ErrorBoundary
98
+ fallback={<p role="alert">This part could not be displayed.</p>}
99
+ resetKeys={[report]}
100
+ >
43
101
  <Suspense fallback={<p>Loading part…</p>}>
44
102
  <Viewer style={{ height: 500 }} onPointerMissed={() => setSelection([])}>
45
103
  <EnginePart
@@ -57,930 +115,537 @@ export function PartViewer({ report }: { report: PartResponse }) {
57
115
  }
58
116
  ```
59
117
 
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.
118
+ Suspense supplies loading UI; the error boundary handles invalid report data.
119
+ An accepted analysis job is not a report: finish the API workflow
120
+ before passing its result here. When changing parts/results, key `PartViewer` by
121
+ analysis-result identity to reset selection, the camera, and one-time overlay
122
+ bounds. Refreshing URLs for the same artifact does not require a new key.
62
123
 
63
- What each wrapper is for:
124
+ ## How it works
64
125
 
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.
126
+ - Geometry and tool dimensions are **millimetres**, with **Z up**. Vectors and
127
+ stock must use the same coordinate frame; converting display units does not
128
+ change mesh coordinates.
129
+ - Regions identify surfaces and cover half-open triangle ranges `[start, end)`.
130
+ A feature refers to one or more regions. Several direction-dependent features
131
+ can share one surface, so a click can have several valid owners.
132
+ - `PartMesh` emits picks and hover information. Your application owns selection,
133
+ filtering, DFM rules, annotations, and persistence.
134
+ - The viewer frames its content when it first appears and renders on demand.
135
+ It still renders during interaction and transitions; this is not a zero-cost
136
+ guarantee for an idle mounted scene. Call `fit()` after later content changes
137
+ when you want to reframe, or remount the viewer to start a new view.
71
138
 
72
- ## How it works
139
+ ## Using it without the Engine
73
140
 
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
- ├─ <Stock> translucent geometry, GLB, box or cylinder stock, included in Fit
80
- ├─ <SectionTool> optional: click a face or a plane to cut the part open
81
- ├─ <MeasureTool> optional: click two points for a distance, three for an angle
82
- ├─ <Grid> <Axes> reference geometry, sized to the part
83
- └─ <ViewCube> the clickable orientation cube in the corner
84
- ```
141
+ Supply a `PartModel` and a **non-indexed** three.js `BufferGeometry`. Region ranges
142
+ must cover every triangle without gaps or overlaps; `buildRegionIndex` validates
143
+ that mapping. Preserve triangle order when preparing geometry.
85
144
 
86
- 1. **`<Viewer>`** sets up the 3D scene. Put everything else inside it. It frames the camera on
87
- whatever you put inside once that content appears. It only redraws when something changes, so
88
- a viewer that isn't being used costs nothing.
89
- 2. **`<EnginePart>`** checks the report, downloads the mesh (GLB first, STL if there is no GLB),
90
- and makes sure the mesh matches the report. Meshes are cached, so showing the same part twice
91
- downloads it once.
92
- 3. **`<PartMesh>`** draws the part as one mesh. The Engine splits the part's surface into
93
- **regions** (faces) and groups regions into **features** (holes, pockets, walls, and so on).
94
- When the pointer is over the part, `PartMesh` works out which region it is on and which
95
- features own that region.
96
- 4. **You own the state.** The viewer tells you what was clicked (`onPick`), and you tell it what to
97
- highlight (`selection`, `highlights`, …). It never changes your selection by itself.
98
-
99
- ### Focus the current selection
100
-
101
- Pass `focus` to make selected features solid while the remainder of the part becomes translucent.
102
- The default outside opacity is 15%; set `opacity` for a different X-ray strength. With no selected
103
- features, the part stays fully opaque.
145
+ This complete example renders a 20 mm cube and disposes the caller-owned geometry
146
+ on unmount:
104
147
 
105
148
  ```tsx
106
- <EnginePart report={report} selection={selection} focus={{ opacity: 0.12 }} />
149
+ import { useEffect, useMemo } from 'react'
150
+ import { BoxGeometry } from 'three'
151
+ import { PartMesh, Viewer, ViewCube, buildRegionIndex, type PartModel } from '@toolpath/viewer'
152
+
153
+ export const Cube = () => {
154
+ const { model, geometry } = useMemo(() => {
155
+ const indexed = new BoxGeometry(20, 20, 20)
156
+ const geometry = indexed.toNonIndexed()
157
+ indexed.dispose()
158
+ const regions = Array.from({ length: 6 }, (_, idx) => ({
159
+ idx,
160
+ splitOrigin: idx,
161
+ shapeKind: 'Plane',
162
+ area: 400,
163
+ triangles: { start: idx * 2, end: idx * 2 + 2 },
164
+ }))
165
+ const features = [
166
+ {
167
+ tag: 'top',
168
+ featureType: 'face',
169
+ machiningDirection: { x: 0, y: 0, z: 1 },
170
+ axis: null,
171
+ regionIdxs: [4],
172
+ },
173
+ ]
174
+ const model: PartModel = {
175
+ partId: 'local-cube',
176
+ kernelVersion: '',
177
+ regions,
178
+ features,
179
+ candidateDirections: [{ x: 0, y: 0, z: 1 }],
180
+ mesh: { pointCount: 36, triangleCount: 12, glbUrl: null, stlUrl: null, thumbnailUrl: null },
181
+ regionIndex: buildRegionIndex({ regions, features, triangleCount: 12 }),
182
+ warnings: [],
183
+ }
184
+ return { model, geometry }
185
+ }, [])
186
+
187
+ useEffect(() => () => geometry.dispose(), [geometry])
188
+
189
+ return (
190
+ <Viewer style={{ height: 400 }}>
191
+ <PartMesh model={model} geometry={geometry} selection={['top']} />
192
+ <ViewCube />
193
+ </Viewer>
194
+ )
195
+ }
107
196
  ```
108
197
 
109
- Parts are in **millimetres** with **Z pointing up**. The camera uses the same convention.
110
-
111
- ### A click usually matches several features
112
-
113
- The same face is usually owned by **5–8 features at once**, even on a plain cube: it can be a
114
- `face` when cut from one direction, a `wall` when cut from another, and part of every `profile`
115
- around it. So a click gives you every match, and you decide which to use:
116
-
117
- | Field | What it contains |
118
- | ---------------- | --------------------------------------------------------------------- |
119
- | `pick.best` | The most likely feature, or `null` |
120
- | `pick.ranked` | Every matching feature, most likely first |
121
- | `pick.owners` | Every matching feature, in report order |
122
- | `pick.region` | The index of the face that was clicked |
123
- | `pick.point` | Where the click hit, as `[x, y, z]` |
124
- | `pick.normal` | The direction the clicked surface faces, as `[x, y, z]` |
125
- | `pick.pointer` | Browser coordinates for an application-owned hover card, when present |
126
- | `pick.modifiers` | `{ alt, ctrl, meta, shift, secondary }`: keys held, and right-click |
127
- | `pick.doubled` | `true` if this click was the second half of a double-click |
128
-
129
- The ranking puts specific features first: holes, then pockets and bosses, then chamfers and
130
- fillets, then walls and faces, then profiles. Among features of the same kind, the one whose
131
- machining direction points most toward the camera wins.
132
-
133
- `onHover` receives the same `PartPick` shape. Use the optional `pointer` location yourself, or
134
- wrap application content in `<HoverCard pick={hoverPick}>`; it follows the cursor while that
135
- region remains hovered. The viewer intentionally leaves card content and actions to the application.
136
- Hover feedback is on by default; pass `hover={false}` to disable both face feedback and `onHover`
137
- callbacks while preserving clicks. That makes an application toolbar's feature-hover toggle
138
- unambiguous.
139
-
140
- The normalized model contains feature identity, type, directions, face shape, and analytic area.
141
- An Engine DFM card can join `pick.best` to the part's detailed feature data and render its own
142
- depth, clearance-diameter, or L/D rows inside `HoverCard`; setup labels remain application or plan
143
- context rather than a fact of the part surface.
198
+ The feature covers only the top face; surfaces without a feature remain drawable
199
+ and pickable, but have no `pick.best`. An application-supplied model does not go
200
+ through the Engine kernel-version gate. Keep geometry/model references stable
201
+ between renders. `PartMesh` manages its rendering resources, but never disposes
202
+ the input geometry; the caller owns that lifetime.
144
203
 
145
- ## Components
204
+ ## Selection and hover
146
205
 
147
- ### Stock and display controls
206
+ Viewer identity is `FeatureTag` (`model.features[i].tag`, sourced from an Engine
207
+ report's `featureTag`). Do not replace it with an array index or an API
208
+ `featureId`. Region indices are local to their report and cannot join unrelated
209
+ results. `FeatureType` and `ShapeKind` allow unknown strings for newer data.
210
+
211
+ | `PartPick` field | Meaning |
212
+ | ------------------------- | ----------------------------------------------------------------------------------- |
213
+ | `best` | Highest-ranked feature tag, or `null`. |
214
+ | `ranked` | Owners ranked for the camera and active direction. |
215
+ | `owners` | All owners in report order, before active-direction filtering. |
216
+ | `region`, `triangleIndex` | Surface index and triangle hit. |
217
+ | `point`, `normal` | Hit position and outward normal; three-number tuples. The normal is in world space. |
218
+ | `pointer` | Optional browser `clientX`/`clientY` for DOM overlays. |
219
+ | `modifiers` | `alt`, `ctrl`, `meta`, `shift`, and `secondary` (right button). |
220
+ | `doubled` | This pick completed a double-click. |
221
+ | `sharpCorner` | Optional corner hit when sharp-corner visualization is enabled. |
222
+
223
+ `onPick` reports primary and secondary clicks. Decide whether secondary clicks,
224
+ double-clicks, or modifier keys should change selection. Double-click retargeting
225
+ still works independently. `onHover` receives a pick or `null`; `hover={false}`
226
+ disables hover feedback and callbacks while leaving click picking enabled.
148
227
 
149
- Use `<Stock>` for a box, cylinder, prepared geometry or GLB bytes. Stock and part coordinates
150
- must use the same millimetre, Z-up frame. Stock is included in Fit and Reset while
151
- keeping section tools, measurements, the grid, and direction arrows sized to the finished part.
152
- Stock does not intercept clicks or get clipped by the part's section plane.
228
+ This inspector shares selection between a list and the mesh, cycles overlapping
229
+ owners on repeated clicks, and draws application-owned hover text:
153
230
 
154
231
  ```tsx
155
- import { useMemo, useRef, useState } from 'react'
232
+ import { useState } from 'react'
233
+ import type { BufferGeometry } from 'three'
156
234
  import {
157
- Axes,
158
- Stock,
159
- DirectionArrows,
235
+ HoverCard,
160
236
  PartMesh,
161
237
  Viewer,
162
- directionHighlights,
238
+ focusForPick,
239
+ type PartModel,
240
+ type PartPick,
163
241
  } from '@toolpath/viewer'
164
- import type { PartModel, ViewerHandle } from '@toolpath/viewer'
165
- import type { BufferGeometry } from 'three'
166
242
 
167
- export function StockPreview({ model, geometry }: { model: PartModel; geometry: BufferGeometry }) {
168
- const viewer = useRef<ViewerHandle>(null)
169
- const [stock, setStock] = useState(false)
170
- const [axes, setAxes] = useState(true)
171
- const [directions, setDirections] = useState(false)
172
- const [direction, setDirection] = useState<number | null>(null)
173
- const [wireframe, setWireframe] = useState(false)
174
- const colors = useMemo(
175
- () => (directions ? directionHighlights(model, direction) : []),
176
- [model, direction, directions],
177
- )
243
+ export const FeatureInspector = ({
244
+ model,
245
+ geometry,
246
+ }: {
247
+ model: PartModel
248
+ geometry: BufferGeometry
249
+ }) => {
250
+ const [selected, setSelected] = useState<string | null>(null)
251
+ const [hovered, setHovered] = useState<string | null>(null)
252
+ const [pick, setPick] = useState<PartPick | null>(null)
253
+ const [lastRegion, setLastRegion] = useState<number | null>(null)
178
254
 
179
255
  return (
180
256
  <>
181
- <Viewer ref={viewer} style={{ height: 500 }}>
257
+ <ul>
258
+ {model.features.map((feature) => (
259
+ <li key={feature.tag}>
260
+ <button
261
+ type="button"
262
+ onClick={() => setSelected(feature.tag)}
263
+ onMouseEnter={() => setHovered(feature.tag)}
264
+ onMouseLeave={() => setHovered(null)}
265
+ >
266
+ {feature.featureType}: {feature.tag}
267
+ </button>
268
+ </li>
269
+ ))}
270
+ </ul>
271
+ <Viewer style={{ height: 400 }} onPointerMissed={() => setSelected(null)}>
182
272
  <PartMesh
183
273
  model={model}
184
274
  geometry={geometry}
185
- display={wireframe ? 'wireframe' : 'solid'}
186
- regionHighlights={colors}
187
- activeDirection={directions ? direction : null}
188
- />
189
- {stock && <Stock box={{ partGeometry: geometry, allowance: { wall: 3, floor: 3 } }} />}
190
- {axes && <Axes />}
191
- <DirectionArrows
192
- directions={model.candidateDirections}
193
- visible={directions}
194
- shownDirection={direction}
195
- onPickDirection={(index) => setDirection((held) => (held === index ? null : index))}
275
+ selection={selected ? [selected] : []}
276
+ hoveredFeatureIds={hovered ? [hovered] : []}
277
+ focusFeature={selected}
278
+ onHover={setPick}
279
+ onPick={(next) => {
280
+ if (next.modifiers.secondary || next.doubled) {
281
+ return
282
+ }
283
+ setSelected(focusForPick(next, lastRegion, selected))
284
+ setLastRegion(next.region)
285
+ }}
196
286
  />
197
287
  </Viewer>
198
- <button aria-pressed={stock} onClick={() => setStock(!stock)}>
199
- Stock
200
- </button>
201
- <button aria-pressed={axes} onClick={() => setAxes(!axes)}>
202
- Axes
203
- </button>
204
- <button aria-pressed={directions} onClick={() => setDirections(!directions)}>
205
- By direction
206
- </button>
207
- <button aria-pressed={wireframe} onClick={() => setWireframe(!wireframe)}>
208
- Wireframe
209
- </button>
210
- <button onClick={() => viewer.current?.fit()}>Fit</button>
288
+ <HoverCard pick={pick}>
289
+ {(current) => <span>{current.best ?? `Region ${current.region}`}</span>}
290
+ </HoverCard>
211
291
  </>
212
292
  )
213
293
  }
214
294
  ```
215
295
 
216
- For a box preview, pass `box={{ partGeometry, allowance, offset }}`. Allowance is padding
217
- **per side**, in millimetres. The preferred form is
218
- `{ wall, floor }`: wall stock expands X/Y and floor stock expands Z, matching the wall/floor
219
- roughing-stock distinction used by machining settings. A number or `{ x, y, z }` remains accepted
220
- for uniform or axis-specific padding. It defaults to zero. `offset` translates the stock from the
221
- part's bounding-box centre. `boxStockBounds(geometry, allowance, offset)` returns the same `Box3`
222
- for displaying dimensions.
223
-
224
- For fixed-box previews, pass `box={{ partGeometry, dimensions, position, positionOffset, offset }}`
225
- instead, where `dimensions` is `{ x, y, z }`. Dimensions and allowance are alternative sizing
226
- modes, not simultaneous options. `position` accepts
227
- `model_centered`, `offset_from_top`, or `offset_from_bottom`, and `positionOffset` is the distance
228
- from the selected top or bottom bound. All fixed-box values use millimetres and the part's Z-up
229
- coordinate frame.
230
- Negative/non-finite allowances, non-finite coordinates, and empty or non-positive stock dimensions
231
- throw `RangeError`. The 3 mm allowance above is an example, not an automatic stock recommendation.
232
-
233
- `<Stock cylinder={{ origin, axis, diameter, length }} />` draws round stock along any axis:
234
- `origin` is the centre of its base and `axis` points from the base toward its far end, in part
235
- coordinates. For a preview, pass
236
- `cylinder={{ partGeometry, diameter, length, position, positionOffset }}` instead. It stands along
237
- the part's Z, is centred on the part's bounding box, and is placed along Z by the same `position`
238
- rule as fixed-box stock. The kernel centres fixed round stock on the part's
239
- smallest enclosing circle about the first setup's cutting direction, so render the resolved stock
240
- once a job is planned.
241
-
242
- `<Stock box={{ frame, lower, upper }} />` draws a block whose corners are given in a frame
243
- of `location`, `axis` and `refDirection`: a point `(x, y, z)` sits at
244
- `location + x·refDirection + y·(axis × refDirection) + z·axis`, with `axis` normalised and
245
- `refDirection` projected square to it and normalised. That is the shape of an Engine plan's
246
- `stock.resolved` box, which is squared to the setup rather than to the part. Omit `frame` when
247
- `lower` and `upper` are already in part coordinates. Resolved placement and `partGeometry`
248
- preview options are mutually exclusive for both shapes.
249
-
250
- Box and cylinder sources rebuild their mesh only when their figures/options change (or the
251
- preview's `partGeometry` reference changes). Inline option objects do not trigger repeated part
252
- scans on ordinary renders. Figures that cannot make a
253
- solid — a zero axis, a non-positive size, a frame whose axis and reference direction are parallel —
254
- throw `RangeError`.
255
-
256
- `Stock` accepts exactly one of `geometry`, `glb`, `box` or `cylinder`:
257
-
258
- ```tsx
259
- <Stock geometry={geometry} showEdges={false} />
260
- // Or:
261
- <Stock glb={glbBytes} showEdges={false} />
262
- <Stock box={resolvedBox} />
263
- <Stock cylinder={resolvedCylinder} />
264
- <Stock box={{ partGeometry, dimensions, position, positionOffset }} />
265
- <Stock cylinder={{ partGeometry, diameter, length, position, positionOffset }} />
266
- ```
267
-
268
- All four inputs are mutually exclusive in `StockProps`. With `geometry`, the caller owns its
269
- lifetime and disposal. With `box` or `cylinder`, `Stock` owns the generated geometry. With `glb`,
270
- `Stock` decodes the bytes internally and disposes decoded
271
- geometry when the bytes change or the component unmounts. Keep the same `ArrayBuffer` reference
272
- while displaying the same artifact to avoid decoding it again on ordinary renders.
273
- It renders nothing while decoding and throws decoding failures to the nearest error boundary.
274
- Changing the source immediately hides the previous stock; late results from an earlier decode
275
- are discarded and cleaned up.
276
-
277
- The component does not fetch, cache or store artifacts. The caller supplies the bytes.
278
- GLB node transforms are applied without centering, rescaling or changing axes:
279
- Engine stock artifacts are already in part coordinates, millimetres and Z-up. Indexed geometry
280
- stays indexed; missing normals are computed without requiring a part report. The parser cleans up
281
- its temporary scene after preparing the returned geometry.
282
-
283
- Engine stock files are plain GLB served with HTTP gzip, which the browser handles automatically;
284
- no meshopt decoder is needed. Authentication, durable artifact copies, action selection and
285
- missing-artifact fallback and artifact caching belong to the application. The example's
286
- **Stock source → GLB fixture** option exercises synthetic blanks encoded by the services stock
287
- writer. **Shape preview** exposes the separate box/cylinder shape controls.
288
-
289
- `Stock` accepts `color`, `emissive` (default black), `flatShading` (default `false`),
290
- `opacity` (default `0.2`), `edgeColor`, `edgeOpacity`, and `showEdges`. Emissive shading
291
- and flat shading change only the material, not the source's vertices, indices or normals.
292
- `renderStyle="legacy-workpiece"` reproduces legacy Workpiece rendering: white with dark emissive
293
- shading, per-face shading, 20% opacity, two shared-geometry surface passes and black 50%-opacity
294
- edges drawn before surfaces (rather than over them). The default `overlay` style is unchanged.
295
-
296
- For action-to-action playback, `retainPrevious` keeps the current decoded GLB visible until
297
- the next GLB is ready, then swaps meshes without a blank frame. Key the stock's parent by
298
- part/calculation identity (not action) to prevent retaining geometry across unrelated runs.
299
- The default still hides the previous source during decoding. Geometry is disposed after a
300
- replacement commits; cancelled/late results are discarded.
301
- The outline is built the first time it is shown, so a large stock mesh with `showEdges={false}`
302
- costs no edge pass. All source types accept the same appearance props. Caller-provided geometry is never disposed; the
303
- components dispose their own materials and outlines. Conditionally mount stock to toggle it.
304
- Toggling stock preserves the camera; Fit/Reset then frames the visible stock together with the part.
305
-
306
- **Migration from 1.x:** `BoxStock` and `BoxStockProps` have been removed. Use
307
- `<Stock box={{ partGeometry, allowance, offset }} />` for allowance-based previews, or
308
- `<Stock box={{ partGeometry, dimensions, position, positionOffset, offset }} />` for fixed
309
- dimensions. No preview-builder helper is required. `Stock geometry={geometry}` remains supported.
310
- `StockProps` is now a union type alias, so `interface X extends StockProps` becomes
311
- `type X = StockProps & { … }`; `StockAppearanceProps` names the shared appearance props alone.
312
-
313
- `directionHighlights(model, activeDirection?)` returns region colors in the same palette as
314
- `DirectionArrows`. For a face with several owners, the most specific feature wins, followed by
315
- candidate-direction order and feature tag. Unmatched directions are left unpainted. Hover and
316
- selection still paint over this wash. Scoping by direction filters ownership; it does not hide
317
- the rest of the model or claim that an unpainted face cannot be manufactured.
318
-
319
- Wireframe keeps the existing semantic edges, including rear edges, and keeps faces available for
320
- picking, measuring, and section placement. Highlighted faces and section caps remain visible.
321
- `showEdges` controls solid-mode outlines; wireframe always shows them. The example's bottom toolbar
322
- makes wireframe and direction coloring mutually exclusive and includes a direction legend and
323
- contextual Section/Measure controls.
324
-
325
- ### `<ViewerToolbar>`
326
-
327
- `ViewerToolbar` is a composable, viewer-scoped toolbar for camera, display, section, and measurement
328
- controls. Import its stylesheet alongside your application stylesheet:
329
-
330
- ```tsx
331
- import '@toolpath/viewer/toolbar.css'
332
- import { ViewerToolbar, ViewerToolbarProvider } from '@toolpath/viewer'
333
- ```
334
-
335
- Give `ViewerToolbarProvider` the current values and callbacks from the host app, then place the
336
- toolbar and viewer inside it. The toolbar owns no application state or behavior: it only reads the
337
- provider. Omit `children` for every configured control in the standard order, or compose the
338
- individual buttons to use only the controls and order your application needs.
296
+ `HoverCard` is a DOM element; keep it outside the canvas and style its contents.
297
+ `focusFeature` frames the feature when its value changes. The `focus` prop is
298
+ different: `focus={{ opacity: 0.12 }}` makes regions outside `selection`
299
+ translucent. Its default outside opacity is 0.15, and an empty selection leaves
300
+ the part opaque. For multiselection, update your own tag array and avoid duplicates.
339
301
 
340
- ```tsx
341
- <ViewerToolbarProvider
342
- controls={{
343
- stock: { pressed: showStock, onClick: () => setShowStock((shown) => !shown) },
344
- measure: { pressed: measuring, onClick: toggleMeasuring },
345
- fit: { onClick: () => viewer.current?.fit() },
346
- }}
347
- >
348
- <ViewerToolbar>
349
- <ViewerToolbar.Controls>
350
- <ViewerToolbar.MeasureButton />
351
- <ViewerToolbar.Divider />
352
- <ViewerToolbar.StockButton />
353
- <ViewerToolbar.FitButton />
354
- </ViewerToolbar.Controls>
355
- </ViewerToolbar>
356
- <Viewer ref={viewer}>…</Viewer>
357
- </ViewerToolbarProvider>
358
- ```
302
+ ## Components
359
303
 
360
- `ViewerToolbar.Controls`, `Divider`, and the sixteen `*Button` components are available on
361
- `ViewerToolbar`: `StockButton`, `AxesButton`, `GridButton`, `BananaButton`, `ToolsButton`, `DirectionsButton`,
362
- `DfmButton` (shows or hides an application's DFM flags), `SharpCornersButton`, `HoverButton`, `FocusButton`, `WireframeButton`, `SectionButton`, `MeasureButton`, `FitButton`,
363
- `ResetButton`, and `TopButton`. A rendered button requires its matching provider control. For an
364
- application-specific toolbar component, `useViewerToolbar()` reads the same controls from inside
365
- the provider.
366
-
367
- #### Styling hooks
368
-
369
- Every viewer, hover-card, and toolbar DOM boundary has a stable class name. Your `className` on
370
- `<Viewer>`, `<ViewerToolbar>`, or `<HoverCard>` is appended to the component's own class; it never
371
- replaces the hook. The toolbar stylesheet uses the same names, so import it for the default look or
372
- override any of these selectors in your application stylesheet.
373
-
374
- | Component | Class | Element |
375
- | ----------------- | ------------------------- | --------------------------------- |
376
- | `<Viewer>` | `viewer-root` | Wrapper around the canvas |
377
- | `<Viewer>` | `viewer-canvas-container` | R3F canvas event container |
378
- | `<Viewer>` | `viewer-canvas-frame` | R3F's canvas-sizing frame |
379
- | `<Viewer>` | `viewer-canvas` | The `<canvas>` itself |
380
- | `<HoverCard>` | `viewer-hover-card` | Cursor-following tooltip shell |
381
- | `<ViewerToolbar>` | `viewer-toolbar-stack` | Outer toolbar stack |
382
- | `<ViewerToolbar>` | `viewer-toolbar` | Controls group |
383
- | `<ViewerToolbar>` | `viewer-toolbar-button` | Each standard control button |
384
- | `<ViewerToolbar>` | `viewer-toolbar-icon` | SVG within a standard control |
385
- | `<ViewerToolbar>` | `viewer-toolbar-tooltip` | Label shown on button hover/focus |
386
- | `<ViewerToolbar>` | `viewer-toolbar-divider` | Separator between control groups |
387
-
388
- The following `data-*` attributes identify generated elements without depending on labels, which
389
- can change with state or localization:
390
-
391
- | Attribute | Element and values |
392
- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
393
- | `data-viewer-root="true"` | `<Viewer>` wrapper |
394
- | `data-viewer-canvas-container="true"` | R3F canvas event container |
395
- | `data-viewer-canvas-frame="true"` | R3F canvas-sizing frame |
396
- | `data-viewer-canvas="true"` | The `<canvas>` itself |
397
- | `data-viewer-hover-card="true"` | `<HoverCard>` shell |
398
- | `data-viewer-toolbar="true"` | Toolbar stack |
399
- | `data-viewer-toolbar-controls="true"` | Controls group |
400
- | `data-viewer-toolbar-action` | Standard button: `stock`, `axes`, `grid`, `banana`, `tools`, `directions`, `dfm`, `sharpCorners`, `hover`, `focus`, `wireframe`, `section`, `measure`, `fit`, `reset`, or `top` |
401
- | `data-viewer-toolbar-icon="true"` | Standard button SVG |
402
- | `data-viewer-toolbar-tooltip="true"` | Standard button tooltip |
403
- | `data-viewer-toolbar-divider="true"` | Toolbar separator |
404
-
405
- Toggle buttons also carry standard `aria-pressed="true"` or `"false"`; use that to style their
406
- current state. `Fit`, `Reset`, and `Top view` are actions rather than toggles, so they omit the
407
- attribute.
304
+ | Component | Purpose |
305
+ | ---------------------------- | ------------------------------------------------------------------------------------ |
306
+ | `Viewer` | Canvas, camera, lighting, controls, initial framing, and viewer-owned section state. |
307
+ | `EnginePart` | Validated Engine report plus cached mesh loading; imports from `/engine`. |
308
+ | `PartMesh` | Your normalized model and prepared geometry; selection, paint, picking, sections. |
309
+ | `ViewCube` | 26 orientation targets: faces, edges, and corners. |
310
+ | `Grid`, `Axes` | Reference geometry excluded from camera framing. |
311
+ | `DirectionArrows` | Candidate and optional named/preview machining directions. |
312
+ | `Stock` | Geometry, GLB bytes, box, or cylinder stock. |
313
+ | `SectionTool`, `SectionView` | Interactive section placement or a supplied section visualization. |
314
+ | `MeasureTool` | Distance/angle picking and labels. |
315
+ | `ToolMarks` | Supplied cutter/profile geometry and labels; no datasheet inference. |
316
+ | `Banana` | Optional bundled 154 mm scale reference; loads its GLB on mount and suspends. |
317
+ | `CadCameraControls` | Lower-level camera controls for custom R3F scenes. `Viewer` already installs them. |
318
+ | `ViewerToolbar` | DOM controls configured through `ViewerToolbarProvider`. |
319
+ | `HoverCard` | Cursor-following DOM content for a pick. |
408
320
 
409
321
  ### `<Viewer>`
410
322
 
411
- The canvas that holds everything. Import it from `@toolpath/viewer`.
412
-
413
- | Prop | Default | What it does |
414
- | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
415
- | `projection` | `'orthographic'` | `'orthographic'` keeps parallel lines parallel. `'perspective'` gives depth, which helps with deep pockets. |
416
- | `controls` | `'toolpath'` | CAD navigation preset. See [CAD controls](#cad-controls). |
417
- | `zoomTo` | `'cursor'` | Zoom toward the pointer, or toward `'centre'` (often easier on a trackpad). |
418
- | `freeOrbit` | `true` | Let the view keep rotating past straight-up and straight-down. |
419
- | `retargetOnDoubleClick` | `true` | Double-click the part to rotate around that point. |
420
- | `recentreOnDoubleClick` | `true` | Double-click the middle mouse button to fit the whole part again. |
421
- | `showOrbitTarget` | `false` | Show a small marker at the point the view rotates around. |
422
- | `theme` | — | Override lighting and colours. See [Theming](#theming). |
423
- | `onPointerMissed` | — | Called when a left-click hits nothing. Clear your selection here. |
424
- | `className`, `style` | — | Applied to the wrapping `<div>`. It fills its parent by default. |
425
- | `ref` | — | A `ViewerHandle` for moving the camera. See [Camera buttons](#camera-buttons). |
426
-
427
- Changing `projection` rebuilds the canvas and returns the camera to its starting view.
428
-
429
- The canvas is transparent. To set a background colour, style the wrapper or its parent.
323
+ | Prop | Default | Behavior |
324
+ | ----------------------- | ----------------- | ------------------------------------------------------------------------------------- |
325
+ | `projection` | `orthographic` | `perspective` is also supported. Changing it remounts the canvas and resets the view. |
326
+ | `controls` | `toolpath` | Navigation preset, independent of projection. |
327
+ | `zoomTo` | `cursor` | Zoom toward the pointer or `centre`. |
328
+ | `freeOrbit` | `true` | Allow rotation through the poles. |
329
+ | `retargetOnDoubleClick` | `true` | Double-click a part to move the orbit target there. |
330
+ | `recentreOnDoubleClick` | `true` | Double middle-click to fit content. |
331
+ | `showOrbitTarget` | `false` | Show the pivot marker. |
332
+ | `theme` | Default lighting | Override lighting; part/cube/tool colors have their own theme props. |
333
+ | `onPointerMissed` | — | Empty-space primary click, with drag guarding; use it to clear selection. |
334
+ | `style`, `className` | Full-size wrapper | Set dimensions and background on the wrapper or parent. |
335
+ | `ref` | — | `ViewerHandle` for controls outside the canvas. |
336
+
337
+ `ViewerHandle` exposes `fit()`, `reset()`, `setView(name)`,
338
+ `setViewDirection({ x, y, z })`, `frameBox(box)`, and `setSection(options | null)`.
339
+ `fit` and `frameBox` preserve view direction; `reset` returns to the opening view.
340
+ Named views are `top`, `bottom`, `front`, `back`, `left`, `right`, and `isometric`.
341
+ Direction vectors point from the part toward the camera.
430
342
 
431
343
  #### CAD controls
432
344
 
433
- `controls` changes only the navigation gestures. It does not choose a projection: the viewer stays
434
- orthographic by default, and `projection="perspective"` remains an independent opt-in.
345
+ | Preset | Rotate | Pan | Zoom |
346
+ | -------------------- | ----------------------------------------------- | ------------------------------- | --------------------------- |
347
+ | `toolpath` | Left-drag | Right-drag | Wheel |
348
+ | `fusion`, `inventor` | Shift + middle-drag / Shift + two-finger scroll | Middle-drag / two-finger scroll | Trackpad pinch |
349
+ | `alias` | Left-drag | Middle-drag | Wheel |
350
+ | `solidworks` | Middle-drag | Ctrl + middle-drag | Shift + middle-drag / wheel |
351
+ | `tinkercad` | Right-drag | Shift + right-drag | Wheel |
352
+ | `powermill` | Middle-drag | Shift + middle-drag | Wheel |
353
+ | `onshape` | Right-drag | Middle-drag | Wheel |
435
354
 
436
- | Value | Label | Rotate | Pan | Zoom |
437
- | -------------- | ---------- | ------------------------------------- | ----------------------------------------- | ----------------------------------- |
438
- | `'toolpath'` | Toolpath | Left-drag | Right-drag | Scroll wheel |
439
- | `'fusion'` | Fusion | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch |
440
- | `'alias'` | Alias | Left-drag | Middle-drag | Scroll wheel |
441
- | `'inventor'` | Inventor | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch |
442
- | `'solidworks'` | SolidWorks | Middle-drag | Ctrl + middle-drag | Shift + middle-drag or scroll wheel |
443
- | `'tinkercad'` | Tinkercad | Right-drag | Shift + right-drag | Scroll wheel |
444
- | `'powermill'` | PowerMill | Middle-drag | Shift + middle-drag | Scroll wheel |
445
- | `'onshape'` | Onshape | Right-drag | Middle-drag | Scroll wheel |
446
-
447
- Fusion and Inventor treat a two-finger scroll as pan, Shift + two-finger scroll as orbit, and a
448
- pinch (a wheel event with Ctrl set by the browser) as zoom. Other schemes use one-finger rotate,
449
- two-finger pinch-and-pan, and three-finger pan on touch screens.
450
-
451
- All schemes support double-clicking the part to rotate around that point and double-clicking the
452
- middle mouse button to fit the whole part. Dragging never selects anything; a click only counts if
453
- the pointer barely moved.
454
-
455
- The package exports `CONTROL_SCHEME_OPTIONS`, `ControlScheme`, and `ControlSchemeOption` for a host
456
- application's settings UI. It deliberately stores no preferences: pass the selected `controls`,
457
- `freeOrbit`, and `zoomTo` values to `<Viewer>` from your own state or persistence layer.
458
-
459
- `'toolpath'`, `freeOrbit={true}`, and `zoomTo="cursor"` are the defaults.
355
+ `CONTROL_SCHEME_OPTIONS` supplies labels/values for a settings UI. Preferences are
356
+ application-owned. Default touch controls use one-finger rotation, two-finger
357
+ pinch/pan, and three-finger pan; Fusion/Inventor use their CAD scroll behavior.
460
358
 
461
359
  ### `<EnginePart>`
462
360
 
463
- Draws a Toolpath Engine part report. Import it from `@toolpath/viewer/engine`.
464
-
465
- It takes one prop of its own, `report`: the `PartResponse` from `@toolpath/api`, unchanged. All
466
- other props are the same as `<PartMesh>`'s, except `model` and `geometry`, which it creates for
467
- you.
468
-
469
- Mesh URLs in a report expire **15 minutes** after the report was created. If a part may be shown
470
- later than that, fetch a fresh report first. Don't save the URLs.
361
+ `report` is its only additional prop. It inherits `PartMeshProps` except `model`
362
+ and `geometry`, which it creates. Mesh loading prefers GLB and tries STL if GLB
363
+ fails or is absent. It does not fetch a report or start an Engine job.
471
364
 
472
365
  ### `<PartMesh>`
473
366
 
474
- Draws the part and handles clicks. You only use it directly when you
475
- [don't use the Engine](#using-it-without-the-engine). All of these props also work on
476
- `<EnginePart>`.
477
-
478
- **Colours**
479
-
480
- | Prop | Type | What it does |
481
- | ------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------- |
482
- | `selection` | `string[]` | Feature tags to highlight as selected (orange). |
483
- | `highlights` | `FeatureHighlight[]` | Your own colour per feature, e.g. difficulty or setup. |
484
- | `regionHighlights` | `RegionHighlight[]` | Your own colour per face. |
485
- | `candidates` | `string[]` | Other possible matches, faintly tinted by machining direction. |
486
- | `pickedRegions` | `number[]` | Faces to mark as just clicked. |
487
- | `hoveredFeatureIds` | `string[]` | Features to show as hovered, e.g. when hovering a list row. |
488
- | `showEdges` | `boolean` (`true`) | Draw outlines between faces. |
489
- | `sharpCorners` | `SharpCornerOptions \| null` (`null`) | Draw the sharp corners the DFM rules flag. See [Showing sharp corners](#showing-sharp-corners). |
490
- | `display` | `'solid' \| 'wireframe'` (`'solid'`) | Wireframe draws face boundaries without triangle diagonals. Painted and hovered faces remain visible. |
491
- | `theme` | `Partial<ViewerTheme>` | Override the part's colours. |
492
-
493
- **Interaction**
494
-
495
- | Prop | Type | What it does |
496
- | ----------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- |
497
- | `onPick` | `(pick: PartPick) => void` | Left- or right-click on the part. |
498
- | `onHover` | `(pick: PartPick \| null) => void` | The pointer moved onto a different face, or off the part (`null`). |
499
- | `hover` | `boolean` (`true`) | Paint and report faces under the pointer. `false` leaves click picking on and clears any hover. |
500
- | `activeDirection` | `number \| null` | Only match features machined from this direction (an index into `candidateDirections`). |
501
- | `focusFeature` | `string \| null` | Zoom to this feature. The camera moves each time the value changes. |
502
- | `section` | `SectionOptions` | Cut the part open. Omit it to follow the viewer's own cut. See [Section view](#section-view). |
503
- | `onSectionChange` | `(state: SectionState) => void` | Called when the cut moves or goes away. With `section`, passing it also shows the drag handle. |
504
- | `onAdjacency` | `(map) => void` | Called once per mesh with which faces touch which. |
505
- | `children` | `ReactNode` | Objects drawn on the part. They read its model and mesh with `usePartContext()`. |
506
-
507
- Hovering over the part is handled for you. You only need `onHover` if you want to show the hovered
508
- feature elsewhere in your UI.
509
-
510
- ### `<ViewCube>`
511
-
512
- The orientation cube in the corner. It has 26 clickable areas: 6 faces, 12 edges, and 8 corners,
513
- so you can get an isometric view with one click.
514
-
515
- ```tsx
516
- <ViewCube alignment="bottom-right" margin={[60, 60]} onViewChange={(view) => track(view)} />
517
- ```
518
-
519
- | Prop | Default | |
520
- | -------------- | ------------- | -------------------------------------------------------------- |
521
- | `alignment` | `'top-right'` | `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'` |
522
- | `margin` | `[80, 80]` | Distance from the corner, in pixels |
523
- | `onViewChange` | — | Called with the view name, e.g. `'top-front-right'` |
524
- | `theme` | — | Cube colours |
525
-
526
- ### `<DirectionArrows>`
527
-
528
- An arrow for each direction the part can be machined from. Each direction has its own colour, and
529
- features machined from that direction use the same colour. The arrows point **at** the part,
530
- because that is the direction the tool comes from.
531
-
532
- ```tsx
533
- <DirectionArrows
534
- directions={model.candidateDirections}
535
- activeDirection={direction}
536
- onPickDirection={(index) => setDirection(index === direction ? null : index)}
537
- />
538
- ```
539
-
540
- | Prop | What it does |
541
- | ------------------ | ------------------------------------------------------------------------------------ |
542
- | `directions` | The report's `candidateDirections`. |
543
- | `activeDirection` | Show only this arrow. |
544
- | `shownDirection` | Which arrows to show: `null` for all, an index, a list of indices, or `-1` for none. |
545
- | `previewDirection` | An extra arrow drawn on top of the part, e.g. while the user aims a new direction. |
546
- | `namedDirections` | Extra directions that aren't candidates, each with its own colour. |
547
- | `onPickDirection` | Called with an arrow's index when it is clicked. |
548
- | `visible` | Hide all arrows. |
549
-
550
- Clicking an arrow does not trigger `onPointerMissed`, so it won't clear your selection.
551
-
552
- With the Engine, get the directions from the model:
553
- `const model = useMemo(() => normalizePartReport(report), [report])`.
554
-
555
- ### `<SectionTool>`
556
-
557
- Cut the part open from inside the viewport. Optional: mount it next to the part when the user
558
- enters section mode, and unmount it when they leave.
559
-
560
- ```tsx
561
- {
562
- sectioning ? <SectionTool /> : null
563
- }
564
- ```
565
-
566
- While it is up, hovering the part previews a cut through the face under the pointer and a click
567
- places it; three coloured planes behind the part cut along an axis instead. Once there is a cut,
568
- the part's own arrow drags it, an outlined sheet shows the cutting plane, and Escape clears it.
569
-
570
- | Prop | What it does |
571
- | ------- | ---------------------------------------------------------------------- |
572
- | `theme` | Override its colours — `sectionOutline` for the sheet and the preview. |
573
-
574
- The cut it places is the viewer's own, and `<PartMesh>` follows it when given no `section` prop.
575
- While the tool is mounted the part reports no hovers or picks. Details and the controlled
576
- alternative are under [Section view](#section-view).
577
-
578
- ### `<MeasureTool>`
579
-
580
- Measure the part from inside the viewport. Optional: mount it next to the part when the user
581
- enters measure mode, and unmount it when they leave.
582
-
583
- ```tsx
584
- {
585
- measuring ? <MeasureTool mode="distance" onChange={setMeasurements} /> : null
586
- }
587
- ```
588
-
589
- The pointer snaps to what is under it — a corner first, then an edge's midpoint, then the edge,
590
- then the face — and a marker shows where the click will land. Two clicks measure a distance, three
591
- an angle (one arm's end, the vertex, the other arm's end), with a live readout between clicks.
592
- Finished measurements stay drawn over the part, each with a label, until Delete removes the last
593
- one or the tool is unmounted. Escape drops the points of one in progress, and Shift holds the next
594
- point to an axis through the last.
595
-
596
- | Prop | What it does |
597
- | ---------------- | --------------------------------------------------------------------------------------------- |
598
- | `mode` | `'distance'` (default) or `'angle'`. Changing it drops any points already placed. |
599
- | `measurements` | Own the list. Omit it and the tool keeps its own. |
600
- | `onChange` | The list changed: a measurement finished, or Delete removed the last one. |
601
- | `showDeltas` | Show a distance's X, Y and Z parts as dashed legs in the axis colours. On by default. |
602
- | `format` | Write a length. Millimetres to two places by default; pass your own to show inches. |
603
- | `labelClassName` | Added to every label, beside `toolpath-measure-label`. |
604
- | `theme` | Override its colours — `measure` for lines and markers, `measureSnap` for the snap indicator. |
605
-
606
- Labels are DOM elements laid over the canvas and come **unstyled**: the tool is headless, and
607
- what a label looks like is your stylesheet's, through the `toolpath-measure-label` class and the
608
- data attributes on each one — see [Measuring](#measuring) for a starting point. While the tool is
609
- mounted the part reports no hovers or picks, as with `<SectionTool>`.
610
-
611
- ### `<ToolMarks>`
612
-
613
- Cutting tools drawn on the part, each to scale where it stands: a see-through end mill — flat,
614
- bull nose or ball — or any tool given by its profile, rimmed at its bottom and top, with a dimension
615
- across its top and a label on it. What a tool stands for is yours: the widest tool a pocket admits, the tool an operation uses.
616
- Nothing here reads a datasheet.
617
-
618
- ```tsx
619
- <EnginePart report={report}>
620
- <ToolMarks
621
- marks={[
622
- {
623
- base: { x: 12, y: 4, z: -8 },
624
- axis: { x: 0, y: 0, z: 1 },
625
- diameter: 6,
626
- height: 8,
627
- cornerRadius: 0.5,
628
- label: '⌀ 6.000 mm',
629
- note: 'bull nose R 0.5',
630
- },
631
- ]}
632
- />
633
- </EnginePart>
634
- ```
635
-
636
- | Prop | What it does |
637
- | ---------------- | ---------------------------------------------------------------------------------- |
638
- | `marks` | The tools, in the coordinates the part is drawn in. See `ToolMark` below. |
639
- | `visible` | `false` hides them without unmounting. On by default. |
640
- | `labelClassName` | Added to every label, beside `toolpath-measure-label`. |
641
- | `theme` | `measure` colours the rims and the dimension, as it does the measure tool's lines. |
642
-
643
- A `ToolMark` is `base` (the centre of the tool's bottom), `axis` (up the tool), `diameter`,
644
- `height`, and optionally `cornerRadius` (0 flat, half the diameter a ball), `across` (which way the
645
- dimension runs; any direction square to the axis otherwise), `label` and `note`. Any other tool —
646
- a drill, a chamfer mill, a tool in its holder — takes `profile`: its side as `{ r, z }` points from
647
- the tip up, such as `@toolpath/tool-drawing`'s outline, turned about the axis in place of the end
648
- mill.
649
-
650
- Show and hide them with `visible`, from the toolbar's `ToolsButton` and its `tools` control.
651
-
652
- It draws as `<MeasureTool>` draws: the lines show through the part, and each label is an unstyled
653
- `<div class="toolpath-measure-label" data-measure-label="tool">` with its text in a
654
- `data-measure-value` span and its note in a `data-measure-note` one. The marks take no pointer
655
- events and are left out of framing.
656
-
657
- Inside `<PartMesh>` or `<EnginePart>`, `usePartContext()` gives the part's `model` and `geometry`,
658
- and `featureTriangles(model, geometry, tag)` a feature's triangles, for working out where a tool
659
- stands.
660
-
661
- ### `<Grid>` and `<Axes>`
662
-
663
- Reference geometry. The camera ignores both when it frames the part.
664
-
665
- - **`<Grid>`** sits under the part and sizes itself to it: 5 mm squares under a 50 mm cube, 50 mm
666
- squares under a 900 mm plate. Props: `step` (square size in mm), `extent`, `color`,
667
- `opacity` (default `0.35`).
668
- - **`<Axes>`** draws X (red), Y (green), and Z (blue) from the origin. Prop: `size` (default `25`).
367
+ | Props | Behavior |
368
+ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
369
+ | `model`, `geometry` | Required normalized data and non-indexed geometry. |
370
+ | `selection`, `candidates` | Selected feature tags and secondary candidates. |
371
+ | `highlights`, `regionHighlights` | Feature/region colors. Hover and selection overlay the direction wash. |
372
+ | `pickedRegions`, `hoveredFeatureIds` | Application-driven region/feature feedback. |
373
+ | `hover`, `onHover`, `onPick` | Hover enablement and interaction callbacks. |
374
+ | `focus`, `focusFeature` | Selection X-ray rendering versus camera framing. |
375
+ | `activeDirection` | Candidate-direction index; `null`/omitted means all directions. |
376
+ | `display`, `showEdges` | `solid` or `wireframe`; outlines default on. Wireframe always shows semantic face boundaries, not triangle diagonals. |
377
+ | `sharpCorners` | `{ features: tags }` for externally flagged features, or `null`. |
378
+ | `section`, `onSectionChange` | Controlled section options and change reporting. Omit `section` to follow the viewer's section store. |
379
+ | `onAdjacency` | Receive touching-region sets for the prepared mesh. |
380
+ | `theme` | Partial `ViewerTheme` for the part. |
381
+ | `children` | R3F overlays that can read `usePartContext()`. |
382
+
383
+ With an active direction, picking ranks only reachable owners; a face with none
384
+ has `best === null`. `owners` still carries all owners. `directionHighlights(model,
385
+ index)` produces region paint matching `DirectionArrows`; it does not hide geometry
386
+ or establish that an unpainted face is impossible to machine.
387
+
388
+ ### `<ViewCube>`, `<Grid>`, and `<Axes>`
389
+
390
+ `ViewCube` defaults to `alignment="top-right"` and `margin={[80, 80]}` (pixels).
391
+ `onViewChange` receives a `ViewName`, including diagonal names. Its theme controls
392
+ cube colors. Grid chooses a step from part size unless you supply `step`/`extent`;
393
+ it accepts `color` and `opacity` (default 0.35). Axes has a `size` in millimetres
394
+ (default 25). These controls/reference objects do not enlarge normal part framing.
669
395
 
670
396
  ## Recipes
671
397
 
672
- ### Camera buttons
398
+ ### Toolbar and camera buttons
673
399
 
674
- Use a ref to move the camera from buttons outside the viewer:
400
+ This scene uses caller-owned geometry. The provider supplies actions; it does not
401
+ implement toggling, persistence, measurement state, or stock creation for you.
402
+ The containing DOM element is positioned so the toolbar sits over this viewer.
675
403
 
676
404
  ```tsx
677
- import { useRef } from 'react'
678
- import { Viewer, type ViewerHandle } from '@toolpath/viewer'
679
-
680
- const viewer = useRef<ViewerHandle>(null)
681
-
682
- <button onClick={() => viewer.current?.fit()}>Fit</button>
683
- <button onClick={() => viewer.current?.reset()}>Reset</button>
684
- <button onClick={() => viewer.current?.setView('top')}>Top</button>
685
- <button onClick={() => viewer.current?.setView('isometric')}>Iso</button>
686
-
687
- <Viewer ref={viewer}>…</Viewer>
688
- ```
689
-
690
- | Method | What it does |
691
- | --------------------------- | ------------------------------------------------------------------------------ |
692
- | `fit()` | Fit the whole part, keeping the current angle. |
693
- | `reset()` | Go back to the starting view. |
694
- | `setView(name)` | `'top'`, `'bottom'`, `'front'`, `'back'`, `'left'`, `'right'`, `'isometric'` |
695
- | `setViewDirection({x,y,z})` | Look at the part from any direction (a vector pointing toward the camera). |
696
- | `frameBox(box)` | Zoom to a `THREE.Box3`, keeping the current angle. |
697
- | `setSection(options)` | Set or clear (`null`) the viewer's own cut. See [Section view](#section-view). |
698
-
699
- Components rendered **inside** `<Viewer>` can get the same methods with `useViewerControls()`.
700
-
701
- ### Selecting from a list
405
+ import { useRef, useState } from 'react'
406
+ import type { BufferGeometry } from 'three'
407
+ import {
408
+ Axes,
409
+ Grid,
410
+ MeasureTool,
411
+ PartMesh,
412
+ SectionTool,
413
+ Stock,
414
+ ViewCube,
415
+ Viewer,
416
+ ViewerToolbar,
417
+ ViewerToolbarProvider,
418
+ type Measurement,
419
+ type PartModel,
420
+ type ViewerHandle,
421
+ } from '@toolpath/viewer'
422
+ import '@toolpath/viewer/toolbar.css'
702
423
 
703
- Keep the selection in your own state and share it between a feature list and the part. Hovering a
704
- list row highlights the feature on the part, and clicking the row zooms to it:
424
+ export const Workbench = ({ model, geometry }: { model: PartModel; geometry: BufferGeometry }) => {
425
+ const viewer = useRef<ViewerHandle>(null)
426
+ const [stock, setStock] = useState(false)
427
+ const [mode, setMode] = useState<'section' | 'measure' | null>(null)
428
+ const [measurements, setMeasurements] = useState<readonly Measurement[]>([])
429
+ const toggle = (next: 'section' | 'measure') =>
430
+ setMode((current) => (current === next ? null : next))
705
431
 
706
- ```tsx
707
- const [selected, setSelected] = useState<string | null>(null)
708
- const [hovered, setHovered] = useState<string | null>(null)
709
-
710
- <ul>
711
- {model.features.map((feature) => (
712
- <li
713
- key={feature.tag}
714
- onMouseEnter={() => setHovered(feature.tag)}
715
- onMouseLeave={() => setHovered(null)}
716
- onClick={() => setSelected(feature.tag)}
432
+ return (
433
+ <ViewerToolbarProvider
434
+ controls={{
435
+ stock: { pressed: stock, onClick: () => setStock((shown) => !shown) },
436
+ section: { pressed: mode === 'section', onClick: () => toggle('section') },
437
+ measure: { pressed: mode === 'measure', onClick: () => toggle('measure') },
438
+ fit: { onClick: () => viewer.current?.fit() },
439
+ reset: { onClick: () => viewer.current?.reset() },
440
+ top: { onClick: () => viewer.current?.setView('top') },
441
+ }}
717
442
  >
718
- {feature.featureType}
719
- </li>
720
- ))}
721
- </ul>
722
-
723
- <Viewer onPointerMissed={() => setSelected(null)}>
724
- <EnginePart
725
- report={report}
726
- selection={selected ? [selected] : []}
727
- hoveredFeatureIds={hovered ? [hovered] : []}
728
- focusFeature={selected}
729
- onPick={(pick) => setSelected(pick.best)}
730
- />
731
- </Viewer>
732
- ```
733
-
734
- `feature.tag` is the feature's ID. Always use it to refer to a feature, never the feature's
735
- position in the array.
736
-
737
- ### Clicking the same face again to go through its matches
738
-
739
- Clicking the same face repeatedly can step through its matching features, like most CAD tools do.
740
- `focusForPick` handles this:
741
-
742
- ```tsx
743
- const [focused, setFocused] = useState<string | null>(null)
744
- const [lastRegion, setLastRegion] = useState<number | null>(null)
745
-
746
- <EnginePart
747
- report={report}
748
- selection={focused ? [focused] : []}
749
- onPick={(pick) => {
750
- setFocused(focusForPick(pick, lastRegion, focused))
751
- setLastRegion(pick.region)
752
- }}
753
- />
754
- ```
755
-
756
- A click on a different face starts again from that face's best match. To show the other matches
757
- faintly, pass `pick.ranked` as `candidates`.
758
-
759
- ### Selecting several features with a modifier key
760
-
761
- The viewer tells you which keys were held and leaves the behaviour to you:
762
-
763
- ```tsx
764
- onPick={(pick) => {
765
- const tag = pick.best
766
- if (!tag) return
767
- if (pick.modifiers.shift) setSelection((current) => [...current, tag])
768
- else setSelection([tag])
769
- }}
770
- ```
771
-
772
- A double-click on the part also re-centres the view. The second click still calls `onPick`, with
773
- `pick.doubled` set to `true`. Ignore those clicks if a double-click shouldn't change your selection.
774
-
775
- ### Showing only one machining direction
776
-
777
- Pass the same index to the arrows and the part. Clicks then only match features machined from that
778
- direction. A face that can't be reached from that direction gives `pick.best === null`.
779
-
780
- ```tsx
781
- <EnginePart report={report} activeDirection={direction} onPick={onPick} />
782
- <DirectionArrows
783
- directions={model.candidateDirections}
784
- activeDirection={direction}
785
- onPickDirection={setDirection}
786
- />
787
- ```
788
-
789
- To list features by direction, use `groupByDirection(model)`. To label a direction, use
790
- `directionLabel(direction)`, which gives `+Z`, `−X`, or `0.707, 0, 0.707`. To colour a direction
791
- the same way as its arrow, use `directionColor(index)`.
792
-
793
- ### Showing sharp corners
794
-
795
- `sharpCorners` draws a part's sharp corners as red bands painted on the two faces that meet there: strong where you can see them, faint
796
- where the part is in front of them. **Which corners is the DFM rules' call, not the viewer's.** Pass
797
- the features the rules flag with a sharp corner — those whose `featureSheet(datasheet).sharpCorners`
798
- is set, from `@toolpath/dfm/model` — and the viewer draws each one's inside corners that run along
799
- its machining direction: the vertical corners between its walls that no end mill makes. Nothing else
800
- is drawn — not a hole's rim, not an outside edge, not a floor meeting a wall.
801
-
802
- The mesh only says where those corners are. It needs the closed mesh the Engine produces (every edge
803
- shared by exactly two triangles) and the report's regions, which tell a real edge from tessellation.
804
-
805
- ```tsx
806
- const flagged = Object.entries(sheets)
807
- .filter(([, sheet]) => sheet.sharpCorners)
808
- .map(([tag]) => tag)
809
-
810
- <ViewerToolbarProvider
811
- controls={{ sharpCorners: { pressed: shown, onClick: () => setShown((on) => !on) } }}
812
- >
813
- <ViewerToolbar />
814
- <Viewer>
815
- <EnginePart report={report} sharpCorners={shown ? { features: flagged } : null} />
816
- </Viewer>
817
- </ViewerToolbarProvider>
818
- ```
819
-
820
- `ViewerToolbar.SharpCornersButton` turns them on and off; there is nothing else to offer users.
821
- Tags are matched without regard to case, so the lower-cased keys an application keeps its sheets
822
- under work as they are.
823
-
824
- With `hover` on, the pointer on a corner is on the corner: it takes the theme's `hover` colour at
825
- the same width, the face under it is not painted as hovered, and the pick `onHover` reports carries it as
826
- `pick.sharpCorner`. That is a `SharpCorner`: the flagged `feature` it belongs to, its `start` and `end`,
827
- its `length` (how deep a tool would have to reach, mm), and the `angle` between its faces (90 for a
828
- square corner). What a hover card says about it is the application's call; with the feature's sheet,
829
- the widest tool that fits it (`pinchDiameter`) says what milling would leave there — that tool's radius.
830
-
831
- ```tsx
832
- <HoverCard pick={pick}>
833
- {(pick) =>
834
- pick.sharpCorner ? (
835
- <SharpCornerDetails corner={pick.sharpCorner} />
836
- ) : (
837
- <FeatureDetails pick={pick} />
838
- )
839
- }
840
- </HoverCard>
443
+ <div style={{ position: 'relative', height: 500 }}>
444
+ <Viewer ref={viewer}>
445
+ <PartMesh model={model} geometry={geometry} />
446
+ {stock && <Stock box={{ partGeometry: geometry, allowance: { wall: 3, floor: 3 } }} />}
447
+ {mode === 'section' && <SectionTool />}
448
+ {mode === 'measure' && (
449
+ <MeasureTool measurements={measurements} onChange={setMeasurements} />
450
+ )}
451
+ <Grid />
452
+ <Axes />
453
+ <ViewCube />
454
+ </Viewer>
455
+ <ViewerToolbar />
456
+ </div>
457
+ </ViewerToolbarProvider>
458
+ )
459
+ }
841
460
  ```
842
461
 
843
- `findSharpCorners(creases, model, options)` lists the same corners without a pointer.
844
-
845
- `features` is the only option. How sharp a fold must be to count, how closely it must follow the
846
- machining direction, and how the bands look are fixed: the rules have already decided which corners
847
- matter, and there is nothing left worth tuning. The colour is the `sharpCorner` theme key. To count
848
- or measure the corners yourself, use `surfaceCreases(geometry, model)` and
849
- `sharpCornerPositions(creases, model, options)`.
462
+ Call Fit after toggling stock if you want to frame the blank. When switching from
463
+ Section to Measure, the viewer's existing cut remains until cleared with Escape
464
+ while the section tool is active or `viewer.current?.setSection(null)`. The example
465
+ retains measurements in React state when the tool is unmounted.
850
466
 
851
- If your application paints the features a rule flags, leave that rule's paint off while the corners
852
- are shown. A corner drawn in the rule's colour disappears into paint of the same colour, and the
853
- corner is the rule's mark, more exactly than the paint is.
467
+ With no children, `ViewerToolbar` renders configured controls in standard order.
468
+ For custom composition use `ViewerToolbar.Controls`, `ViewerToolbar.Divider`, and
469
+ its `*Button` components. A button without a matching provider control throws.
470
+ The supported action names are `stock`, `axes`, `grid`, `banana`, `tools`,
471
+ `directions`, `dfm`, `sharpCorners`, `hover`, `focus`, `wireframe`, `section`,
472
+ `measure`, `fit`, `reset`, and `top`; button names capitalize the action, such as
473
+ `ViewerToolbar.SharpCornersButton`. `useViewerToolbar()` reads those controls.
854
474
 
855
- ### Custom colours
475
+ ### Stock and display controls
856
476
 
857
- Use `highlights` for colours that mean something in your app, such as difficulty, setup, or cost.
858
- Colours are hex numbers:
477
+ `Stock` takes exactly one source. Stock stays in part coordinates, is included in
478
+ Fit/Reset, and does not intercept part picks or follow its section cut.
479
+
480
+ | Source | Input |
481
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
482
+ | `geometry` | A caller-owned `BufferGeometry`; dispose it yourself after use. |
483
+ | `glb` | An `ArrayBuffer` of GLB bytes you fetched. Keep the buffer reference stable. Stock decodes/disposes its own geometry. |
484
+ | `box` preview | `{ partGeometry, allowance, offset? }` or `{ partGeometry, dimensions, position?, positionOffset?, offset? }`. |
485
+ | `box` resolved | `{ lower, upper, frame? }`. |
486
+ | `cylinder` preview | `{ partGeometry, diameter, length, position?, positionOffset? }`. |
487
+ | `cylinder` resolved | `{ origin, axis, diameter, length }`; origin is the base center, axis points along its length. |
488
+
489
+ Allowances are **per side**: `{ wall, floor }` expands X/Y and Z respectively;
490
+ a number or `{ x, y, z }` is also accepted. The Workbench's 3 mm allowance is an
491
+ example, not a stock recommendation. Fixed preview `dimensions` are `{ x, y, z }`.
492
+ Positions are `model_centered`, `offset_from_top`, and `offset_from_bottom`, with
493
+ `positionOffset` in millimetres. Preview options and resolved figures are mutually
494
+ exclusive. Use `boxStockBounds` / `fixedBoxStockBounds` for corresponding dimensions.
495
+
496
+ Centered stock is centered on the part's bounds. For top positioning its upper Z
497
+ is `part.max.z + positionOffset`; for bottom positioning its lower Z is
498
+ `part.min.z - positionOffset`. These rules also position cylinder previews along Z.
499
+
500
+ A box's optional frame has `location`, `axis`, and `refDirection`. Its local point
501
+ maps to `location + x·refDirection + y·(axis × refDirection) + z·axis`; the axis is
502
+ normalized and the reference direction projected perpendicular and normalized.
503
+ Without a frame, corners are already in part coordinates. A cylinder preview is
504
+ Z-aligned and centered on the part's bounding box. Treat previews as visualization;
505
+ prefer the operation's resolved stock when the selected API contract supplies it.
506
+
507
+ Negative/non-finite allowances, invalid coordinates, degenerate frames/axes, and
508
+ non-positive dimensions throw `RangeError`. GLB node transforms are applied without
509
+ centering, rescaling, or axis conversion. Stock does not fetch, cache, decompress
510
+ HTTP responses itself, or need an analysis report; the browser handles HTTP gzip.
511
+
512
+ Appearance props are `color`, `emissive`, `flatShading`, `opacity` (default 0.2),
513
+ `edgeColor`, `edgeOpacity`, and `showEdges`. `renderStyle="legacy-workpiece"` selects
514
+ the legacy two-pass material/edge appearance. With `showEdges={false}`, outlines
515
+ are not built until needed. `retainPrevious` applies to GLB playback: keep the
516
+ previous decode visible until the replacement is ready. Key the stock subtree by
517
+ part/calculation identity to prevent carrying geometry into a different run.
518
+ Without it, changing GLB bytes hides the previous geometry during decoding.
519
+
520
+ **Migration from 1.x:** replace removed `BoxStock` with `Stock`'s `box` source.
521
+ `StockProps` is a union; use an intersection type rather than extending it with an
522
+ interface. `StockAppearanceProps` describes just the shared appearance options.
523
+
524
+ ### Direction coloring
525
+
526
+ Keep the same candidate index in `PartMesh.activeDirection` and the arrows. Use
527
+ `shownDirection={null}` for all arrows, an index/list to choose arrows, or `-1`
528
+ for none. `activeDirection` also scopes arrows when `shownDirection` is omitted.
529
+ `previewDirection` and `namedDirections` add non-candidate directions.
859
530
 
860
531
  ```tsx
861
- const DIFFICULTY = { easy: 0x22c55e, medium: 0xeab308, hard: 0xef4444 }
532
+ import { useMemo, useState } from 'react'
533
+ import type { BufferGeometry } from 'three'
534
+ import {
535
+ DirectionArrows,
536
+ PartMesh,
537
+ Viewer,
538
+ directionHighlights,
539
+ type PartModel,
540
+ } from '@toolpath/viewer'
862
541
 
863
- <EnginePart
864
- report={report}
865
- highlights={features.map((f) => ({ tag: f.tag, color: DIFFICULTY[f.difficulty] }))}
866
- selection={selection}
867
- />
542
+ export const DirectionViewer = ({
543
+ model,
544
+ geometry,
545
+ }: {
546
+ model: PartModel
547
+ geometry: BufferGeometry
548
+ }) => {
549
+ const [direction, setDirection] = useState<number | null>(null)
550
+ const colors = useMemo(() => directionHighlights(model, direction), [model, direction])
551
+ return (
552
+ <Viewer style={{ height: 400 }}>
553
+ <PartMesh
554
+ model={model}
555
+ geometry={geometry}
556
+ activeDirection={direction}
557
+ regionHighlights={colors}
558
+ />
559
+ <DirectionArrows
560
+ directions={model.candidateDirections}
561
+ shownDirection={direction}
562
+ onPickDirection={(index) => setDirection((current) => (current === index ? null : index))}
563
+ />
564
+ </Viewer>
565
+ )
566
+ }
868
567
  ```
869
568
 
870
- A face shows only one colour. When several layers apply, the one lower in this list wins:
871
-
872
- 1. `highlights` (your colours, per feature)
873
- 2. `regionHighlights` (your colours, per face)
874
- 3. `candidates` (faint, per direction)
875
- 4. `selection` (orange)
876
- 5. `pickedRegions`
877
- 6. `hoveredFeatureIds`
878
- 7. the face under the pointer
879
-
880
- `weight` (`0`–`1`, default `0.7`) sets how strongly a highlight covers the part.
881
-
882
- Changing colours is fast, even when every feature on a large part is highlighted.
569
+ Arrows point toward the part. Picking an arrow does not clear selection through
570
+ `onPointerMissed`. Use `groupByDirection`, `directionLabel`, and `directionColor`
571
+ for a matching list/legend. Wireframe and direction coloring are independent props;
572
+ any mutual exclusion in the example app is application behavior.
883
573
 
884
574
  ### Section view
885
575
 
886
- The quickest way is `<SectionTool>`. Put it inside `<Viewer>` next to the part and give `PartMesh`
887
- no `section` prop:
576
+ Mount `SectionTool` inside `Viewer` beside a part with no explicit `section` prop.
577
+ It previews the surface under the pointer, places a cut on click, and offers X/Y/Z
578
+ planes. A drag handle moves the cut; Escape clears it while the tool is mounted.
579
+ It writes the viewer-owned section store and suppresses normal part hover/picks
580
+ while active. Unmounting the tool restores part interactions, not the uncut solid.
888
581
 
889
- ```tsx
890
- <Viewer ref={viewer}>
891
- <EnginePart report={report} />
892
- {sectioning ? <SectionTool /> : null}
893
- </Viewer>
894
- ```
895
-
896
- Hovering the part previews a cut through the face under the pointer; clicking places it. Three
897
- coloured planes stand behind the part — click one to cut along that axis, in from your side. Once
898
- there is a cut, a framed plane and two-way normal-axis handle show exactly what will move; drag the
899
- handle to move it. Keep the canvas clear by presenting `sectionMeasurement(state)` beside the host
900
- application's section slider; it reports the depth from a picked surface, or the distance swept
901
- through the part bounds. Escape clears it.
902
- The cut belongs to the viewer: `viewer.current.setSection(null)` clears it from a button outside
903
- the canvas, `setSection({ enabled: true, normal, offset })` sets one, and `onSectionChange` on
904
- `PartMesh` still reports every move.
905
-
906
- While the tool is mounted, the part reports no hovers or picks — clicking a face cuts through it
907
- without also selecting it. Unmount the tool, as above, and they come back.
908
-
909
- To drive the cut yourself instead, pass `section`. `normal` points toward the half you keep.
910
- `offset` goes from `0` (whole part) to `1` (fully cut away).
582
+ For application-controlled cuts, supply `section` directly. Here `normal` points
583
+ toward the retained half; `offset` ranges from 0 (whole part) to 1 (cut away).
911
584
 
912
585
  ```tsx
913
- const [cutting, setCutting] = useState(false)
914
- const [offset, setOffset] = useState(0.5)
915
-
916
- <input type="checkbox" checked={cutting} onChange={(e) => setCutting(e.target.checked)} />
917
- <input type="range" min={0} max={1} step={0.01} value={offset}
918
- onChange={(e) => setOffset(Number(e.target.value))} />
919
-
920
- <EnginePart
921
- report={report}
922
- section={{ enabled: cutting, normal: { x: 0, y: 0, z: 1 }, offset }}
923
- onSectionChange={(state) => setOffset(state.offset)}
924
- />
586
+ import { useState } from 'react'
587
+ import type { BufferGeometry } from 'three'
588
+ import { PartMesh, Viewer, type PartModel } from '@toolpath/viewer'
589
+
590
+ export const SectionViewer = ({
591
+ model,
592
+ geometry,
593
+ }: {
594
+ model: PartModel
595
+ geometry: BufferGeometry
596
+ }) => {
597
+ const [offset, setOffset] = useState(0.5)
598
+ return (
599
+ <>
600
+ <label>
601
+ Section offset
602
+ <input
603
+ type="range"
604
+ min="0"
605
+ max="1"
606
+ step="0.01"
607
+ value={offset}
608
+ onChange={(event) => setOffset(Number(event.target.value))}
609
+ />
610
+ </label>
611
+ <Viewer style={{ height: 400 }}>
612
+ <PartMesh
613
+ model={model}
614
+ geometry={geometry}
615
+ section={{ enabled: true, normal: { x: 0, y: 0, z: 1 }, offset }}
616
+ onSectionChange={(state) => setOffset(state.offset)}
617
+ />
618
+ </Viewer>
619
+ </>
620
+ )
621
+ }
925
622
  ```
926
623
 
927
- The cut surface is filled in with a hatch and outlined, so the part doesn't look hollow and the cut
928
- reads as a cut. Passing `onSectionChange` also shows the draggable plane-frame gizmo. The handler is
929
- called on every drag and only when the cut actually changes, so it's safe to store the value in
930
- state; a cut going away is reported once, with `enabled: false`.
931
-
932
- **Cutting at a clicked surface.** `sectionFromPick` places the cut at the surface the user clicked.
933
- `depth` moves it into the part, in millimetres:
934
-
935
- ```tsx
936
- const [plane, setPlane] = useState<SectionPlacement | null>(null)
937
-
938
- <EnginePart
939
- report={report}
940
- section={{ enabled: plane !== null, plane, depth: 1 }}
941
- onPick={({ point: [x, y, z], normal: [nx, ny, nz] }) =>
942
- setPlane(sectionFromPick({ point: { x, y, z }, normal: { x: nx, y: ny, z: nz } }))
943
- }
944
- />
945
- ```
624
+ Passing `onSectionChange` also enables the controlled cut's drag handle. The callback
625
+ reports `SectionState`, including `enabled`, `normal`, `offset`, `plane`, `depth`,
626
+ `constant`, and depth bounds. Use `sectionFromPick({ point, normal })` to create a
627
+ surface placement, then `{ enabled: true, plane, depth }` to cut inward by a depth
628
+ in millimetres. Pick tuples must be converted to `{ x, y, z }` for that helper.
629
+ `sectionMeasurement(state)` supplies a readout for your own slider or label.
946
630
 
947
631
  ### Measuring
948
632
 
949
- Mount `<MeasureTool>` next to the part. It needs nothing else:
633
+ `MeasureTool` mounts inside `Viewer`. Two clicks measure distance; three measure an
634
+ angle, with the vertex as the middle point. Snapping prefers corners, edge
635
+ midpoints, edges, then faces. `mode` is `distance` or `angle`; `showDeltas={false}`
636
+ hides a distance's X/Y/Z legs. `onChange` reports `readonly Measurement[]`; pass
637
+ `measurements` too to control and retain the list, as the Workbench does.
950
638
 
951
- ```tsx
952
- const [measuring, setMeasuring] = useState(false)
953
- const [mode, setMode] = useState<MeasureMode>('distance')
954
- const [measurements, setMeasurements] = useState<readonly Measurement[]>([])
955
-
956
- <Viewer>
957
- <EnginePart report={report} />
958
- {measuring ? <MeasureTool mode={mode} onChange={setMeasurements} /> : null}
959
- </Viewer>
960
- ```
639
+ Shift constrains the next point to an axis. Escape cancels points in progress;
640
+ Delete/Backspace removes the last measurement unless focus is in a form field.
641
+ Normal part hover/picks are suppressed while measuring. Without controlled state,
642
+ unmounting the tool drops its measurements.
643
+
644
+ The tool measures the visible sectioned geometry, including the cap and clipped
645
+ edges. If a mounted SectionTool has no cut yet, measuring waits so its first
646
+ placement click is not also a measurement. Existing measurements remain visible.
961
647
 
962
- Hover the part and a marker shows where a click will land: a corner, an edge's midpoint, a point
963
- on an edge, or a point on the face, in that order of preference. It snaps to the edges the part
964
- draws — the boundaries between analytic surfaces — so a corner is a corner of the part, not of a
965
- mesh triangle. Click twice for a distance or three times for an angle. The line follows the pointer
966
- between clicks with a live readout.
967
-
968
- A distance is labelled with its length and, unless it already runs along an axis, broken into its
969
- X, Y and Z parts as dashed legs in the axis colours with a label each. `showDeltas={false}` turns
970
- the legs off. An angle draws its two arms and an arc, labelled in degrees.
971
-
972
- `onChange` reports the list whenever it changes. Each entry is a `Measurement`: `kind`, an `id`,
973
- and its `points` — two for a distance, three for an angle with the vertex in the middle. To own the
974
- list, pass `measurements` too; to clear it, unmount the tool or pass `[]`. `measurementLabel(m)`
975
- writes an entry the way the tool does, and `distanceBetween`, `deltaBetween` and `angleAt` are the
976
- arithmetic behind it.
977
-
978
- **Styling the labels.** Every label is a `<div class="toolpath-measure-label">` with
979
- `data-measure-label` set to `distance`, `angle`, `delta` (one leg of a distance's X, Y, Z breakdown,
980
- with `data-axis`) or `live` (the readout that follows the pointer), and `data-measurement-id` on
981
- anything belonging to a finished measurement. Nothing else is set: no font, no colour, no
982
- background. `labelClassName` adds your own class, for Tailwind or a CSS module. A stylesheet to
983
- start from, which is what the example uses:
648
+ Labels are unstyled DOM overlays with class `toolpath-measure-label`; add CSS such as:
984
649
 
985
650
  ```css
986
651
  .toolpath-measure-label {
@@ -988,355 +653,300 @@ start from, which is what the example uses:
988
653
  font:
989
654
  600 12px/1.2 system-ui,
990
655
  sans-serif;
991
- color: #fff;
992
- background: rgba(20, 23, 33, 0.85);
656
+ color: white;
657
+ background: #141721dd;
993
658
  border: 1px solid #4f8ef7;
994
659
  border-radius: 4px;
995
660
  padding: 2px 6px;
996
- transform: translateY(-16px);
997
661
  }
998
662
  .toolpath-measure-label[data-measure-label='delta'] {
999
663
  font-size: 11px;
1000
- transform: translateY(14px);
1001
- }
1002
- .toolpath-measure-label[data-axis='x'] {
1003
- border-color: #ff6b6b;
1004
664
  }
1005
665
  ```
1006
666
 
1007
- Labels take no pointer events, so one lying over the part never takes a click meant for the face
1008
- under it. The lines, markers and arc are drawn on the canvas and themed through `measure`.
667
+ `data-measure-label` identifies `distance`, `angle`, `delta`, `live`, or tool labels;
668
+ axis legs also have `data-axis`, and finished labels have `data-measurement-id`.
669
+ `labelClassName` adds a class. Labels do not take pointer events. Lengths default to
670
+ millimetres; the `format(mm)` callback changes their displayed string, not geometry
671
+ units or angles (which remain degrees).
1009
672
 
1010
- Lengths are written in millimetres by default. The part is in millimetres and the viewer does not
1011
- convert, so pass `format` to show anything else:
673
+ ### Showing sharp corners and tool marks
1012
674
 
1013
- ```tsx
1014
- <MeasureTool format={(mm) => `${(mm / MM_PER_INCH).toFixed(3)} in`} />
1015
- ```
1016
-
1017
- **Measuring a cut part.** Mount the measure tool with a section cut in place, or beside
1018
- `<SectionTool>`, and it measures what is on screen: the half a cut has removed is not there to snap
1019
- to, the part's edges stop at the plane, the outline where the plane passes through solid material
1020
- is an edge with corners of its own, and the capped face is a surface a point can land on. The
1021
- sample follows the cut — drag the handle or set a depth and the next pointer move sees the new cut.
1022
- It reads the plane off the part's own material, so a controlled `section` prop works the same way.
1023
- Beside a `<SectionTool>` that has no cut yet, the measure tool waits: it offers no snap and places
1024
- no point until a cut is chosen or the section tool is unmounted, so the click that picks the cut is
1025
- not also the first point of a measurement. Measurements already made stay up meanwhile.
1026
-
1027
- Hold Shift to hold the next point to the X, Y or Z line through the last one — whichever axis the
1028
- pointer is furthest along — so a length along an edge is measured square rather than slightly across
1029
- it. The line wears that axis's colour while it is held, and the finished distance has no legs to
1030
- show. Delete or Backspace removes the last measurement, unless focus is in a field. Escape drops the
1031
- points of a measurement in progress. While the tool is mounted the part reports no hovers or picks;
1032
- unmounting it hands the pointer back.
1033
-
1034
- ### Theming
1035
-
1036
- ```tsx
1037
- <Viewer theme={{ ambientIntensity: 1.5 }}>
1038
- <EnginePart report={report} theme={{ part: 0xe5e7eb, highlight: 0x2563eb, hover: 0x93c5fd }} />
1039
- <ViewCube theme={{ cube: 0x1f2937, cubeLabel: 0xffffff }} />
1040
- </Viewer>
1041
- ```
1042
-
1043
- Colours are hex numbers. Lighting goes on `<Viewer>`, part colours go on the part, and cube colours
1044
- go on the cube. See `DEFAULT_THEME` for every key. The default part colours are tuned for the
1045
- default lighting, so if you change one, check the other.
1046
-
1047
- The section cut is themed on the part too: `sectionCap` and `sectionHatch` are the cap's fill and
1048
- hatch lines, `sectionHandle` overrides the drag arrow's colour, and `sectionOutline` the
1049
- cutting-plane sheet and preview that `<SectionTool>` draws. Without a `sectionHandle` override, the
1050
- drag arrow uses the cutting plane's direction colour. `measure` and `measureSnap` are
1051
- `<MeasureTool>`'s lines and its snap indicator; a distance's X, Y, Z legs take `AXIS_COLORS`, the
1052
- same hues as the section tool's global planes. `sharpCorner` is the colour of
1053
- [sharp corners](#showing-sharp-corners).
1054
-
1055
- `HIGHLIGHT_COLORS` has the standard selection colours (`default`, `toolIssue`, `geometryIssue`).
1056
- `DIRECTION_COLORS` has the 9 direction colours, which repeat for parts with more than 9 directions.
1057
-
1058
- ### Drawing your own objects in the scene
1059
-
1060
- Anything React Three Fiber can draw can go inside `<Viewer>`, and the camera includes it when it
1061
- frames the scene. To leave an object out of framing, as `<Grid>` does, set
1062
- `userData={{ [EXCLUDE_FROM_FRAME]: true }}`.
1063
-
1064
- Use `useContentBox()` inside the viewer to get the part's bounding box, for example to size your own
1065
- object.
1066
-
1067
- ## Errors
1068
-
1069
- These are all thrown, so an error boundary catches them.
1070
-
1071
- | Error | Import from | When |
1072
- | ------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
1073
- | `PartReportFormatError` | `@toolpath/viewer` | The report is malformed. `error.issues` lists every problem. |
1074
- | `UnsupportedKernelVersionError` | `@toolpath/viewer` | The report is from an Engine kernel older than `0.3.0`. Re-analyze the part. |
1075
- | `PartMeshError` | `@toolpath/viewer/engine` | The mesh download failed (usually an expired URL: fetch the report again), or the mesh doesn't match the report. |
1076
- | `AggregateError` | — | Both the GLB and the STL failed. `error.errors` has one error per file. |
675
+ DFM decisions belong to the application. Pass flagged feature tags as
676
+ `sharpCorners={{ features: tags }}`; the viewer draws qualifying inside corners
677
+ along those features' machining directions. It does not flag features or guess
678
+ missing manufacturing data. Use a closed mesh with report region boundaries;
679
+ `findSharpCorners`/`sharpCornerPositions` operate on `surfaceCreases` for custom
680
+ inspection. A hovered corner appears as `pick.sharpCorner` with its feature,
681
+ endpoints, length, and angle. Feature tag matching for these flags is case-insensitive.
1077
682
 
1078
- ```tsx
1079
- import { PartReportFormatError, UnsupportedKernelVersionError } from '@toolpath/viewer'
1080
-
1081
- function PartError({ error }: { error: Error }) {
1082
- if (error instanceof UnsupportedKernelVersionError) return <p>Re-analyze this part to view it.</p>
1083
- if (error instanceof PartReportFormatError)
1084
- return <p>Invalid report: {error.issues.join(', ')}</p>
1085
- return <p>Could not load this part. Refresh to try again.</p>
1086
- }
1087
- ```
1088
-
1089
- The viewer does not read STEP files. The Engine analyzes STEP files and produces the mesh the
1090
- viewer displays.
1091
-
1092
- ## Working with the Toolpath API
1093
-
1094
- Use [`@toolpath/api`](https://www.npmjs.com/package/@toolpath/api) to get parts. Call it **from your
1095
- server**, so your API key stays private, and pass the result to the browser.
1096
-
1097
- The API can give you two things to show:
1098
-
1099
- - **An analyzed part** (`getPart`): faces and features. Use it with `<EnginePart>`.
1100
- - **A display mesh** (`getPartMesh`): faces only, but much faster, because the part isn't
1101
- analyzed. Use it with `<PartMesh>`.
1102
-
1103
- Both need a part whose CAD file is already uploaded. Their mesh URLs expire after 15 minutes, so
1104
- don't cache the responses.
1105
-
1106
- ### Analyzed part
683
+ Mount `ToolMarks` under a part to illustrate supplied cutters or profiles. For
684
+ example, this is a 6 mm bull-nose cutter standing along +Z:
1107
685
 
1108
686
  ```ts
1109
- // server
1110
- import { createToolpathClient } from '@toolpath/api'
1111
-
1112
- const api = createToolpathClient({ apiKey: process.env.TOOLPATH_API_KEY! })
687
+ import type { ToolMark } from '@toolpath/viewer'
1113
688
 
1114
- export const getReport = (partId: string) => api.parts.getPart({ id: partId })
1115
- ```
1116
-
1117
- ```tsx
1118
- // browser
1119
- <Viewer>
1120
- <EnginePart report={report} />
1121
- </Viewer>
689
+ export const toolMarks: Array<ToolMark> = [
690
+ {
691
+ base: { x: 12, y: 4, z: -8 },
692
+ axis: { x: 0, y: 0, z: 1 },
693
+ diameter: 6,
694
+ height: 8,
695
+ cornerRadius: 0.5,
696
+ label: '⌀ 6 mm',
697
+ note: 'Bull nose R 0.5',
698
+ },
699
+ ]
1122
700
  ```
1123
701
 
1124
- ### Display mesh
702
+ Use `ToolMarks`'s `marks` prop for that array. A `ToolMark` can instead supply a
703
+ rotational `profile` of `{ r, z }` points. `visible` toggles display;
704
+ `labelClassName` styles labels. Marks use the measure-label CSS hook with
705
+ `data-measure-label="tool"`, optional value/note spans, take no pointer events,
706
+ and are excluded from camera framing. They do not read datasheets themselves.
1125
707
 
1126
- On the server, start a mesh job, wait for it, then get the mesh:
708
+ ### Theming
1127
709
 
1128
- ```ts
1129
- // server
1130
- export async function getMesh(partId: string) {
1131
- const { jobId } = await api.parts.createPartMesh({ id: partId })
1132
-
1133
- let job = await api.jobs.getJob({ id: jobId })
1134
- while (job.status === 'queued' || job.status === 'running') {
1135
- await new Promise((resolve) => setTimeout(resolve, 1000))
1136
- job = await api.jobs.getJob({ id: jobId })
1137
- }
1138
- if (job.status === 'failed') throw new Error(job.error ?? 'Meshing failed')
1139
-
1140
- return api.parts.getPartMesh({ id: partId, jobId })
1141
- }
1142
- ```
710
+ Colors in `ViewerTheme` are numeric hex values. Set lighting on `Viewer`, part
711
+ colors on `PartMesh`/`EnginePart`, and overlay colors on their components; passing
712
+ part colors only to `Viewer` does not theme its children automatically.
713
+ `DEFAULT_THEME` lists the keys. Selection palettes are `HIGHLIGHT_COLORS`; direction
714
+ colors cycle through `DIRECTION_COLORS`.
715
+
716
+ Part keys include `part`, `partEmissive`, `hover`, `highlight`, `picked`, `edge`,
717
+ `edgeOpacity`, and `sharpCorner`. Section keys include `sectionCap`, `sectionHatch`,
718
+ `sectionOutline`, `sectionHandle`, and `sectionHandleOutline`. Measurement tools
719
+ use `measure`/`measureSnap`; cube keys are `cube`, `cubeEdge`, and `cubeLabel`.
720
+ The canvas background comes from the wrapper/parent, independently of those colors.
721
+
722
+ Stable DOM classes include `viewer-root`, `viewer-canvas-container`,
723
+ `viewer-canvas-frame`, `viewer-canvas`, `viewer-hover-card`, `viewer-toolbar-stack`,
724
+ `viewer-toolbar`, `viewer-toolbar-button`, `viewer-toolbar-icon`,
725
+ `viewer-toolbar-tooltip`, and `viewer-toolbar-divider`. Caller `className` values
726
+ append to the component's hook. Corresponding `data-viewer-*` attributes identify
727
+ the DOM boundaries; `data-viewer-toolbar-action` contains the control's action name.
728
+ Style the hover card yourself; it is a pointer-events-free fixed-position shell.
729
+
730
+ Custom R3F objects can go inside `Viewer` and participate in framing. Set
731
+ `userData={{ [EXCLUDE_FROM_FRAME]: true }}` to exclude an object. Use the exported
732
+ `EXCLUDE_FROM_FRAME` constant, not a guessed string.
1143
733
 
1144
- If the part already has a display mesh, skip the job and call `api.parts.getPartMesh({ id })`.
734
+ ## Working with the Toolpath API
1145
735
 
1146
- In the browser, turn the response into a model. `faceTriangleCounts` says how many triangles each
1147
- face has, so each count becomes one face:
736
+ Keep analysis/job orchestration outside the viewer. A server-side
737
+ [`@toolpath/api`](https://www.npmjs.com/package/@toolpath/api) integration can fetch a
738
+ completed report and pass it to the browser. Deliberate browser API access needs
739
+ an appropriately scoped, allowed-origin key; a browser-delivered key is observable
740
+ and is not made secret by CORS. See the [authentication guide](https://developers.toolpath.com/authentication).
741
+ The viewer's mesh loader does not attach an Engine API key to artifact requests.
742
+
743
+ ### Analyzed part versus display mesh
744
+
745
+ A completed analysis report includes features and regions for `EnginePart`.
746
+ A display-mesh response has geometry and face counts without analysis features;
747
+ it is not an `EnginePart` report. Use `PartMesh` with a model whose region ranges
748
+ come from that display mesh's `faceTriangleCounts`. Without them, a single region
749
+ can cover all triangles. Such a model has no feature owners, so `pick.best` is
750
+ `null`; use `pickedRegions` and `regionHighlights` for face feedback. Its face
751
+ indices do not identify analysis-report regions, and synthetic area values do not
752
+ establish analytic measurements or DFM results.
753
+
754
+ ### Mesh loading and ownership
755
+
756
+ For custom fetching, backend proxies, or local files, use `/engine` loaders and
757
+ render `PartMesh`. `EnginePart` itself has no custom-fetch prop.
758
+ `loadPartMesh(mesh, { fetch?, signal? })` tries GLB then STL;
759
+ `loadPartGeometry(url, counts, { format?, fetch?, signal? })` loads one artifact;
760
+ `parsePartGeometry(bytes, counts, format?)` parses bytes without fetching.
761
+ All produce caller-owned, non-indexed geometry, preserve triangle ordering and
762
+ coordinates, and validate counts against the supplied expectation. Call
763
+ `smoothRegionNormals(geometry, model.regions)` to smooth curved regions while
764
+ preserving their boundaries.
765
+
766
+ The counts argument is `{ pointCount, triangleCount }`, as in `model.mesh`; raw
767
+ API fields named `meshPointCount`/`meshTriangleCount` must be mapped deliberately.
768
+
769
+ This loader handles cancellation, failures, changed reports, and owned geometry:
1148
770
 
1149
771
  ```tsx
1150
- // browser
1151
772
  import { useEffect, useMemo, useState } from 'react'
1152
773
  import type { BufferGeometry } from 'three'
1153
- import type { PartMeshResponse } from '@toolpath/api'
1154
- import { PartMesh, Viewer, buildRegionIndex, type PartModel } from '@toolpath/viewer'
1155
- import { loadPartMesh } from '@toolpath/viewer/engine'
1156
-
1157
- function toModel(mesh: PartMeshResponse): PartModel {
1158
- let start = 0
1159
- const regions = (mesh.faceTriangleCounts ?? [mesh.meshTriangleCount]).map((count, idx) => {
1160
- const triangles = { start, end: start + count }
1161
- start += count
1162
- return { idx, splitOrigin: idx, shapeKind: 'Unknown', area: 0, triangles }
1163
- })
1164
- const triangleCount = mesh.meshTriangleCount
1165
-
1166
- return {
1167
- partId: mesh.partId,
1168
- kernelVersion: '',
1169
- features: [],
1170
- regions,
1171
- candidateDirections: [],
1172
- mesh: {
1173
- pointCount: mesh.meshPointCount,
1174
- triangleCount,
1175
- glbUrl: mesh.meshGlbUrl,
1176
- stlUrl: null,
1177
- thumbnailUrl: null,
1178
- },
1179
- regionIndex: buildRegionIndex({ regions, features: [], triangleCount }),
1180
- warnings: [],
1181
- }
1182
- }
1183
-
1184
- export function MeshViewer({ mesh }: { mesh: PartMeshResponse }) {
1185
- const model = useMemo(() => toModel(mesh), [mesh])
1186
- const [geometry, setGeometry] = useState<BufferGeometry | null>(null)
774
+ import { PartMesh, Viewer, type PartModel } from '@toolpath/viewer'
775
+ import {
776
+ loadPartMesh,
777
+ normalizePartReport,
778
+ smoothRegionNormals,
779
+ type FetchLike,
780
+ } from '@toolpath/viewer/engine'
1187
781
 
1188
- useEffect(() => {
1189
- loadPartMesh(model.mesh).then(setGeometry)
1190
- }, [model])
782
+ type Loaded = { model: PartModel; request: FetchLike | undefined; geometry: BufferGeometry }
783
+ type Failure = { model: PartModel; request: FetchLike | undefined; error: Error }
1191
784
 
1192
- if (!geometry) return <p>Loading…</p>
785
+ export const ManagedPart = ({ report, request }: { report: unknown; request?: FetchLike }) => {
786
+ const model = useMemo(() => normalizePartReport(report), [report])
787
+ const [loaded, setLoaded] = useState<Loaded | null>(null)
788
+ const [failure, setFailure] = useState<Failure | null>(null)
1193
789
 
790
+ useEffect(() => {
791
+ const controller = new AbortController()
792
+ let active = true
793
+ let owned: BufferGeometry | undefined
794
+ const load = async () => {
795
+ const geometry = await loadPartMesh(model.mesh, { fetch: request, signal: controller.signal })
796
+ if (!active) {
797
+ geometry.dispose()
798
+ return
799
+ }
800
+ try {
801
+ smoothRegionNormals(geometry, model.regions)
802
+ } catch (error) {
803
+ geometry.dispose()
804
+ throw error
805
+ }
806
+ owned = geometry
807
+ setLoaded({ model, request, geometry })
808
+ }
809
+ void load().catch((error: unknown) => {
810
+ if (active) {
811
+ setFailure({
812
+ model,
813
+ request,
814
+ error: error instanceof Error ? error : new Error(String(error)),
815
+ })
816
+ }
817
+ })
818
+ return () => {
819
+ active = false
820
+ controller.abort()
821
+ owned?.dispose()
822
+ }
823
+ }, [model, request])
824
+
825
+ if (failure?.model === model && failure.request === request) throw failure.error
826
+ if (loaded?.model !== model || loaded.request !== request) return <p>Loading mesh…</p>
1194
827
  return (
1195
- <Viewer style={{ height: 500 }}>
1196
- <PartMesh model={model} geometry={geometry} onPick={(pick) => console.log(pick.region)} />
828
+ <Viewer style={{ height: 400 }}>
829
+ <PartMesh model={model} geometry={loaded.geometry} />
1197
830
  </Viewer>
1198
831
  )
1199
832
  }
1200
833
  ```
1201
834
 
1202
- In a real app, also handle a failed download and call `geometry.dispose()` when you're done.
1203
-
1204
- A display mesh has no features, so a click gives you only the face (`pick.region`), and
1205
- `pick.best` is always `null`. Use `pickedRegions` and `regionHighlights` to colour faces. Don't pass
1206
- a display mesh to `<EnginePart>`: its faces are numbered differently from an analyzed part's.
1207
-
1208
- ## Using it without the Engine
1209
-
1210
- `<PartMesh>` takes a `PartModel` (the part's faces and features) and a three.js `BufferGeometry`
1211
- (the mesh). Use it to show a local file, a test fixture, or geometry you created yourself.
1212
-
1213
- ```tsx
1214
- import * as THREE from 'three'
1215
- import { PartMesh, Viewer, buildRegionIndex, type PartModel } from '@toolpath/viewer'
835
+ Use an error boundary around `ManagedPart`, as in the quick start. Keep an injected
836
+ `request` function stable; changing it starts a new load. The component hides the
837
+ previous model while the replacement loads, aborts on cleanup, and disposes a
838
+ late result if the loader completed after cancellation. It does not use the shared
839
+ Engine geometry cache.
1216
840
 
1217
- // A 20 mm cube. Each face is a region made of 2 triangles.
1218
- const geometry = new THREE.BoxGeometry(20, 20, 20).toNonIndexed()
841
+ ### Expiring URLs and cache
1219
842
 
1220
- const regions = [0, 1, 2, 3, 4, 5].map((idx) => ({
1221
- idx,
1222
- splitOrigin: idx,
1223
- shapeKind: 'Plane',
1224
- area: 400,
1225
- triangles: { start: idx * 2, end: idx * 2 + 2 }, // [start, end)
1226
- }))
843
+ Artifact URLs are short-lived. Fetch promptly and obtain fresh URLs through the
844
+ owning API when needed; do not store them as durable identifiers or log them. URL
845
+ expiry belongs to the signed URL, not the age of the immutable analysis result.
846
+ An error-boundary reset alone cannot renew an expired URL. Browser artifact fetches
847
+ also need the artifact host's CORS permission, independently of API-key origins.
1227
848
 
1228
- const features = [
1229
- {
1230
- tag: 'top',
1231
- featureType: 'face',
1232
- machiningDirection: { x: 0, y: 0, z: 1 },
1233
- axis: null,
1234
- regionIdxs: [4],
1235
- },
1236
- {
1237
- tag: 'sides',
1238
- featureType: 'wall',
1239
- machiningDirection: { x: 0, y: 0, z: 1 },
1240
- axis: null,
1241
- regionIdxs: [0, 1, 2, 3],
1242
- },
1243
- ]
1244
-
1245
- const model: PartModel = {
1246
- partId: 'cube',
1247
- kernelVersion: '0.3.0',
1248
- features,
1249
- regions,
1250
- candidateDirections: [{ x: 0, y: 0, z: 1 }],
1251
- mesh: { pointCount: 36, triangleCount: 12, glbUrl: null, stlUrl: null, thumbnailUrl: null },
1252
- regionIndex: buildRegionIndex({ regions, features, triangleCount: 12 }),
1253
- warnings: [],
1254
- }
849
+ `EnginePart` uses a shared reference-aware geometry cache with a default capacity
850
+ of eight entries. Mounted parts retain their resources; released entries are
851
+ LRU-evicted/disposed when capacity is exceeded. The key uses GLB/STL URLs without query strings so renewed signatures reuse geometry. Proxy paths therefore
852
+ need distinct artifact identities; putting all identities only in query parameters
853
+ can collide. Keep the report's region mapping compatible with the cached artifact.
854
+ Use `engineGeometryCache.clear()` only after consumers have unmounted: it disposes
855
+ cached geometry, including retained resources.
1255
856
 
1256
- export const Cube = () => (
1257
- <Viewer style={{ height: 400 }}>
1258
- <PartMesh model={model} geometry={geometry} onPick={(pick) => console.log(pick.ranked)} />
1259
- </Viewer>
1260
- )
1261
- ```
1262
-
1263
- Requirements:
1264
-
1265
- - **The mesh must be non-indexed** (call `.toNonIndexed()`). Each vertex can belong to only one
1266
- face.
1267
- - **Regions must cover every triangle, with no gaps or overlaps.** `buildRegionIndex` throws if
1268
- they don't.
1269
- - **Create the geometry once** (with `useMemo` or at module level). `PartMesh` doesn't dispose of
1270
- it, so you're responsible for that.
1271
-
1272
- If you have a report and want to load the mesh yourself, for example through your own backend,
1273
- use the functions in `@toolpath/viewer/engine`:
1274
-
1275
- ```ts
1276
- import { loadPartMesh, normalizePartReport, smoothRegionNormals } from '@toolpath/viewer/engine'
857
+ ## Errors
1277
858
 
1278
- const model = normalizePartReport(report) // throws on a bad report
1279
- const geometry = await loadPartMesh(model.mesh, { fetch: myFetch, signal })
1280
- smoothRegionNormals(geometry, model.regions) // curved faces look smooth, edges stay sharp
1281
- ```
859
+ | Error | Export | Handling |
860
+ | ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------- |
861
+ | `PartReportFormatError` | Root | Inspect `issues`; reject malformed reports or mismatched region/triangle mappings. |
862
+ | `UnsupportedKernelVersionError` | Root | `kernelVersion` is below `MIN_KERNEL_VERSION` (currently `0.3.0`); request compatible analysis data. |
863
+ | `PartMeshError` | `/engine` | No artifact, HTTP failure, unreadable mesh, or count/shape mismatch. |
864
+ | `AggregateError` | Built-in | All available GLB/STL attempts failed; inspect `errors` for individual causes. |
865
+ | `RangeError` | Built-in | Invalid stock dimensions, frames, axes, or coordinates. |
1282
866
 
1283
- [`examples/react-viewer`](https://github.com/toolpath/ui-packages/tree/main/examples/react-viewer)
1284
- is a complete app built this way, with no API key needed.
867
+ Wrap `EnginePart` in Suspense and an error boundary. A custom async loader must
868
+ catch a rejected promise or rethrow it during render, as `ManagedPart` does; React
869
+ boundaries do not catch arbitrary async callback errors. Recover by correcting
870
+ input or fetching a fresh report/URL as appropriate, then reset the boundary.
871
+ Do not disable validation to render an unrelated mesh against a report's regions.
1285
872
 
1286
873
  ## Reference
1287
874
 
1288
875
  ### Entry points
1289
876
 
1290
- | Import | Contents |
1291
- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1292
- | `@toolpath/viewer` | All components, hooks, types, and helpers. Also re-exports `EnginePart`, `normalizePartReport`, and `smoothRegionNormals`. |
1293
- | `@toolpath/viewer/engine` | Loading Engine reports and meshes: `EnginePart`, `loadPartMesh`, `loadPartGeometry`, `parsePartGeometry`, `PartMeshError`, `engineGeometryCache`, `createEngineGeometryCache`, `MIN_KERNEL_VERSION`. |
877
+ | Import | Contents |
878
+ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
879
+ | `@toolpath/viewer` | Components, hooks, models, geometry/camera/selection helpers, and types. Also re-exports `EnginePart`, `normalizePartReport`, and `smoothRegionNormals`. |
880
+ | `@toolpath/viewer/engine` | Engine loading/normalization, geometry cache, `PartMeshError`, and adapter types. |
881
+ | `@toolpath/viewer/toolbar.css` | Optional toolbar stylesheet. |
882
+
883
+ Other source/dist subpaths are not public imports. The complete signatures for an
884
+ installed version are in its `dist/index.d.ts`, `dist/engine/index.d.ts`, and
885
+ referenced declaration chunks. The public source export lists are
886
+ [root](https://github.com/toolpath/ui-packages/blob/main/packages/viewer/src/index.ts)
887
+ and [Engine adapter](https://github.com/toolpath/ui-packages/blob/main/packages/viewer/src/engine/index.ts).
1294
888
 
1295
889
  ### Hooks
1296
890
 
1297
- | Hook | Where | What it does |
1298
- | --------------------- | -------------------------------- | -------------------------------------------------------------------------------- |
1299
- | `useViewerControls()` | Inside `<Viewer>` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. |
1300
- | `useViewerToolbar()` | Inside `<ViewerToolbarProvider>` | Get the actions and state supplied to the provider. |
1301
- | `useSectionStore()` | Inside `<Viewer>` | Read, set, or subscribe to the viewer's own cut. |
1302
- | `useContentBox()` | Inside `<Viewer>` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). |
1303
- | `usePartContext()` | Inside `<PartMesh>` | Get the part's `model` and the `geometry` it is drawn from, for an overlay. |
1304
- | `useTapGuard()` | Inside `<Viewer>` | Check whether a pointer event was a click and not a drag. |
1305
-
1306
- ### Helpers
1307
-
1308
- | Helper | What it does |
1309
- | -------------------------------------------------------- | -------------------------------------------------------------------- |
1310
- | `normalizePartReport(report)` | Checks an API report and converts it to a `PartModel`. |
1311
- | `buildRegionIndex({ regions, features, triangleCount })` | Builds the lookups between triangles, faces, and features. |
1312
- | `model.regionIndex.featuresForRegion(i)` | The features that own a face. |
1313
- | `model.regionIndex.regionsForFeature(tag)` | The faces that make up a feature. |
1314
- | `focusForPick(pick, lastRegion, lastFocus)` | Steps through a face's matches on repeated clicks. |
1315
- | `rankOwners` / `bestOwner` | The ranking `onPick` uses, if you want to run it yourself. |
1316
- | `groupByDirection(model)` | Features grouped by machining direction. |
1317
- | `featureTriangles(model, geometry, tag)` | A feature's triangles and outward normals, as plain arrays. |
1318
- | `directionLabel(v)` / `directionColor(i)` | A direction's label and colour. |
1319
- | `sectionFromPick({ point, normal })` | Turns a clicked surface into a section plane. |
1320
- | `measurementLabel(measurement, format?)` | Writes a measurement the way `<MeasureTool>` labels it. |
1321
- | `snapAt(hit, edges, camera, viewport)` | Where a point on the part snaps to, for a tool of your own. |
1322
- | `surfaceCreases(geometry, model)` | Every edge between two faces, with its angle and which way it folds. |
1323
- | `sharpCornerPositions(creases, model, options)` | The ones `sharpCorners` would draw, as line segment endpoints. |
1324
- | `findSharpCorners(creases, model, options)` | The same, as whole corners: feature, ends, length and angle. |
1325
- | `engineGeometryCache.clear()` | Frees every cached mesh. |
1326
-
1327
- The package also exports lower-level geometry, camera, and view-cube functions that the components
1328
- use. They're listed in `dist/index.d.ts`.
1329
-
1330
- ### Types
1331
-
1332
- `ViewerProps`, `ViewerHandle`, `ViewerView`, `Projection`, `ControlScheme`, `EnginePartProps`,
1333
- `PartMeshProps`, `PartPick`, `PickModifiers`, `PartModel`, `PartModelFeature`, `PartModelRegion`,
1334
- `FeatureTag`, `FeatureType`, `Vec3`, `FeatureHighlight`, `RegionHighlight`, `FocusOptions`, `SectionOptions`,
1335
- `SectionState`, `SectionPlacement`, `SectionToolProps`, `SectionStore`, `MeasureToolProps`,
1336
- `MeasureMode`, `Measurement`, `DistanceMeasurement`, `AngleMeasurement`, `Snap`, `SnapKind`,
1337
- `ViewerTheme`, `ViewName`, `DirectionArrowsProps`, `NamedDirection`, `GridProps`, `AxesProps`,
1338
- `ViewCubeProps`, `ViewerToolbarProps`, `ViewerToolbarControlsProps`, `ViewerToolbarControls`,
1339
- `ViewerToolbarControl`, `SharpCorner`, `SharpCornerOptions`, `SurfaceCreases`.
1340
-
1341
- `FeatureType` and `ShapeKind` accept any string, because newer Engine versions add new values.
1342
- Handle values you don't recognize.
891
+ | Hook | Required context | Use |
892
+ | ------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- |
893
+ | `useViewerControls` | Inside `Viewer` | Camera controls and `setSection`. |
894
+ | `useViewerToolbar` | Inside `ViewerToolbarProvider` | Configured host actions/state. |
895
+ | `useSectionStore` | Inside `Viewer` | Read, set, or subscribe to viewer-owned section state. |
896
+ | `useContentBox` | Inside the scene | Finished-part bounds, excluding stock/furniture; empty until found and measured once per hook mount. |
897
+ | `usePartContext` | Child of `PartMesh`/`EnginePart` | Part model and prepared input geometry. |
898
+ | `useRetarget` | Inside viewer scene | Retarget callback, or `null` when disabled/outside context. |
899
+ | `useTapGuard` | Inside viewer scene | Distinguish taps from drags. |
900
+
901
+ ### Helpers and types
902
+
903
+ Common model/selection helpers include `buildRegionIndex`, `focusForPick`,
904
+ `rankOwners`, `bestOwner`, `groupByDirection`, `directionLabel`, `directionColor`,
905
+ `directionHighlights`, `regionAdjacency`, and `featureTriangles`. The region index
906
+ provides `featuresForRegion(idx)` and `regionsForFeature(tag)`.
907
+
908
+ For custom tools, section helpers include `sectionFromPick`, `sectionMeasurement`,
909
+ `resolveSectionPlane`, and `createSectionStore`; measurement helpers include
910
+ `distanceBetween`, `deltaBetween`, `angleAt`, `measurementLabel`, and `snapAt`.
911
+ Lower-level camera, view-cube, painting, and geometry functions are also exported;
912
+ consult their declarations rather than importing implementation files.
913
+
914
+ Start with `ViewerProps`, `ViewerHandle`, `PartMeshProps`, `EnginePartProps`,
915
+ `PartModel`, `PartModelRegion`, `PartModelFeature`, `FeatureTag`, `PartPick`,
916
+ `FeatureHighlight`, `RegionHighlight`, `FocusOptions`, `ViewerTheme`, `StockProps`,
917
+ `SectionOptions`, `SectionState`, `Measurement`, `MeasureMode`, and `ToolMark`.
918
+ `EnginePartProps` and the loader/cache types come from `/engine`; scene/model
919
+ types come from the root. Use the types belonging to your installed version; a newer source checkout does
920
+ not add exports to a published package.
921
+
922
+ ## Working on this package
923
+
924
+ The [React viewer example](https://github.com/toolpath/ui-packages/tree/main/examples/react-viewer)
925
+ loads procedural parts without credentials. It also opens local report/mesh files;
926
+ its [README](https://github.com/toolpath/ui-packages/blob/main/examples/react-viewer/README.md)
927
+ describes the controls and browser tests.
928
+
929
+ From the repository root with its pinned toolchain:
930
+
931
+ ```sh
932
+ pnpm --filter @toolpath/viewer build
933
+ pnpm --filter @toolpath/viewer check-types
934
+ pnpm --filter @toolpath/viewer test
935
+ pnpm --filter @toolpath/example-react-viewer... build
936
+ pnpm --filter @toolpath/example-react-viewer test
937
+ ```
938
+
939
+ The example imports built packages. `build:watch` on `@toolpath/viewer` refreshes
940
+ that output during development. Browser tests need Chromium installed. When
941
+ changing exports/assets, pack the viewer and test that artifact in a clean consumer;
942
+ check both JS entries, declarations, toolbar CSS, and the bundled banana asset.
943
+ Fixture URLs are redacted test data, not working download links. Keep retained
944
+ fixtures unchanged and use a fixture copy when adapting URLs for a local test.
945
+
946
+ See the [contributor guide](https://github.com/toolpath/ui-packages/blob/main/AGENTS.md)
947
+ and [changelog](https://github.com/toolpath/ui-packages/blob/main/packages/viewer/CHANGELOG.md)
948
+ for repo workflows and published changes.
949
+
950
+ ## License
951
+
952
+ [MIT](https://github.com/toolpath/ui-packages/blob/main/packages/viewer/LICENSE).