three-slicer 0.2.2 → 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.
package/engine/index.d.ts CHANGED
@@ -71,12 +71,38 @@ export interface SliceStats {
71
71
  [k: string]: unknown
72
72
  }
73
73
 
74
+ /** A condition the slice SUCCEEDED with. `'over_bed_model'`: the model itself sliced outside the printable area. */
75
+ export type SliceWarning = 'over_bed_model' | (string & {})
76
+
77
+ /** How fast the slice ran. Derived at the call site; the `stats` phase timings are untouched. */
78
+ export interface SliceThroughput {
79
+ /** Wall time around the slice call, ms — includes the STL crossing the WASM boundary and the layer stream */
80
+ ms: number
81
+ /** The kernel's own phase total (`t_pass1_ms` + surface + support + emit), or `null` if it reported none.
82
+ * `ms - kernelMs` is the boundary cost. */
83
+ kernelMs: number | null
84
+ /** Emitted layers per second of wall time — the figure to show a person */
85
+ layersPerSecond: number
86
+ /** Milliseconds per million path segments, or `null` when nothing was emitted. The figure to COMPARE runs with:
87
+ * the kernel is not deterministic in segment count (measured 15% apart on one input), so raw milliseconds
88
+ * compare two different amounts of work. */
89
+ msPerMsegment: number | null
90
+ }
91
+
74
92
  export interface SliceResult {
75
93
  /** Absent when `onLayer` was set — assemble it from the callback instead */
76
94
  gcode?: string
77
95
  stats: SliceStats
78
96
  /** Absent when `onLayer` was set */
79
97
  layers?: unknown[]
98
+ /**
99
+ * What the slice got away with, empty when nothing. Every entry restates a `stats` flag that is easy to miss
100
+ * among twenty numbers — an off-bed model slices with plausible time and material and no error — so check
101
+ * `result.warnings.length` once instead of learning which flags exist. Absent on a failed slice (see `error`).
102
+ */
103
+ warnings?: SliceWarning[]
104
+ /** How fast it ran. Absent on a failed slice (see `error`). */
105
+ throughput?: SliceThroughput
80
106
  /** Set instead of a result when the slice failed or was cancelled (`'canceled'`) */
81
107
  error?: string
82
108
  }
package/engine/index.js CHANGED
@@ -3,10 +3,25 @@
3
3
  // Schema-driven helpers (deriveKernelParams etc.) live in the "three-slicer/settings" subpath because
4
4
  // they import JSON — use them in a bundler (vite) or with Node JSON import attributes.
5
5
 
6
+ import { withSliceWarnings } from './src/warnings.js'
7
+ import { withSliceThroughput } from './src/throughput.js'
8
+
6
9
  const u8 = (b) => (b instanceof Uint8Array ? b : new Uint8Array(b))
7
10
 
8
- // Off-main-thread worker URL for the browser: `new Worker(engineWorkerURL(), { type: 'module' })`.
11
+ // Off-main-thread worker URL for a NO-BUNDLER browser setup (native ESM, an import map, a CDN):
12
+ // `new Worker(engineWorkerURL(), { type: 'module' })`. Under a bundler use `createSlicerClient()`
13
+ // (three-slicer/client), which creates the worker through the static pattern bundlers recognize.
9
14
  // The worker speaks the stage-30 streaming protocol ({type:'layer'|'done'|'error'|'progress'}).
15
+ //
16
+ // This expression on its OWN is what a bundler reads as a plain asset reference: through 0.2.2 it is how the
17
+ // client built its default worker, and Vite copied slicer.worker.js into dist verbatim — measured on a Vite 5 app
18
+ // importing three-slicer/client and nothing else, a 21KB copy of the untouched source, still importing
19
+ // `./slicer_core.js`, `./slicer_core.mt.js`, `./sla_request.js` and `./sla_core.js` in a 40KB dist that held none
20
+ // of them. It 404s on the worker's first message, and only after `vite build`, since dev serves the sources.
21
+ // What fixed it is client.js creating the worker through the static `new Worker(new URL('literal', …))` pattern
22
+ // bundlers treat as a worker ENTRY. Once that entry exists this reference resolves onto the same chunk and emits
23
+ // nothing extra (verified: dist byte-identical with the path written here inline or held in a const), so it is
24
+ // harmless — but it is not what makes a bundled worker work. Leave it for the no-bundler callers.
10
25
  export const engineWorkerURL = () => new URL('./src/slicer.worker.js', import.meta.url)
