three-slicer 0.2.3 → 0.2.4

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.
@@ -21,6 +21,7 @@ export { SLA_CAPABILITIES, SLA_JOB_VERSION, SlaRequestError } from './sla_reques
21
21
  const REPLY_OF = {
22
22
  warmup: 'warm', prepare: 'prepared', paint: 'painted', erase: 'painted', clear: 'painted',
23
23
  importPaint: 'painted', exportPaint: 'paintExport', overlay: 'overlay', slice: 'done', sla: 'done',
24
+ fillPreview: 'fillPreview', paintMode: 'paintMode',
24
25
  }
25
26
 
26
27
  const asBuffer = (data) => (data instanceof ArrayBuffer ? data : data?.buffer ?? data)
@@ -143,6 +144,14 @@ export function createSlicerClient(worker = new Worker(new URL('./slicer.worker.
143
144
  overlay: (states) => send({ cmd: 'overlay', ...(states ? { states } : {}) })
144
145
  .then(reply => ({ enforcer: reply.enf, blocker: reply.blk, overlays: reply.overlays })),
145
146
 
147
+ /** What a fill would select under `{tool, facet, hx, hy, hz, angle}`, without applying it. `{clear:true}`
148
+ * drops the standing selection. `supported` is false on a kernel built before the preview existed. */
149
+ fillPreview: (args) => send({ cmd: 'fillPreview', ...args })
150
+ .then(reply => ({ supported: reply.supported, triangles: reply.tris })),
151
+ /** Brush-wide options: `{overhangDeg}` restricts every stroke to overhangs (0 = off), `{clipPlane:[nx,ny,nz,d]}`
152
+ * sets the section plane every cursor clips against (null turns it off). */
153
+ paintMode: (args) => send({ cmd: 'paintMode', ...args }).then(() => undefined),
154
+
146
155
  /** Load a 3mf's painting. Replaces every mark, so run it directly after `prepare`. */
147
156
  importPaint: (facets, hex, states) => brush('importPaint', { facets, hex, ...(states ? { states } : {}) }),
148
157
  exportPaint: () => send({ cmd: 'exportPaint' })
@@ -95,6 +95,16 @@ const cursorShapeOf = (message) => (message.cursor === 'circle' ? 1 : 0)
95
95
  // a radius or a camera: what they select comes from the mesh's own topology, which is why they are a branch rather
96
96
  // than more optional fields on the brush call. `state === null` means erase (the entry points with no state
97
97
  // argument), matching how the brush splits paint from erase.
98
+ // The previous sample of the current stroke, when the message carries one. Upstream paints the CAPSULE between two
99
+ // consecutive mouse positions (DoublePointCursor, GLGizmoPainterBase.cpp:878) — a pointer stream is sampled, not
100
+ // continuous, so a ball per sample leaves a fast drag as a row of blobs with gaps between them. Optional on the
101
+ // wire and feature-detected on the kernel, so a caller or a build without it keeps painting single points.
102
+ const strokeFrom = (message) =>
103
+ Number.isFinite(message.px) && Number.isFinite(message.py) && Number.isFinite(message.pz)
104
+
105
+ // The fill modes the kernel's fill_preview takes (selector_bridge.h FILL_*), by the tool name the protocol uses.
106
+ const FILL_PREVIEW_MODE = { smart: 0, bucket: 1, triangle: 2 }
107
+
98
108
  const FILL_TOOLS = new Set(['smart', 'bucket', 'triangle'])
99
109
  const applyFill = (Module, message, state) => {
100
110
  const angle = Number.isFinite(message.angle) ? message.angle : 30
@@ -224,6 +234,8 @@ self.onmessage = async (e) => {
224
234
  applyFill(Module, d, paintedState)
225
235
  }
226
236
  else if (paintedState === null) Module.selector_paint(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, d.enforcer)
237
+ else if (strokeFrom(d) && Module.selector_paint_stroke)
238
+ Module.selector_paint_stroke(d.facet, d.px, d.py, d.pz, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, paintedState, cursorShapeOf(d))
227
239
  else Module.selector_paint_shape(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, paintedState, cursorShapeOf(d))
228
240
  self.postMessage(paintedReply(Module, d, paintedState)); return
229
241
  }
@@ -236,6 +248,11 @@ self.onmessage = async (e) => {
236
248
  // change to the same counts, and the viewer's listeners must not need to know which brush produced it.
237
249
  if (d.cmd === 'erase') {
238
250
  if (FILL_TOOLS.has(d.tool)) applyFill(Module, d, null)
251
+ else if (strokeFrom(d) && Module.selector_erase_stroke)
252
+ Module.selector_erase_stroke(d.facet, d.px, d.py, d.pz, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, cursorShapeOf(d))
253
+ // The eraser gets the cursor shape too, or a shift+drag with the circle brush rubs out the far side of a wall
254
+ // the paint brush never touched. `selector_erase` (no shape) stays the fallback for an older kernel.
255
+ else if (Module.selector_erase_shape) Module.selector_erase_shape(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, cursorShapeOf(d))
239
256
  else Module.selector_erase(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius)
240
257
  self.postMessage(paintedReply(Module, d, null)); return
241
258
  }
@@ -292,6 +309,34 @@ self.onmessage = async (e) => {
292
309
  self.postMessage(reply, transfer); return
293
310
  }
294
311
 
312
+ // fillPreview: what a fill WOULD select, without applying it. Upstream runs this on every mouse move while a
313
+ // fill tool is active and draws the result in a lighter shade (GLGizmoPainterBase.cpp:929) — a preview marks
314
+ // nothing, so it needs no state at all and there is no NONE for a stray boolean to become. `{clear:true}`
315
+ // drops the standing selection, which is what leaving the model under the pointer means.
316
+ if (d.cmd === 'fillPreview') {
317
+ if (!Module.selector_fill_preview) { self.postMessage({ type: 'fillPreview', supported: false, tris: EMPTY_OVERLAY }); return }
318
+ if (d.clear) { Module.selector_fill_preview_clear(); self.postMessage({ type: 'fillPreview', supported: true, tris: EMPTY_OVERLAY }); return }
319
+ const angle = Number.isFinite(d.angle) ? d.angle : 30
320
+ const mode = FILL_PREVIEW_MODE[d.tool] ?? FILL_PREVIEW_MODE.smart
321
+ // Upstream's -1 for the non-propagating case, the same value applyFill sends: one facet has no neighbour to
322
+ // measure an angle against.
323
+ const tris = Module.selector_fill_preview(d.facet, d.hx, d.hy, d.hz, mode === FILL_PREVIEW_MODE.triangle ? -1 : angle, mode)
324
+ self.postMessage({ type: 'fillPreview', supported: true, tris },
325
+ tris?.buffer && tris.byteLength > 0 ? [tris.buffer] : [])
326
+ return
327
+ }
328
+ // paintMode: the brush-wide options upstream keeps on the gizmo — the overhang restriction and the section
329
+ // plane. Both are set once and read by every stroke, so they are their own command rather than fields on one.
330
+ if (d.cmd === 'paintMode') {
331
+ if (Number.isFinite(d.overhangDeg) && Module.selector_set_overhang_limit) Module.selector_set_overhang_limit(d.overhangDeg)
332
+ if (Module.selector_set_clip_plane) {
333
+ const plane = d.clipPlane
334
+ if (plane) Module.selector_set_clip_plane(plane[0], plane[1], plane[2], plane[3], true)
335
+ else if (d.clipPlane === null) Module.selector_set_clip_plane(0, 0, 1, 0, false)
336
+ }
337
+ self.postMessage({ type: 'paintMode', ok: true }); return
338
+ }
339
+
295
340
  if (d.stall) return // stage-30 test hook: simulate a hang -> verifies the main-thread watchdog fires (not set in production)
296
341
  // mt (SAB): share the address of the real support progress counter (u32) with main once — even while the kernel is
297
342
  // blocked inside C++, the UI thread polls the SAB directly to show support progress. st (non-shared buffer) is unsupported -> not sent.
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-slicer",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Browser/WASM 3D-printing slicer reverse-engineered from OrcaSlicer: slicing engine (Arachne walls, tree/grid supports, multi-material), SLA resin slicing with PrusaSlicer's ported support/pad chain and .sl1 export, React viewer with GPU toolpath preview, and schema-driven settings UI.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
+ "last_emitted_at_ms": 1788437194779,
6
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
+ }
8
+ },
9
+ "updated_at": "2026-09-03T12:06:34.779Z"
10
+ }
package/types/worker.d.ts CHANGED
@@ -13,6 +13,14 @@ export interface BrushArgs {
13
13
  hx: number; hy: number; hz: number
14
14
  cx: number; cy: number; cz: number
15
15
  radius: number
16
+ /**
17
+ * The PREVIOUS sample of this stroke. Present, the kernel paints the capsule swept from there to `(hx,hy,hz)`
18
+ * instead of a ball at the hit — which is what upstream does between two consecutive mouse positions
19
+ * (`DoublePointCursor`, GLGizmoPainterBase.cpp:878). A pointer stream is sampled, not continuous, so without it
20
+ * a fast drag lands as a row of separate blobs. Omit on the first sample of a stroke; a kernel built before the
21
+ * capsule existed ignores it and paints the ball, so it is safe to always send.
22
+ */
23
+ px?: number; py?: number; pz?: number
16
24
  }
17
25
 
18
26
  /**
@@ -56,6 +64,23 @@ export type SlicerRequest =
56
64
  | { cmd: 'clear'; states?: PaintState[] }
57
65
  /** The painted overlay triangles. `enf`/`blk` always; `states` adds those states. Reply: `overlay`. */
58
66
  | { cmd: 'overlay'; states?: PaintState[] }
67
+
68
+ /**
69
+ * What a fill tool WOULD select under the cursor, without marking anything — upstream draws this on every mouse
70
+ * move while a fill is active (GLGizmoPainterBase.cpp:929) so a click is aimed rather than tried. No state
71
+ * argument at all: a preview marks nothing, so there is no NONE for a stray boolean to become.
72
+ * `{clear:true}` drops the standing selection. Reply: `fillPreview`.
73
+ */
74
+ | { cmd: 'fillPreview'; facet?: number; hx?: number; hy?: number; hz?: number
75
+ tool?: PaintTool; angle?: number; clear?: boolean }
76
+
77
+ /**
78
+ * Brush-wide options, set once and read by every stroke and fill — upstream keeps both on the gizmo.
79
+ * `overhangDeg` restricts painting to facets overhanging by more than that angle (`0` = off, the default);
80
+ * `clipPlane` is the section plane `[nx, ny, nz, offset]` in KERNEL coords that every cursor clips against
81
+ * (`null` turns it off), which is the only way a brush reaches an interior surface. Reply: `paintMode`.
82
+ */
83
+ | { cmd: 'paintMode'; overhangDeg?: number; clipPlane?: [number, number, number, number] | null }
59
84
  /**
60
85
  * Legacy single-object SLA (resin) slice. `params` accepts a JSON string or an object (this path parses either). Same reply stream as
61
86
  * a slice: `progress` and `layer` while it runs, then `done` (stats only; layers arrived as `layer`) — or `error`.
@@ -83,6 +108,9 @@ export type SlicerResponse =
83
108
  /** `supported: false` from a kernel built before the export binding existed — it does not throw. */
84
109
  | { type: 'paintExport'; supported: boolean; facets: number[]; hex: string }
85
110
  | { type: 'overlay'; enf: Float32Array; blk: Float32Array; overlays?: Record<number, Float32Array> }
111
+ /** `supported` false on a kernel built before the preview existed; `tris` is flat x,y,z, 3 vertices per triangle. */
112
+ | { type: 'fillPreview'; supported: boolean; tris: Float32Array }
113
+ | { type: 'paintMode'; ok: boolean }
86
114
  /**
87
115
  * Sent once, unprompted, by the multithreaded kernel only: the address of the support-progress counter and of
88
116
  * the cancel flag, inside a SharedArrayBuffer. Writing 1 to the cancel flag stops a slice the worker is
package/viewer/README.md CHANGED
@@ -180,6 +180,26 @@ arrows and Delete working.
180
180
  | `Z` · `B` | Zoom to all · zoom to bed |
181
181
  | `Esc` | Back to Prepare |
182
182
 
183
+ While a brush is open these take over the same letters — the object shortcuts come back the moment it closes.
184
+
185
+ | Painting | |
186
+ | --- | --- |
187
+ | `C` · `S` | Brush shape: circle (the visible surface) · sphere (reaches through a thin wall) |
188
+ | `F` · `B` · `T` | Smart fill · bucket fill · single triangle |
189
+ | `V` · `H` | Lock the stroke to a vertical · horizontal line — press again to release |
190
+ | `1`…`9` | Paint with that filament (material brush only) |
191
+ | `Shift + drag` | Erase instead of paint |
192
+ | Drag off the model | Rotate the camera — a press that misses the model is not the brush's, so orbiting never means closing it |
193
+ | `Ctrl/⌘ + wheel` | Brush radius, or the fill angle for a fill tool — the bare wheel stays the camera zoom |
194
+ | `Alt + wheel` | Scrub the section plane, which is what lets a brush reach a surface inside the model |
195
+ | `Esc` | Close the brush, and put back whatever the section plane cut away |
196
+
197
+ The brush draws itself while it is open — a ring for the circle cursor, a translucent ball for the sphere — and a
198
+ fill tool shades what a click would flood before you click it. A drag paints the **capsule** swept between samples
199
+ rather than one ball per sample, so a fast stroke is continuous instead of a row of blobs. A press that misses the
200
+ model falls through to the camera, so the view can be turned mid-paint without closing the brush. The support brush also
201
+ offers *on overhangs only*, which restricts every stroke to facets steeper than the support threshold.
202
+
183
203
  Mouse selection follows upstream's rules: a plain click **replaces** the selection, `Ctrl/⌘ + click` adds or
184
204
  removes, a plain click on something already selected **keeps** the set (which is what makes dragging several
185
205
  objects work), `Shift + drag` is a box select, and clicking empty space clears. Alt is not a de-select, in