three-slicer-viewer 0.2.5

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/VIEWER.md ADDED
@@ -0,0 +1,423 @@
1
+ # three-slicer/viewer
2
+
3
+ 3D slicer viewer as a React component: three.js viewport (orbit/transform gizmos), model import (STL/OBJ/3MF/AMF/PLY + pluggable formats such as STEP, multi-object, drag & drop), Web Worker slicing via `three-slicer`, support and material painting, per-extruder filament presets, multi-plate, and a GPU-instanced volumetric toolpath preview ported from OrcaSlicer's libvgcode (millions of segments in a single draw path, per-feature colors, layer range slider, G-code export).
4
+
5
+ ![The Prepare tab — drag-and-drop viewport with the printer, filament and process cards in the right sidebar](../../slice-sidebar.png)
6
+
7
+ ```bash
8
+ npm i three-slicer react react-dom three # the three are optional peers; the viewer needs all of them
9
+ ```
10
+
11
+ ```jsx
12
+ import { useState } from 'react'
13
+ import Viewport from 'three-slicer/viewer'
14
+
15
+ function App() {
16
+ const [settings, setSettings] = useState({}) // OrcaSlicer schema keys; sparse — defaults fill the rest
17
+ // The container matters — see Layout below.
18
+ return (
19
+ <div style={{ position: 'relative', width: '100%', height: '100vh' }}>
20
+ <Viewport settings={settings} setSettings={setSettings} />
21
+ </div>
22
+ )
23
+ }
24
+ ```
25
+
26
+ Styles are injected into the component's Shadow DOM root — nothing to import, and host CSS cannot collide.
27
+
28
+ ## Layout
29
+
30
+ **Give it a positioned, sized container.** The shadow host is `display: contents`, so it contributes no box of its
31
+ own, and the shell inside is `position: absolute; inset: 0`. It therefore fills the nearest *positioned* ancestor —
32
+ `position: relative` (or absolute/fixed) with a real height. Drop it into a plain static `<div>` and it escapes to
33
+ the initial containing block instead, which usually looks like the viewer covering the whole page.
34
+
35
+ There is no width or height prop: the component always fills that container, and resizes with it.
36
+
37
+ Slice parameters are derived from `settings` via `three-slicer`'s schema mapping — pair it with `three-slicer/components`' `<SettingsPanel/>` sharing the same state for a full slicer UI.
38
+
39
+ A `.3mf` written by a slicer (OrcaSlicer/BambuStudio save, MakerWorld download) imports as a **project**: plate layout, project settings, and support/material painting are restored, not just the meshes. Where a facet carries both paint kinds, material paint wins and the dropped support paint is reported.
40
+
41
+ Props: `settings`, `setSettings`, and three optional sidebar slots — `processPanel` (the process card) and
42
+ `motionPanel` (folded into the printer card) take a React node; `filamentPanel` (folded into the filament card,
43
+ next to the material picker) takes a **function** `(settings, setSettings) => ReactNode`, because the filament
44
+ card binds each extruder's per-extruder values.
45
+
46
+ Subpath exports for custom UIs (framework-free, no React):
47
+ - `three-slicer/viewer/toolpath` — GPU toolpath renderer (`buildSegmentData`, `makeToolpath`, view-type colorers)
48
+ - `three-slicer/viewer/loaders` — model loaders returning unified triangle soup
49
+ - `three-slicer/viewer/gcode` — `parseGcode(text)`, G-code back into the layer stream the renderer consumes
50
+
51
+ ## Embedding it without the panels
52
+
53
+ Every panel can be switched off, and the values the component owns can be seeded and watched, so the same
54
+ `<Viewport/>` also works as a bare G-code viewer or as a headless slicer inside another app's UI.
55
+
56
+ ```jsx
57
+ // A G-code viewer: no kernel run, no panels.
58
+ <Viewport gcode={text} panels={{ sidebar: false, topBar: false, gizmoRail: false, plateBar: false }} />
59
+
60
+ // A headless slicer: the host drives it and takes the result.
61
+ <Viewport panels={{ sidebar: false }} defaultAutoSlice onSliced={({ stats, gcode }) => save(gcode)} />
62
+ ```
63
+
64
+ - **`panels`** — `{name: false}` hides one. Everything is visible by default, so a host only ever opts out and a
65
+ panel added in a later version does not vanish for hosts that listed the ones they wanted. `sidebar: false` drops
66
+ the whole right column; the rest are `topBar`, `gizmoRail`, `objectToolbar`, `paintPanel`, `statsCard`, `plateBar`,
67
+ `emptyHint`, `status`, `printerCard`, `filamentCard`, `resinCard`, `towerCard`, `objectList`, `previewControls`,
68
+ `processCard`, `sliceBar`, `moveBar`, `bedWarn` (the off-bed warning strip). Which of `filamentCard`/`resinCard` renders follows the printer profile's technology: a profile whose
69
+ `printer_technology` says SLA swaps the filament card (and the prime tower, and the painting brushes) for the
70
+ resin card, and slicing routes to the kernel's `slice_sla` — PrusaSlicer's ported support/pad chain — with an
71
+ `.sl1` export instead of G-code (portrait masks, like every SL1-family machine). The preview lifts the model by
72
+ `stats.lift_layers` (pad + elevation), and a request the port does not cover (hollowing, organic trees)
73
+ surfaces as a typed error instead of a wrong print.
74
+ An `.sl1` can also be OPENED (file picker or drag & drop). This viewer's own exports carry a SCENE sidecar
75
+ (`Metadata/threeslicer_scene.bin`: the model STL plus the support and pad meshes the preview was showing, ~3%
76
+ of the archive), so reopening one shows the exported surface ITSELF — no reconstruction, nothing approximated,
77
+ and the whole import lands in ~0.3s. A foreign SL1 has no such record, and there its SHAPE is reconstructed
78
+ from the masks —
79
+ every layer streamed through surface nets in workers (six producers turning masks into occupancy slices, one
80
+ sequential consumer sweeping them). It runs in TWO passes: a quarter-resolution one lands a real shaded mesh
81
+ in well under a second, then the full one replaces it (measured 0.7s and 4.2s on a 1095-layer archive), at the archive's own layer height in z and ~0.12mm
82
+ in xy, with the anti-aliased mask grays interpolated for sub-voxel edges and both the surface and its normals
83
+ relaxed (the layer ripple a mask grid leaves behind is largely a shading artefact) — and shown as a solid mesh on the
84
+ selected plate, with the layer slider section-cutting it like any sliced result. While the reconstruction
85
+ runs a translucent stack of sampled masks stands in, until the coarse pass replaces it.
86
+ Colours: a mask records what cures, not which pixels were model, support or pad — so this viewer's own
87
+ exports carry a role sidecar (`Metadata/threeslicer_roles.bin`, the support/pad segments per layer; every
88
+ other consumer ignores the unknown member) and reconstruct in the sliced preview's three colours. A foreign
89
+ SL1 has no such record and reconstructs in one colour. Opening one applies what the archive states —
90
+ `printer_technology` first of all, then the exposure family, the layer height and the pixel grid read off a
91
+ mask header — so a file opened in a fresh FFF session switches the whole route to SLA instead of stranding
92
+ resin masks on a filament bed. The one number it cannot supply is the display's PHYSICAL size: millimetres
93
+ appear nowhere in an SL1, so the preview is scaled by the printer set in the viewer and the import notice says
94
+ so. Export hands an imported archive back byte-identical rather than re-rasterizing empty geometry.
95
+ - **`gcode`** — G-code text drawn on the selected plate instead of a slice result. `parseGcode` recovers roles from
96
+ `;TYPE:` (OrcaSlicer/PrusaSlicer/Cura), from `;_EXTRUSION_ROLE:` tags and from this kernel's own feature comments;
97
+ bead width comes from `;WIDTH:` or, absent that, from E. What the file never states cannot be recovered: an
98
+ unmarked run reads as wall, and there is no print-time estimate (that needs the machine's acceleration limits).
99
+ - **`sl1`** — the SLA half of that contract: an `.sl1` archive (`File`, raw bytes, or `{name, data}`) rendered as a
100
+ raster preview on the selected plate, again without starting the kernel. It routes into the same import the
101
+ picker and a drop use, so it behaves identically — including the two things `gcode` does not do: it writes the
102
+ archive's own job description into your `settings` (`printer_technology` among them, per the paragraph above),
103
+ and it reconstructs a surface from the masks in the background. Set both and the G-code wins; one plate holds one
104
+ artifact. It re-injects on the value's IDENTITY, so hold the File in state rather than rebuilding it each render.
105
+ - **The move scrub** (`panels.moveBar`) — the horizontal counterpart of the layer slider, under the canvas in
106
+ Preview: the vertical one picks WHICH layers are shown, this one walks inside the top one. It is upstream's
107
+ sequential view (`GCodeViewer`'s `update_sequential_view_current`) in this viewer's terms. On an FFF result it
108
+ cuts that layer at a move — extrusions and travels together, in the order the printer performs them, which the
109
+ two draw lists do not store and `moveCursor` recovers — and marks where the nozzle is: a solid cone at the
110
+ current position and a translucent one where the layer ends. Both are fixed SCREEN size and drawn over
111
+ everything, because a marker that scales with the scene is a speck zoomed out and a marker that respects depth
112
+ is buried in the bead it is pointing at. Position changes reach the host as the `moveScrub` event.
113
+ FFF only, and deliberately: mSLA cures a whole layer in one exposure, so a resin layer has no intra-layer
114
+ order to walk — the same conclusion upstream reaches, PrusaSlicer's SLA preview having a layer slider and no
115
+ horizontal one.
116
+ - **`files`** — content imported once on mount, through the same extension dispatch as a drop: models and 3mf
117
+ projects, `.sl1` archives, and preset files. Mount-only on purpose — a host that rebuilds the array each render
118
+ must not re-import its models; runtime loading stays with the picker and drop.
119
+ - **`sliceRequest`** — the host's Slice button: a token whose identity change requests one slice of the current
120
+ plate. The mount value is inert, so hold a counter in state and bump it. Ignored under the same conditions
121
+ the built-in slice bar is not pressable (empty scene, an injected `gcode`/`sl1` plate); a change during a
122
+ running slice cancels and re-slices, so the last request wins.
123
+ - **`defaultExtruderColors`**, **`defaultAutoSlice`** — initial values for state the component owns. Unlike the
124
+ in-app toggle, `defaultAutoSlice` also performs the *first* slice, which is what makes a panel-less embed able to
125
+ slice at all.
126
+ - **`onEvent`** — one channel for every change: `canvasMode`, `objects`, `selectedPlate`, `plateCount`,
127
+ `extruderColors`, `autoSlice`, `slicing`, `progress`, `sliceRate`, `viewType`, `paintMode`, `layerCount`,
128
+ `layerRange`, `moveScrub`, `error`, `notice`. Initial values are not announced — the host passed them.
129
+ `progress` and `sliceRate` fire several times a second while slicing.
130
+ - **`onSliced`** — `{plate, stats, gcode, throughput}` when a slice is cached. Switching plate tabs does not
131
+ re-fire it. `throughput` sits beside `stats` rather than inside it, because that is where the engine puts it:
132
+ it is measured around the call, not reported by the kernel.
133
+ - **`onSliceRun`** — an all-plates run as it moves: `{workers: {pool, active, kernel}, plates: {[i]: {state,
134
+ progress, rate}}}` on every change, `null` between runs. For a host drawing its own per-plate progress.
135
+
136
+ `sliceRate` is live throughput — layers finished per second over a 250ms window, `0` between slices, and shown in
137
+ the slice bar as well. When it appears depends on the technology, because the two publish per-layer news at
138
+ opposite ends of the run:
139
+
140
+ - **SLA** reports it throughout: its progress counter is linear in layers. (Its layer *stream* is not usable for
141
+ this — `slice_sla` builds the whole scene and then drains the sink, measured as 1095 layer messages inside a
142
+ 22ms burst at the end of a 2.8s run, which would time the drain rather than the slicing.)
143
+ - **FFF** reports it once the emission pass starts streaming layers. PASS1 publishes per-mille of itself rather
144
+ than a layer count (the total only arrives when it ends), and surfaces and support publish nothing per layer,
145
+ so there is nothing to measure before that and nothing is invented. Measured on a 38MB model: a 4.0s slice,
146
+ 2.8s of it PASS1, the rate shown for the final 0.6s. On a support-heavy model emission is a much larger share.
147
+
148
+ The whole-slice figure is `throughput` on `onSliced`, and it is present on both technologies — wall milliseconds,
149
+ the kernel's own share, layers/second, and ms per million segments. It reads lower than the live figure whenever
150
+ the live one covers only part of the run.
151
+
152
+ ## Keyboard and mouse
153
+
154
+ Which keys are live depends on the tab, and every one is ignored while an input has focus. Letter shortcuts match
155
+ the **physical** key, so they keep working under a non-Latin keyboard layout — with an IME on, the Korean layout
156
+ reports `'ㅁ'` for M, and matching on the character would silently kill every letter shortcut while leaving the
157
+ arrows and Delete working.
158
+
159
+ | Both tabs | |
160
+ | --- | --- |
161
+ | `Ctrl/⌘ + R` | Slice the current plate |
162
+ | `Ctrl/⌘ + C` / `V` / `X` | Copy · paste · cut the selection |
163
+ | `Ctrl/⌘ + K` or `Ctrl/⌘ + D` | Duplicate |
164
+ | `Ctrl/⌘ + A` | Select all |
165
+ | `Ctrl/⌘ + Z` | Undo — bound to the component root, so it never reaches your app's own Ctrl+Z |
166
+ | `Ctrl/⌘ + Shift + Z` or `Ctrl/⌘ + Y` | Redo |
167
+
168
+ | Prepare | |
169
+ | --- | --- |
170
+ | `M` or `G` · `R` · `S` | Move · rotate · scale gizmo |
171
+ | `Arrow keys` | Nudge 10mm — `Shift` for 1mm |
172
+ | `PageUp` / `PageDown` | Rotate ±45° |
173
+ | `Delete` / `Backspace` | Remove the selection |
174
+ | `Z` · `B` | Zoom to all · zoom to bed |
175
+ | `Esc` | Cancel the current tool |
176
+ | `?` | Shortcut help overlay |
177
+
178
+ | Preview | |
179
+ | --- | --- |
180
+ | `↑` / `↓` | Step one layer — `Shift` for ten |
181
+ | `L` · `T` | Single-layer view · travel moves |
182
+ | `Z` · `B` | Zoom to all · zoom to bed |
183
+ | `Esc` | Back to Prepare |
184
+
185
+ While a brush is open these take over the same letters — the object shortcuts come back the moment it closes.
186
+
187
+ | Painting | |
188
+ | --- | --- |
189
+ | `C` · `S` | Brush shape: circle (the visible surface) · sphere (reaches through a thin wall) |
190
+ | `F` · `B` · `T` | Smart fill · bucket fill · single triangle |
191
+ | `V` · `H` | Lock the stroke to a vertical · horizontal line — press again to release |
192
+ | `1`…`9` | Paint with that filament (material brush only) |
193
+ | `Shift + drag` | Erase instead of paint |
194
+ | Drag off the model | Rotate the camera — a press that misses the model is not the brush's, so orbiting never means closing it |
195
+ | `Ctrl/⌘ + wheel` | Brush radius, or the fill angle for a fill tool — the bare wheel stays the camera zoom |
196
+ | `Alt + wheel` | Scrub the section plane, which is what lets a brush reach a surface inside the model |
197
+ | `Esc` | Close the brush, and put back whatever the section plane cut away |
198
+
199
+ The brush draws itself while it is open — a ring for the circle cursor, a translucent ball for the sphere — and a
200
+ fill tool shades what a click would flood before you click it. A drag paints the **capsule** swept between samples
201
+ rather than one ball per sample, so a fast stroke is continuous instead of a row of blobs. A press that misses the
202
+ model falls through to the camera, so the view can be turned mid-paint without closing the brush. The support brush also
203
+ offers *on overhangs only*, which restricts every stroke to facets steeper than the support threshold.
204
+
205
+ Mouse selection follows upstream's rules: a plain click **replaces** the selection, `Ctrl/⌘ + click` adds or
206
+ removes, a plain click on something already selected **keeps** the set (which is what makes dragging several
207
+ objects work), `Shift + drag` is a box select, and clicking empty space clears. Alt is not a de-select, in
208
+ upstream or here.
209
+
210
+ ## Undo and redo
211
+
212
+ The history covers the viewport's **own** state: object transforms, additions, removals, per-object extruder and
213
+ visibility. Fifty steps, with repeats of the same action inside half a second folded into one — so a held arrow key
214
+ undoes as a single move rather than forty.
215
+
216
+ What it deliberately does not cover, and why the boundary sits there: `settings` is **yours**, passed in as a prop,
217
+ so nothing inside the component can put it back. Painting is outside it too (restoring a step would need a kernel
218
+ re-`prepare` for every entry), as are the prime tower and the plate count, which write host settings. If you want
219
+ those undoable, undo them on your side of the prop.
220
+
221
+ ## Materials and painting
222
+
223
+ The filament card holds **one material per extruder**: add an extruder, pick its colour, and pick its filament preset from the vendor catalog (`filamentPresets()` in `three-slicer/settings` — the shortlist is the printer model's own recommendation, the full list everything compatible). Selecting a row aims the material picker and the settings form at that extruder; the colours feed the object meshes, the prime tower and the preview's **Filament** view type, which colours every segment by the extruder that printed it.
224
+
225
+ The brush has two targets sharing one shell: **support painting** (enforcer / blocker) and **material painting** (one chip per extruder, plus an eraser that returns facets to the default extruder). They are mutually exclusive, and that is a data constraint rather than a UI choice — upstream's `EnforcerBlockerType` is a single enum in which ENFORCER *is* Extruder1 and BLOCKER *is* Extruder2, so one integer per facet means a support mark and a material mark cannot coexist there.
226
+
227
+ Two limits to expect while using it:
228
+
229
+ - Painting a region onto a second extruder only takes effect with **support turned off**. The painted multi-material path emits no support, so a support-enabled slice deliberately stays on the single-material path (where painted support keeps working as before).
230
+ - On a machine with two or more extruders, a support *blocker* paint and an *Extruder 2* paint are the same mark and cannot be told apart.
231
+
232
+ If you consume `three-slicer/viewer/toolpath` directly, note that the stride-8 role field `paths[k+3]` now carries the printing extruder alongside the role as `role + tool * 16`. Mask it — `& 15` for the role, `>>> 4` for the tool. Streams produced before this encoding are entirely below 16 and decode to their own role with tool 0, so older data still reads correctly.
233
+
234
+ ## Turning behaviour off
235
+
236
+ `panels` controls what is drawn; `features` controls what the component *does* outside its own box. Same rule —
237
+ opt-out only, everything on unless you say `false`, an unknown key stays on.
238
+
239
+ ```jsx
240
+ <Viewport
241
+ gcode={text}
242
+ panels={{ sidebar: false, topBar: false }}
243
+ features={{ warmup: false, shortcuts: false, logs: false }}
244
+ />
245
+ ```
246
+
247
+ | Key | Off means |
248
+ | --- | --- |
249
+ | `shortcuts` | No keyboard bindings. They are installed on `window`, so while the viewer is mounted they fire wherever focus is (inputs excepted) — and `Ctrl+C` preventDefaults, which takes a host page's own copy. Turn them off when your app has its own. |
250
+ | `warmup` | The WASM kernel is not loaded on mount. Nothing is downloaded or compiled until something actually slices — which for a `gcode`-only viewer is never, so this is several megabytes and a thread pool you stop paying for. |
251
+ | `drop` | No drag and drop onto the canvas. |
252
+ | `filePicker` | The file dialog never opens, from any of the buttons that open it. |
253
+ | `contextMenu` | No right-click menu. The browser's own menu stays suppressed over the canvas either way — OrbitControls preventDefaults `contextmenu` itself. |
254
+ | `logs` | No console output, from the component or from the slice worker. |
255
+
256
+ ### Read-only panels
257
+
258
+ A sidebar panel can also be shown but not editable — the shape you want when the host picks the printer, process
259
+ and filament itself and does not want the user changing them. It is a third value on the same `panels` key:
260
+
261
+ ```jsx
262
+ <Viewport
263
+ settings={presetSettings}
264
+ panels={{ printerCard: 'readonly', filamentCard: 'readonly', processCard: 'readonly' }}
265
+ />
266
+ ```
267
+
268
+ Lockable: `sidebar`, `printerCard`, `filamentCard`, `objectList`, `towerCard`, `previewControls`, `processCard`,
269
+ `sliceBar`. `sidebar: 'readonly'` locks the whole column at once; the per-card keys are for mixing, such as a
270
+ locked printer card above a live object list. Every other panel takes `true`/`false` only — a `'readonly'` that
271
+ silently did nothing on half the keys would be worse than not offering it.
272
+
273
+ The panel is drawn normally and keeps showing real values; it just cannot be clicked, typed into or tabbed to, and
274
+ that includes any node you passed into it (`motionPanel`, `filamentPanel`, `processPanel`). Implemented with the
275
+ `inert` attribute, so it needs Chrome 102+, Safari 15.5+ or Firefox 112+.
276
+
277
+ **It locks the UI path, not the value.** `settings` is still your prop, and other paths inside the viewer still
278
+ write it — importing a 3mf project applies that project's settings whether or not the cards are locked.
279
+
280
+ ### Printer preset files
281
+
282
+ The printer card carries **Load** and **Save**. Save writes an OrcaSlicer preset: a machine `.json` on its own, or
283
+ an `.orca_printer` bundle when the settings map also names a process or filament preset (the cards record those as
284
+ `printer_settings_id` / `print_settings_id` / `filament_settings_id`, so nothing extra has to be tracked). Load
285
+ accepts the same set upstream's dialog does — `.json`, `.orca_printer`, `.orca_bundle`, `.orca_filament`, `.zip` —
286
+ and applies every preset it finds, clearing that type's keys first.
287
+
288
+ `inherits` is followed through the shipped printer catalog, so a vendor file loads with its bed intact; when the
289
+ parent is unknown the file's own values are still applied and the notice says so. Codec details and the reason
290
+ that matters are in the [package README](../README.md#preset-files).
291
+
292
+ Load opens a file dialog, so it respects `features.filePicker`; Save goes through `onExport` like every other
293
+ download.
294
+
295
+ ### Taking the saves
296
+
297
+ The save buttons are inside the component, so `onExport` is the only way to send a file somewhere other than the
298
+ browser's download folder. It sees every save: the 3mf project, the STL, each plate's G-code, an SLA plate's
299
+ `.sl1` archive, and preset files. Return truthy to say you handled it:
300
+
301
+ ```jsx
302
+ <Viewport onExport={(file, filename) => { uploadToServer(file, filename); return true }} />
303
+ ```
304
+
305
+ Return nothing and the download happens as well, which is useful for logging what was saved without changing it.
306
+
307
+ ## What the host cannot drive
308
+
309
+ The component owns its scene, and the props are the whole interface — there is no ref, no imperative handle, and no
310
+ `models` prop. Worth knowing before you design around it:
311
+
312
+ - **Models enter through the UI, or once at mount through `files`** — after mount, runtime loading stays with the
313
+ file dialog and drag and drop. A host cannot remove or transform an object, or hand a mesh in mid-session. What
314
+ it *can* do is watch: the `objects` event reports `{id, name, extruder, visible}` for every object as the set
315
+ changes.
316
+ - **Slicing is triggered from the UI, by `defaultAutoSlice`, or by bumping `sliceRequest`** — a token prop whose
317
+ identity change requests one slice of the current plate (the mount value is inert), which is what a host with
318
+ the built-in chrome hidden wires its own Slice button to. The result arrives on `onSliced`.
319
+ - **The prime tower stand-in appears only when the plate actually changes tools** — objects assigned to more
320
+ than one extruder, or material paint reaching extruder 2 or higher. Loading a second filament is not enough:
321
+ with everything on T1 the slice emits no tool change and builds no tower, so drawing the box would promise one.
322
+ - **A settings change discards every slice result.** Everything a slice puts on screen — the toolpaths, the stats
323
+ card, the layer slider, the G-code export — describes the settings it ran with, and none of it says so. So when
324
+ a value in `settings` changes, the results are dropped and the viewer returns to Prepare; with `autoSlice` on
325
+ the re-slice then fills the empty preview. Two exceptions, neither of which claims to be a slice of these
326
+ settings: a plate injected through `gcode` / `sl1`, and an opened `.sl1` archive (that import applies the
327
+ archive's own settings, so invalidating on it would erase what the file was opened to show). Comparison is by
328
+ value, so a host that rebuilds its settings object every render does not lose its preview to it.
329
+ - **`gcode` is one-way**: pass G-code text and it is drawn on the selected plate instead of a slice result, and
330
+ auto re-slice leaves that plate alone while it is set.
331
+ - Everything else the component owns — camera, selection, plate count, paint state — is reported through `onEvent`
332
+ and settable only by the user.
333
+
334
+ If you need programmatic control over the scene, build on the subpath exports (`three-slicer/viewer/toolpath`,
335
+ `/loaders`, `/gcode`) and `three-slicer/client` rather than on `<Viewport/>`.
336
+
337
+ ## Multithreaded slicing (automatic)
338
+
339
+ The engine ships two WASM kernels: single-threaded (default) and `-pthread` multithreaded (~2× wall-clock on layer-parallel wall generation, measured 12× on the wall pass itself). The worker picks the mt kernel automatically when the page is [cross-origin isolated](https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated) — i.e. served with:
340
+
341
+ ```
342
+ Cross-Origin-Opener-Policy: same-origin
343
+ Cross-Origin-Embedder-Policy: require-corp
344
+ ```
345
+
346
+ No headers → falls back to the single-threaded kernel. Nothing else to change. Which one loaded is shown as a
347
+ badge (`mt`/`st`) beside the Workers select in the slice menu, and the `warmup()` client call resolves with it —
348
+ the two are a measured 9.8x apart on a 3M-facet model, which is too much to leave to the console.
349
+
350
+ ### Slicing several plates at once
351
+
352
+ **All plates** in the slice menu drains the plates through a pool of workers instead of one after another. The
353
+ **Workers** select beside it sets the pool: *Auto* is half the cores on the threaded kernel and one per core on the
354
+ single-threaded one, capped at the plate count and by model size (below); the select offers every value up to
355
+ that memory cap and nothing past it, because past it the tab itself dies (measured: five workers on a 143MB
356
+ model), which nothing in the page can catch. The plate tabs show each plate's state (queued, slicing with its own percentage, done, failed)
357
+ independently of which one is selected, and the slice bar lists them on one row. Cancel stops every worker. A worker that dies mid-run (memory, most
358
+ often) is dropped and its plate is re-run alone once the others finish, so a count that was too high for the
359
+ model costs time rather than results; the notice says which plates that happened to.
360
+
361
+ The numbers behind Auto (node harness, 3M-facet model, 15 cores, 30 plates, wall time against one worker):
362
+
363
+ | kernel | preset | 3 workers | 5 | 10 | 15 |
364
+ |---|---|---|---|---|---|
365
+ | mt | classic walls, no support | 1.10x | 1.15x | 1.15x | 1.14x |
366
+ | mt | arachne + tree support | 2.08x | 2.60x | 3.16x | 3.31x |
367
+ | st | classic walls, no support | — | — | — | 7.2x (14 workers) |
368
+
369
+ The gain is the kernel's serial share: with classic walls one worker already keeps ~77% of the cores busy, with
370
+ arachne (whose emission pass runs serial) and tree support far less, and the extra workers fill that. More workers
371
+ than cores gained nothing (17 = 15), and no count ever made a run slower — what a worker costs is memory: each
372
+ holds its own copy of the model and its own wasm heap, measured 2.5GB for one, 8.8GB for five and 11.6GB for
373
+ fifteen on that model. That is why Auto stops at half the cores and why the select goes higher only by hand.
374
+ Three things were measured NOT to matter and are deliberately absent: giving each worker a thread budget, slicing
375
+ the largest plate first, and whether the count divides the plate count. The value is `slice_workers` in the
376
+ settings map (0 = Auto), a viewer knob like `sla_antialias`, and it does not reach a 3mf.
377
+
378
+ The browser is tighter than that harness, because every worker's wasm heap lives in the one renderer process that
379
+ already holds each plate's STL buffer and three.js geometry. Measured in Chromium on the same 143MB-STL model
380
+ over nine plates (classic preset, 15 cores):
381
+
382
+ | workers | wall | Chromium RSS peak |
383
+ |---|---|---|
384
+ | 1 | 17.8s | 10.4GB |
385
+ | 2 | 15.7s (1.13x) | 13.7GB |
386
+ | 3 | crashed the tab once; 18.7s once | 10.2GB |
387
+ | 8 (Auto by cores) | crashed the tab | 11.6GB |
388
+
389
+ So Auto is also capped by model size — a worker's heap is taken as 12x the STL it slices (node measured ~1.7GB
390
+ for 143MB) against a 4GB pool budget, which gives 2 for that model and does not bind below ~40MB per plate. A
391
+ manual count is capped the same way. What the browser has NOT measured yet is
392
+ the arachne + tree-support preset, where the harness gains were largest.
393
+
394
+ ## Bundler notes
395
+
396
+ - **Vite:** two config lines (worker code-splitting + top-level await in the mt glue; Chrome 89+/Safari 15+):
397
+
398
+ ```js
399
+ // vite.config.js
400
+ export default defineConfig({
401
+ worker: { format: 'es' },
402
+ build: { target: 'es2022' },
403
+ })
404
+ ```
405
+
406
+ - **Next.js / webpack:** render client-side (`dynamic(..., { ssr: false })`) and alias the emscripten glue's Node-only guards away:
407
+
408
+ ```js
409
+ // next.config.js
410
+ module.exports = {
411
+ webpack: (config) => {
412
+ for (const m of ['node:module', 'node:fs', 'node:path', 'node:url', 'node:crypto', 'node:worker_threads'])
413
+ config.resolve.alias[m] = false
414
+ return config
415
+ },
416
+ }
417
+ ```
418
+
419
+ `three` is a peer dependency pinned to `^0.160` (TransformControls API; r169+ changed its Object3D contract).
420
+
421
+ ## License
422
+
423
+ AGPL-3.0-or-later (derived from OrcaSlicer). A web app embedding this package must comply with AGPL — including offering its source to network users.