11
26
 
12
27
  // createSlicer(): load the WASM kernel and return a handle. Works in Node and the browser main thread.
@@ -20,7 +35,7 @@ export const engineWorkerURL = () => new URL('./src/slicer.worker.js', import.me
20
35
  // paintPrepare/paint/paintClear/overlay: manual support enforcer/blocker painting (TriangleSelector).
21
36
  // heapSize(): current WASM heap bytes (peak, monotonic). module: escape hatch. dispose(): drop the module.
22
37
  export async function createSlicer() {
23
- // Loaded here rather than at module scope so the 3.5MB emscripten glue is a separate chunk: a host that imports
38
+ // Loaded here rather than at module scope so the ~4.8MB emscripten glue is a separate chunk: a host that imports
24
39
  // this module only for engineWorkerURL() — the recommended browser path, where the kernel runs in the worker —
25
40
  // used to pull the whole thing into its bundle for a function that returns a URL.
26
41
  const M = await (await import('./src/slicer_core.js')).default()
@@ -28,14 +43,19 @@ export async function createSlicer() {
28
43
  slice(stl, params, { onProgress, onLayer } = {}) {
29
44
  const p = typeof params === 'string' ? params : JSON.stringify(params || {})
30
45
  if (onLayer) M.set_layer_sink((z, idx, gcode, paths, widths) => onLayer({ z, idx, gcode, paths, widths }))
31
- try { return M.slice(u8(stl), p, onProgress || (() => {})) }
46
+ // `warnings` names the conditions a slice can SUCCEED with (an off-bed model above all) — see src/warnings.js.
47
+ // `throughput` is measured HERE rather than derived from the kernel's phase timings, because the wall time a
48
+ // caller feels includes the STL crossing the WASM boundary and the layer sink running on this side.
49
+ const started = performance.now()
50
+ try { return withSliceThroughput(withSliceWarnings(M.slice(u8(stl), p, onProgress || (() => {}))), performance.now() - started) }
32
51
  finally { if (onLayer) M.clear_layer_sink() }
33
52
  },
34
53
  // SLA (resin) slice: no G-code — layers carry the mask segment stream, support_mesh/pad_mesh the preview
35
54
  // soups, stats the resin figures and lift_layers. Same params-object-or-string contract as slice().
36
55
  sliceSla(stl, params, onProgress) {
37
56
  const p = typeof params === 'string' ? params : JSON.stringify(params || {})
38
- return M.slice_sla(u8(stl), p, onProgress || (() => {}))
57
+ const started = performance.now()
58
+ return withSliceThroughput(M.slice_sla(u8(stl), p, onProgress || (() => {})), performance.now() - started)
39
59
  },
40
60
  paintPrepare(stl) { M.selector_prepare(u8(stl)); return M.selector_facet_count() },
41
61
  paint(a) {
@@ -13,7 +13,6 @@
13
13
  // Requests are answered in order. The worker's onmessage handler processes one message at a time and each command
14
14
  // replies exactly once, so a FIFO of pending requests is enough to match a reply to its caller — the protocol
15
15
  // carries no request ids and does not need them.
16
- import { engineWorkerURL } from '../index.js'
17
16
  import { parseSlaJob, SlaRequestError } from './sla_request.js'
18
17
 
19
18
  export { SLA_CAPABILITIES, SLA_JOB_VERSION, SlaRequestError } from './sla_request.js'
@@ -22,6 +21,7 @@ export { SLA_CAPABILITIES, SLA_JOB_VERSION, SlaRequestError } from './sla_reques
22
21
  const REPLY_OF = {
23
22
  warmup: 'warm', prepare: 'prepared', paint: 'painted', erase: 'painted', clear: 'painted',
24
23
  importPaint: 'painted', exportPaint: 'paintExport', overlay: 'overlay', slice: 'done', sla: 'done',
24
+ fillPreview: 'fillPreview', paintMode: 'paintMode',
25
25
  }
26
26
 
27
27
  const asBuffer = (data) => (data instanceof ArrayBuffer ? data : data?.buffer ?? data)
@@ -32,7 +32,14 @@ const slaTransferables = (job) => job.objects.flatMap(object => [object, ...(obj
32
32
  * @param {Worker} [worker] an existing worker to drive. Omit to create one — which only works in a browser,
33
33
  * since Node has no global Worker; a Node caller should use createSlicer() from the root entry instead.
34
34
  */
35
- export function createSlicerClient(worker = new Worker(engineWorkerURL(), { type: 'module' })) {
35
+ // The default is written as the full `new Worker(new URL('literal', import.meta.url), { type: 'module' })`
36
+ // expression rather than through engineWorkerURL(), and that is the whole reason the no-argument form works
37
+ // after `vite build`. Bundlers recognize this exact shape as a worker ENTRY and emit it with its own chunk
38
+ // graph (the st/mt kernels it dynamically imports); split across a function call, the same file matched Vite's
39
+ // plain-asset rule instead and was copied verbatim, still importing an unhashed `./slicer_core.js` that dist
40
+ // did not contain — a 404 on the first message, visible only in a production build. Same reason the viewer
41
+ // keeps make_worker.js out of its own bundle. Do not factor this expression out.
42
+ export function createSlicerClient(worker = new Worker(new URL('./slicer.worker.js', import.meta.url), { type: 'module' })) {
36
43
  const pending = [] // FIFO of {expect, resolve, reject, onProgress, onLayer, chunks, layers}
37
44
  let cancelFlag = null // Uint32Array over the worker's SharedArrayBuffer — mt kernel only
38
45
  let closed = false
@@ -137,6 +144,14 @@ export function createSlicerClient(worker = new Worker(engineWorkerURL(), { type
137
144
  overlay: (states) => send({ cmd: 'overlay', ...(states ? { states } : {}) })
138
145
  .then(reply => ({ enforcer: reply.enf, blocker: reply.blk, overlays: reply.overlays })),
139
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
+
140
155
  /** Load a 3mf's painting. Replaces every mark, so run it directly after `prepare`. */
141
156
  importPaint: (facets, hex, states) => brush('importPaint', { facets, hex, ...(states ? { states } : {}) }),
142
157
  exportPaint: () => send({ cmd: 'exportPaint' })
@@ -0,0 +1,132 @@
1
+ // AUTO-GENERATED by types/gen_kernel_params.mjs — DO NOT EDIT.
2
+ // The schema keys deriveKernelParams consumes (the distinct "From setting" column of engine/PARAMS.md).
3
+ export const KERNEL_SETTING_KEYS = [
4
+ "bottom_shell_layers",
5
+ "bottom_surface_filament_id",
6
+ "bridge_no_support",
7
+ "bridge_speed",
8
+ "brim_object_gap",
9
+ "brim_width",
10
+ "close_fan_the_first_x_layers",
11
+ "default_acceleration",
12
+ "enable_arc_fitting",
13
+ "enable_pressure_advance",
14
+ "enable_prime_tower",
15
+ "enable_support",
16
+ "fan_max_speed",
17
+ "filament_cost",
18
+ "filament_density",
19
+ "filament_diameter",
20
+ "filament_flow_ratio",
21
+ "filament_map",
22
+ "filament_retraction_length",
23
+ "filament_retraction_minimum_travel",
24
+ "filament_retraction_speed",
25
+ "filament_settings_id",
26
+ "filament_type",
27
+ "filament_z_hop",
28
+ "flush_into_infill",
29
+ "flush_multiplier",
30
+ "flush_volumes_matrix",
31
+ "full_fan_speed_layer",
32
+ "hot_plate_temp",
33
+ "independent_support_layer_height",
34
+ "infill_direction",
35
+ "initial_layer_line_width",
36
+ "initial_layer_print_height",
37
+ "initial_layer_speed",
38
+ "inner_wall_filament_id",
39
+ "inner_wall_line_width",
40
+ "internal_solid_filament_id",
41
+ "internal_solid_infill_line_width",
42
+ "ironing_flow",
43
+ "ironing_spacing",
44
+ "ironing_speed",
45
+ "ironing_type",
46
+ "layer_height",
47
+ "line_width",
48
+ "machine_end_gcode",
49
+ "machine_max_acceleration_e",
50
+ "machine_max_acceleration_retracting",
51
+ "machine_max_acceleration_x",
52
+ "machine_max_acceleration_z",
53
+ "machine_max_jerk_e",
54
+ "machine_max_jerk_x",
55
+ "machine_max_jerk_z",
56
+ "machine_max_speed_e",
57
+ "machine_max_speed_x",
58
+ "machine_max_speed_z",
59
+ "machine_start_gcode",
60
+ "max_volumetric_extrusion_rate_slope",
61
+ "nozzle_diameter",
62
+ "nozzle_temperature",
63
+ "outer_wall_filament_id",
64
+ "outer_wall_line_width",
65
+ "outer_wall_speed",
66
+ "pressure_advance",
67
+ "prime_tower_width",
68
+ "prime_volume",
69
+ "printable_area",
70
+ "printable_height",
71
+ "raft_contact_distance",
72
+ "raft_expansion",
73
+ "raft_layers",
74
+ "reduce_crossing_wall",
75
+ "resolution",
76
+ "retraction_length",
77
+ "retraction_minimum_travel",
78
+ "retraction_speed",
79
+ "seam_position",
80
+ "seam_slope_type",
81
+ "skirt_distance",
82
+ "skirt_height",
83
+ "skirt_loops",
84
+ "slow_down_layer_time",
85
+ "sparse_infill_density",
86
+ "sparse_infill_filament_id",
87
+ "sparse_infill_line_width",
88
+ "sparse_infill_pattern",
89
+ "spiral_mode",
90
+ "support_angle",
91
+ "support_base_pattern",
92
+ "support_base_pattern_spacing",
93
+ "support_bottom_z_distance",
94
+ "support_expansion",
95
+ "support_filament",
96
+ "support_interface_bottom_layers",
97
+ "support_interface_filament",
98
+ "support_interface_pattern",
99
+ "support_interface_spacing",
100
+ "support_interface_top_layers",
101
+ "support_line_width",
102
+ "support_object_first_layer_gap",
103
+ "support_object_xy_distance",
104
+ "support_on_build_plate_only",
105
+ "support_remove_small_overhang",
106
+ "support_style",
107
+ "support_threshold_angle",
108
+ "support_threshold_overlap",
109
+ "support_top_z_distance",
110
+ "support_type",
111
+ "top_shell_layers",
112
+ "top_surface_filament_id",
113
+ "top_surface_line_width",
114
+ "travel_acceleration",
115
+ "travel_speed",
116
+ "tree_support_angle_slow",
117
+ "tree_support_branch_angle",
118
+ "tree_support_branch_angle_organic",
119
+ "tree_support_branch_diameter",
120
+ "tree_support_branch_diameter_angle",
121
+ "tree_support_branch_diameter_organic",
122
+ "tree_support_branch_distance",
123
+ "tree_support_branch_distance_organic",
124
+ "tree_support_tip_diameter",
125
+ "tree_support_top_rate",
126
+ "tree_support_wall_count",
127
+ "wall_generator",
128
+ "wall_loops",
129
+ "wipe_tower_x",
130
+ "wipe_tower_y",
131
+ "z_hop",
132
+ ]
@@ -23,7 +23,7 @@ const KERNEL_PATTERNS = ['rectilinear', 'grid', 'triangles', 'zigzag', 'gyroid',
23
23
  const KERNEL_SEAMS = ['nearest', 'aligned', 'back', 'random']
24
24
 
25
25
  // ---- Pass-through kernel parameters -----------------------------------------------------------------------
26
- // The kernel reads 159 parameters (see the reference table in engine/README.md, generated from its own reader).
26
+ // The kernel reads 159 parameters (see the reference table in engine/PARAMS.md, generated from its own reader).
27
27
  // These are the ones whose schema key carries the SAME name, so the mapping is the identity and the only real
28
28
  // decision is WHEN to send them. They are sent only when the settings map actually holds the key — never filled
29
29
  // in from the schema default — because the two defaults disagree for several of them:
@@ -340,8 +340,13 @@ export function deriveKernelParams(settings, opts) {
340
340
  // Support style: schema enum (default/grid/snug/organic/tree_slim/tree_strong/tree_hybrid)
341
341
  // -> kernel grid|tree|tree_lite. Since stage 18 the tree/organic family maps to the real organic TreeSupport ('tree',
342
342
  // generate_tree_support_3D -> real type5 branch toolpaths); everything else maps to grid.
343
+ // support_type participates too, as upstream's UI does: a tree(auto)/tree(manual) TYPE whose style is
344
+ // still 'default' is a tree request — before this, an imported Orca project saved with tree(auto) and
345
+ // the default style silently sliced grid. An explicit non-tree style keeps winning over the type.
343
346
  const styleRaw = String(S('support_style') ?? 'default')
344
- const support_style = /tree|organic/i.test(styleRaw) ? 'tree' : 'grid'
347
+ const typeRaw = String(S('support_type') ?? 'normal(auto)')
348
+ const support_style =
349
+ /tree|organic/i.test(styleRaw) || (/tree/i.test(typeRaw) && styleRaw === 'default') ? 'tree' : 'grid'
345
350
 
346
351
  // Bed: bounding box of the first rectangle in printable_area
347
352
  const pa = settingRaw(settings, 'printable_area')
@@ -698,6 +703,35 @@ export function deriveSlaParams(settings) {
698
703
  }
699
704
  }
700
705
 
706
+ // ---- Preset application -------------------------------------------------------------------------------------
707
+ // The clear-then-merge every preset consumer needs and each demo used to rewrite: a preset carries only the
708
+ // keys it sets, so merging one over another leaves the previous pick's leftovers underneath (an ABS chamber
709
+ // temperature under a PLA pick). `keys` is the catalog's own key set (filaments.keys, processApi.keys,
710
+ // printerKeys) — cleared first, so applying a material never disturbs a process pick and vice versa.
711
+ // A null/undefined preset (a failed settingsFor lookup) applies NOTHING — the previous pick stays whole,
712
+ // because half-clearing on a bad name would be worse than doing nothing.
713
+ export function applyPreset(settings, preset, keys) {
714
+ if (!preset) return settings ?? {}
715
+ const next = { ...(settings ?? {}) }
716
+ for (const key of keys ?? []) delete next[key]
717
+ return Object.assign(next, preset)
718
+ }
719
+
720
+ // ---- Ignored-key reporting ----------------------------------------------------------------------------------
721
+ // The FFF derivation consumes a curated key set (generated by probing — types/gen_kernel_params.mjs). Every
722
+ // other key in a settings map is accepted and silently does nothing to a slice, which is exactly how a
723
+ // support_type request once sliced grid with no warning. This is the inspection surface for that class:
724
+ // which of MY map's keys does deriveKernelParams actually read?
725
+ // Scope: the FFF kernel derivation ONLY. A key ignored here can still be real elsewhere — the SLA derivation
726
+ // (slaSettingKeys), the viewer (enable_prime_tower, printable_area), or project metadata — so treat the
727
+ // result as "has no effect on an FFF slice", not "useless".
728
+ import { KERNEL_SETTING_KEYS } from './kernel_setting_keys.js'
729
+ export const kernelSettingKeys = Object.freeze([...KERNEL_SETTING_KEYS])
730
+ const KERNEL_SETTING_KEY_SET = new Set(KERNEL_SETTING_KEYS)
731
+ export function ignoredKernelSettings(settings) {
732
+ return Object.keys(settings ?? {}).filter(key => !KERNEL_SETTING_KEY_SET.has(key)).sort()
733
+ }
734
+
701
735
  // ---- Preset files (the .json OrcaSlicer imports/exports) ----------------------------------------------------
702
736
  // Re-exported here rather than given their own subpath: they are settings-map transforms like the 3mf project
703
737
  // codecs above, and a consumer that has one hand on `settings` should not need a second import path for them.
@@ -16,6 +16,14 @@
16
16
  // which sets `quiet` on the messages it sends): this is a separate module instance from the viewer's log.js and
17
17
  // cannot read a flag set over there.
18
18
  import { assertLegacySlaFallback, parseSlaJob } from './sla_request.js'
19
+ import { withSliceWarnings } from './warnings.js'
20
+ import { withSliceThroughput } from './throughput.js'
21
+
22
+ // The kernel parses `params` as JSON text, so the raw protocol used to require a string — while the direct handle
23
+ // and createSlicerClient both take an object and stringify it for you. One object, two shapes, and the difference
24
+ // showed up only at the boundary. The worker now accepts either; the SLA branch always did, which is what made
25
+ // the split arbitrary rather than principled.
26
+ const paramsText = (params) => (typeof params === 'string' ? params : JSON.stringify(params ?? {}))
19
27
 
20
28
  let quiet = false
21
29
  const say = (level, ...args) => { if (!quiet) console[level](...args) }
@@ -87,6 +95,16 @@ const cursorShapeOf = (message) => (message.cursor === 'circle' ? 1 : 0)
87
95
  // a radius or a camera: what they select comes from the mesh's own topology, which is why they are a branch rather
88
96
  // than more optional fields on the brush call. `state === null` means erase (the entry points with no state
89
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
+
90
108
  const FILL_TOOLS = new Set(['smart', 'bucket', 'triangle'])
91
109
  const applyFill = (Module, message, state) => {
92
110
  const angle = Number.isFinite(message.angle) ? message.angle : 30
@@ -140,10 +158,11 @@ self.onmessage = async (e) => {
140
158
  self.postMessage({ type: 'layer', z, idx, gcode: '', paths, widths }, transfer)
141
159
  })
142
160
  let r
161
+ const started = performance.now()
143
162
  try { r = Kernel.slice_sla_job(job, onProgress) }
144
163
  finally { Kernel.clear_layer_sink() }
145
164
  if (r?.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
146
- self.postMessage({ type: 'done', result: r }); return
165
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - started) }); return
147
166
  }
148
167
  self.postMessage({ type: 'error', code: 'SLA_UNSUPPORTED_OBJECT_AWARE',
149
168
  error: 'Object-aware SLA slicing requires WASM kernel support' })
@@ -153,7 +172,7 @@ self.onmessage = async (e) => {
153
172
  // the same layer-sink streaming a kernel slice uses. The pure-JS contour slicer (sla_core.js) stays as the
154
173
  // fallback for a loaded kernel that predates the binding — it produces the same stream, minus the supports.
155
174
  if (d.cmd === 'sla') {
156
- const params = typeof d.params === 'string' ? d.params : JSON.stringify(d.params ?? {})
175
+ const params = paramsText(d.params)
157
176
  if (!modPromise) modPromise = loadCore()
158
177
  const Kernel = await modPromise
159
178
  const onProgress = (done, total) => self.postMessage({ type: 'progress', done, total })
@@ -165,15 +184,19 @@ self.onmessage = async (e) => {
165
184
  self.postMessage({ type: 'layer', z, idx, gcode: '', paths, widths }, transfer)
166
185
  })
167
186
  let r
187
+ const started = performance.now()
168
188
  try { r = Kernel.slice_sla(new Uint8Array(d.stl), params, onProgress) }
169
189
  finally { Kernel.clear_layer_sink() }
170
190
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
171
- self.postMessage({ type: 'done', result: r })
191
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - started) })
172
192
  return
173
193
  }
174
194
  const { sliceSla } = await import('./sla_core.js')
175
195
  const fallbackParams = JSON.parse(params)
176
196
  assertLegacySlaFallback(fallbackParams)
197
+ // The JS fallback reports no phase timings at all, so its `kernelMs` comes back null rather than 0 — the
198
+ // wall time and the two rates are still the honest figures for what it did.
199
+ const startedFallback = performance.now()
177
200
  const r = sliceSla(new Uint8Array(d.stl), fallbackParams, {
178
201
  onProgress,
179
202
  onLayer: (L) => {
@@ -184,7 +207,7 @@ self.onmessage = async (e) => {
184
207
  },
185
208
  })
186
209
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
187
- self.postMessage({ type: 'done', result: r })
210
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - startedFallback) })
188
211
  return
189
212
  }
190
213
  if (!modPromise) modPromise = loadCore()
@@ -211,6 +234,8 @@ self.onmessage = async (e) => {
211
234
  applyFill(Module, d, paintedState)
212
235
  }
213
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))
214
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))
215
240
  self.postMessage(paintedReply(Module, d, paintedState)); return
216
241
  }
@@ -223,6 +248,11 @@ self.onmessage = async (e) => {
223
248
  // change to the same counts, and the viewer's listeners must not need to know which brush produced it.
224
249
  if (d.cmd === 'erase') {
225
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))
226
256
  else Module.selector_erase(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius)
227
257
  self.postMessage(paintedReply(Module, d, null)); return
228
258
  }
@@ -279,6 +309,34 @@ self.onmessage = async (e) => {
279
309
  self.postMessage(reply, transfer); return
280
310
  }
281
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
+
282
340
  if (d.stall) return // stage-30 test hook: simulate a hang -> verifies the main-thread watchdog fires (not set in production)
283
341
  // mt (SAB): share the address of the real support progress counter (u32) with main once — even while the kernel is
284
342
  // blocked inside C++, the UI thread polls the SAB directly to show support progress. st (non-shared buffer) is unsupported -> not sent.
@@ -302,11 +360,17 @@ self.onmessage = async (e) => {
302
360
  self.postMessage({ type: 'layer', z, idx, gcode, paths, widths }, transfer)
303
361
  })
304
362
  let r
305
- try { r = Module.slice(new Uint8Array(d.stl), d.params, onProgress) }
363
+ // Timed around the kernel call INCLUDING the layer sink, which posts each layer to the main thread from inside
364
+ // it — that transfer is part of what a streamed slice costs and leaving it out would flatter the number.
365
+ const started = performance.now()
366
+ try { r = Module.slice(new Uint8Array(d.stl), paramsText(d.params), onProgress) }
306
367
  finally { Module.clear_layer_sink() }
307
368
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
308
369
  // streamed=true -> g-code/layers were already emitted as 'layer' (result holds stats only). batch/MM keep them in result.
309
- self.postMessage({ type: 'done', result: r })
370
+ // withSliceWarnings names what the slice got away with (an off-bed model) on the result itself, and
371
+ // withSliceThroughput how fast it ran — the raw protocol carries both, so createSlicerClient's callers get
372
+ // them without the client having to re-derive anything.
373
+ self.postMessage({ type: 'done', result: withSliceThroughput(withSliceWarnings(r), performance.now() - started) })
310
374
  } catch (err) {
311
375
  // Includes the WASM abort("memory access out of bounds") — the main thread's OOM ladder decides on re-creation / economy retry.
312
376
  self.postMessage({ type: 'error', error: String((err && err.message) || err), ...(err?.code ? { code: err.code } : {}) })
Binary file
Binary file
@@ -0,0 +1,53 @@
1
+ // How fast the slice actually ran.
2
+ //
3
+ // The kernel already times its own phases (`t_pass1_ms`, `t_surface_ms`, `t_support_ms`, `t_emit_ms` — set
4
+ // unconditionally in finish.cpp), but a phase breakdown is a profiling tool, not an answer to "how fast is this".
5
+ // The wall time around the call is, and only the caller can measure it: it includes the STL crossing the WASM
6
+ // boundary and, in the worker, the layer stream leaving it.
7
+ //
8
+ // `msPerMsegment` is the field to compare runs with, and it is not decoration. The kernel is not deterministic in
9
+ // segment COUNT — the same input has been measured 15% apart between runs — so a raw millisecond figure compares
10
+ // two different amounts of work and reads as a regression that is not there. Normalizing by the work done is what
11
+ // makes two numbers comparable; `layersPerSecond` is the one to show a person, since layers are what a progress
12
+ // bar counts.
13
+ //
14
+ // Everything here is derived. Nothing replaces or renames a `stats` field.
15
+
16
+ // The two technologies time entirely different passes, and only the CONSECUTIVE ones may be summed.
17
+ // FFF: pass1 -> surfaces -> support -> emit (finish.cpp).
18
+ // SLA: contours -> sample -> tree -> raster -> emit (slice_sla.cpp). Its `t_sample_*` and `t_raster_*` keys are
19
+ // nested INSIDE t_sample_ms / t_raster_ms — the C++ says so at each set() — so adding them would double-count.
20
+ // `t_emit_ms` is the one name both use, which is exactly why the FFF list cannot be applied to an SLA result:
21
+ // it would find that single key, sum it alone, and report the emit pass as the whole kernel time.
22
+ const FFF_PHASES = ['t_pass1_ms', 't_surface_ms', 't_support_ms', 't_emit_ms']
23
+ const SLA_PHASES = ['t_contours_ms', 't_sample_ms', 't_tree_ms', 't_raster_ms', 't_emit_ms']
24
+
25
+ /**
26
+ * @param {object} [stats] the slice result's `stats`
27
+ * @param {number} wallMs wall time around the slice call, ms
28
+ */
29
+ export function sliceThroughput(stats, wallMs) {
30
+ const ms = Math.max(0, Number(wallMs) || 0)
31
+ const layers = Number(stats?.layers) || 0
32
+ // SLA counts mask segments here rather than toolpath segments, which is the same thing for this purpose: the
33
+ // amount of geometry the run actually produced, so the normalized figure stays comparable within a technology.
34
+ const segments = Number(stats?.path_segments) || 0
35
+ // The kernel's own total, when it reported its phases — always smaller than `ms`, and the gap is the boundary
36
+ // cost (STL upload, the layer stream, the JS sink). Worth having side by side: a slice that is slow only in
37
+ // the gap is a marshalling problem, not a slicing one.
38
+ const phases = (stats?.sla ? SLA_PHASES : FFF_PHASES)
39
+ .map(key => Number(stats?.[key])).filter(Number.isFinite)
40
+ return {
41
+ ms,
42
+ kernelMs: phases.length ? phases.reduce((sum, value) => sum + value, 0) : null,
43
+ layersPerSecond: ms > 0 ? (layers * 1000) / ms : 0,
44
+ // Null rather than 0 when there is nothing to normalize against: 0 ms/Mseg would read as infinitely fast.
45
+ msPerMsegment: segments > 0 ? ms / (segments / 1e6) : null,
46
+ }
47
+ }
48
+
49
+ /** Attaches `throughput` to a slice result in place and returns it. */
50
+ export function withSliceThroughput(result, wallMs) {
51
+ if (result && !result.error) result.throughput = sliceThroughput(result.stats, wallMs)
52
+ return result
53
+ }
@@ -0,0 +1,23 @@
1
+ // Result-level warnings — the slices that succeed while being wrong.
2
+ //
3
+ // The kernel reports an off-bed model as one flag among twenty stats numbers (`over_bed_model`), which a consumer
4
+ // only finds by already knowing it exists: a bed-centred model handed in without the plate-local subtraction slices
5
+ // at X 234.7–265.4 on a 250mm bed with plausible time and material and no error (examples/DEMOS.md §4.5). All four
6
+ // demos ended up writing the same `if (result.stats.over_bed_model) throw` line. Naming the whole set on the result
7
+ // lets a host surface or log every such condition without knowing which flags the kernel happens to carry.
8
+ //
9
+ // Warnings are strings, and the array is always present so `result.warnings.length` needs no guard. The stats flags
10
+ // stay exactly where they were — this is an additional view of them, not a replacement.
11
+
12
+ /** @param {object} [stats] a slice result's `stats` @returns {string[]} */
13
+ export function sliceWarnings(stats) {
14
+ const warnings = []
15
+ if (stats?.over_bed_model) warnings.push('over_bed_model')
16
+ return warnings
17
+ }
18
+
19
+ /** Attaches `warnings` to a slice result in place (the result is a fresh object per slice) and returns it. */
20
+ export function withSliceWarnings(result) {
21
+ if (result && !result.error) result.warnings = sliceWarnings(result.stats)
22
+ return result
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-slicer",
3
- "version": "0.2.2",
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
+ }
@@ -19,6 +19,26 @@ export function settingScalar(settings: SlicerSettings | null | undefined, key:
19
19
  */
20
20
  export function deriveKernelParams(settings: SlicerSettings | null | undefined, opts?: { plate?: number }): Record<string, unknown>
21
21
 
22
+ /**
23
+ * Clear-then-merge preset application: deletes `keys` (the catalog's own key set) from `settings`, then
24
+ * merges `preset`. A null preset applies nothing and returns the settings unchanged — a failed lookup must
25
+ * not half-clear the previous pick.
26
+ */
27
+ export function applyPreset(settings: SlicerSettings | null | undefined, preset: Partial<SlicerSettings> | null | undefined, keys: readonly string[] | null | undefined): SlicerSettings
28
+
29
+ /**
30
+ * The schema keys deriveKernelParams consumes — the distinct "From setting" column of engine/PARAMS.md,
31
+ * generated by the same probe that generates the table (so it cannot drift from the real mapping).
32
+ */
33
+ export const kernelSettingKeys: readonly string[]
34
+
35
+ /**
36
+ * The keys of `settings` that deriveKernelParams does NOT read — they are accepted and silently have no
37
+ * effect on an FFF slice. Scope is the FFF derivation only: a key listed here can still be meaningful to
38
+ * the SLA derivation, the viewer, or project metadata. Sorted.
39
+ */
40
+ export function ignoredKernelSettings(settings: SlicerSettings | null | undefined): string[]
41
+
22
42
  /** Which slicing technology the settings map's printer profile declares. Defaults to FFF. */
23
43
  export function printerTechnology(settings: SlicerSettings | null | undefined): 'FFF' | 'SLA'
24
44