three-slicer 0.2.2 → 0.2.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/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'
@@ -32,7 +31,14 @@ const slaTransferables = (job) => job.objects.flatMap(object => [object, ...(obj
32
31
  * @param {Worker} [worker] an existing worker to drive. Omit to create one — which only works in a browser,
33
32
  * since Node has no global Worker; a Node caller should use createSlicer() from the root entry instead.
34
33
  */
35
- export function createSlicerClient(worker = new Worker(engineWorkerURL(), { type: 'module' })) {
34
+ // The default is written as the full `new Worker(new URL('literal', import.meta.url), { type: 'module' })`
35
+ // expression rather than through engineWorkerURL(), and that is the whole reason the no-argument form works
36
+ // after `vite build`. Bundlers recognize this exact shape as a worker ENTRY and emit it with its own chunk
37
+ // graph (the st/mt kernels it dynamically imports); split across a function call, the same file matched Vite's
38
+ // plain-asset rule instead and was copied verbatim, still importing an unhashed `./slicer_core.js` that dist
39
+ // did not contain — a 404 on the first message, visible only in a production build. Same reason the viewer
40
+ // keeps make_worker.js out of its own bundle. Do not factor this expression out.
41
+ export function createSlicerClient(worker = new Worker(new URL('./slicer.worker.js', import.meta.url), { type: 'module' })) {
36
42
  const pending = [] // FIFO of {expect, resolve, reject, onProgress, onLayer, chunks, layers}
37
43
  let cancelFlag = null // Uint32Array over the worker's SharedArrayBuffer — mt kernel only
38
44
  let closed = false
@@ -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) }
@@ -140,10 +148,11 @@ self.onmessage = async (e) => {
140
148
  self.postMessage({ type: 'layer', z, idx, gcode: '', paths, widths }, transfer)
141
149
  })
142
150
  let r
151
+ const started = performance.now()
143
152
  try { r = Kernel.slice_sla_job(job, onProgress) }
144
153
  finally { Kernel.clear_layer_sink() }
145
154
  if (r?.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
146
- self.postMessage({ type: 'done', result: r }); return
155
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - started) }); return
147
156
  }
148
157
  self.postMessage({ type: 'error', code: 'SLA_UNSUPPORTED_OBJECT_AWARE',
149
158
  error: 'Object-aware SLA slicing requires WASM kernel support' })
@@ -153,7 +162,7 @@ self.onmessage = async (e) => {
153
162
  // the same layer-sink streaming a kernel slice uses. The pure-JS contour slicer (sla_core.js) stays as the
154
163
  // fallback for a loaded kernel that predates the binding — it produces the same stream, minus the supports.
155
164
  if (d.cmd === 'sla') {
156
- const params = typeof d.params === 'string' ? d.params : JSON.stringify(d.params ?? {})
165
+ const params = paramsText(d.params)
157
166
  if (!modPromise) modPromise = loadCore()
158
167
  const Kernel = await modPromise
159
168
  const onProgress = (done, total) => self.postMessage({ type: 'progress', done, total })
@@ -165,15 +174,19 @@ self.onmessage = async (e) => {
165
174
  self.postMessage({ type: 'layer', z, idx, gcode: '', paths, widths }, transfer)
166
175
  })
167
176
  let r
177
+ const started = performance.now()
168
178
  try { r = Kernel.slice_sla(new Uint8Array(d.stl), params, onProgress) }
169
179
  finally { Kernel.clear_layer_sink() }
170
180
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
171
- self.postMessage({ type: 'done', result: r })
181
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - started) })
172
182
  return
173
183
  }
174
184
  const { sliceSla } = await import('./sla_core.js')
175
185
  const fallbackParams = JSON.parse(params)
176
186
  assertLegacySlaFallback(fallbackParams)
187
+ // The JS fallback reports no phase timings at all, so its `kernelMs` comes back null rather than 0 — the
188
+ // wall time and the two rates are still the honest figures for what it did.
189
+ const startedFallback = performance.now()
177
190
  const r = sliceSla(new Uint8Array(d.stl), fallbackParams, {
178
191
  onProgress,
179
192
  onLayer: (L) => {
@@ -184,7 +197,7 @@ self.onmessage = async (e) => {
184
197
  },
185
198
  })
186
199
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
187
- self.postMessage({ type: 'done', result: r })
200
+ self.postMessage({ type: 'done', result: withSliceThroughput(r, performance.now() - startedFallback) })
188
201
  return
189
202
  }
190
203
  if (!modPromise) modPromise = loadCore()
@@ -302,11 +315,17 @@ self.onmessage = async (e) => {
302
315
  self.postMessage({ type: 'layer', z, idx, gcode, paths, widths }, transfer)
303
316
  })
304
317
  let r
305
- try { r = Module.slice(new Uint8Array(d.stl), d.params, onProgress) }
318
+ // Timed around the kernel call INCLUDING the layer sink, which posts each layer to the main thread from inside
319
+ // it — that transfer is part of what a streamed slice costs and leaving it out would flatter the number.
320
+ const started = performance.now()
321
+ try { r = Module.slice(new Uint8Array(d.stl), paramsText(d.params), onProgress) }
306
322
  finally { Module.clear_layer_sink() }
307
323
  if (r && r.error) { self.postMessage({ type: 'error', error: String(r.error) }); return }
308
324
  // 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 })
325
+ // withSliceWarnings names what the slice got away with (an off-bed model) on the result itself, and
326
+ // withSliceThroughput how fast it ran — the raw protocol carries both, so createSlicerClient's callers get
327
+ // them without the client having to re-derive anything.
328
+ self.postMessage({ type: 'done', result: withSliceThroughput(withSliceWarnings(r), performance.now() - started) })
310
329
  } catch (err) {
311
330
  // Includes the WASM abort("memory access out of bounds") — the main thread's OOM ladder decides on re-creation / economy retry.
312
331
  self.postMessage({ type: 'error', error: String((err && err.message) || err), ...(err?.code ? { code: err.code } : {}) })
@@ -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.3",
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",
@@ -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
 
package/types/viewer.d.ts CHANGED
@@ -52,6 +52,37 @@ export interface ViewportProps {
52
52
  * shown on the selected plate; the kernel is never started. While it is set, auto re-slice leaves that plate alone.
53
53
  */
54
54
  gcode?: string | null
55
+ /**
56
+ * An `.sl1` archive rendered as a raster preview — the SLA half of {@link ViewportProps.gcode}'s contract: it
57
+ * lands on the selected plate and the kernel is never started. Set both and the G-code wins; one plate holds
58
+ * one artifact.
59
+ *
60
+ * Two things it does that `gcode` does not. It writes to your `settings` — the archive's own job description,
61
+ * `printer_technology` included, because an .sl1 opened in an FFF session has to switch it. And it reconstructs
62
+ * a surface from the masks in the background, so the preview fills in a moment after the layers appear.
63
+ *
64
+ * Re-injects whenever the value's IDENTITY changes, so hold the File/buffer in state rather than building it
65
+ * during render — a fresh `new Uint8Array(...)` each render re-imports each render. `{name, data}` exists
66
+ * because the name is the import notice's subject and the filename a re-export hands back.
67
+ */
68
+ sl1?: File | Blob | ArrayBuffer | Uint8Array | { name?: string, data: ArrayBuffer | Uint8Array } | null
69
+ /**
70
+ * Content imported once on mount, through the same extension dispatch as a drop: models (STL/OBJ/3MF/AMF/PLY —
71
+ * a 3mf project applies its settings, plates and painting), `.sl1` raster archives, and preset files
72
+ * (`.json` / `.orca_printer` / `.orca_bundle` / `.orca_filament` / `.zip`). The `{name, data}` form exists
73
+ * because the dispatch runs on the file NAME's extension. Deliberately mount-only — recreating the array on a
74
+ * later render does not re-import; runtime loading is the picker, drop, or a remount.
75
+ */
76
+ files?: Array<File | { name: string, data: ArrayBuffer | Uint8Array }>
77
+ /**
78
+ * A token whose identity CHANGE requests one slice of the current plate — the host's Slice button:
79
+ * keep a counter in state and bump it. The mount value is inert (0 and null both slice nothing), so is
80
+ * setting it back to `null`. Ignored while a `gcode`/`sl1` injection holds the plate or the scene is
81
+ * empty — the same conditions under which the built-in slice bar is not pressable. A change while a
82
+ * slice is running cancels it and re-slices: the last request wins.
83
+ */
84
+ sliceRequest?: number | string | object | null
85
+
55
86
  /** Initial filament colours (hex), one per extruder. Defaults to the built-in T1/T2 pair. */
56
87
  defaultExtruderColors?: string[] | null
57
88
  /**
@@ -62,7 +93,12 @@ export interface ViewportProps {
62
93
  /** Every value change in one channel. See {@link ViewportEvent}. */
63
94
  onEvent?: (event: ViewportEvent) => void
64
95
  /** A finished slice, fired where the result is cached — switching plate tabs does not re-fire it. */
65
- onSliced?: (result: { plate: number; stats: Record<string, unknown>; gcode: string }) => void
96
+ /** `throughput` is how fast the slice ran — see `SliceThroughput` in the engine entry. Absent on a kernel
97
+ * result that carried none (a cached plate re-announced from before the field existed). */
98
+ onSliced?: (result: {
99
+ plate: number; stats: Record<string, unknown>; gcode: string
100
+ throughput?: { ms: number; kernelMs: number | null; layersPerSecond: number; msPerMsegment: number | null }
101
+ }) => void
66
102
  }
67
103
 
68
104
  /**
@@ -97,6 +133,9 @@ export type LockablePanel =
97
133
  export type ViewportPanel =
98
134
  | 'topBar' | 'gizmoRail' | 'objectToolbar' | 'paintPanel' | 'statsCard' | 'plateBar' | 'emptyHint' | 'status'
99
135
  | 'sidebar' | 'printerCard' | 'filamentCard' | 'objectList' | 'previewControls' | 'processCard' | 'sliceBar'
136
+ /** The horizontal move scrub under the canvas — how far into the top shown layer the print has got. Preview
137
+ * and FFF only: a resin layer is cured in one exposure and has no intra-layer order to walk. */
138
+ | 'moveBar'
100
139
  /** The over-the-bed warning, shown in Prepare when something leaves the printable area. */
101
140
  | 'bedWarn'
102
141
  /** The prime tower card. Only rendered anyway once a second filament exists. */
@@ -115,10 +154,20 @@ export type ViewportEvent =
115
154
  | { type: 'slicing'; value: boolean }
116
155
  /** 0..1. Fires several times a second while slicing — throttle on the host side if that matters. */
117
156
  | { type: 'progress'; value: number }
157
+ /**
158
+ * Live throughput: layers finished per second over a 250ms window, `0` between slices. An SLA slice reports it
159
+ * throughout. An FFF slice reports it only once the emission pass begins streaming layers — the earlier passes
160
+ * publish no per-layer progress, so nothing is measured there and none is invented. For the whole-slice figure
161
+ * use `throughput` from `onSliced`.
162
+ */
163
+ | { type: 'sliceRate'; value: number }
118
164
  | { type: 'viewType'; value: 'feature' | 'speed' | 'height' | 'width' | 'fan' | 'temp' | 'filament' }
119
165
  | { type: 'paintMode'; value: 'off' | 'enforcer' | 'blocker' | 'material' }
120
166
  | { type: 'layerCount'; value: number }
121
167
  | { type: 'layerRange'; value: { lo: number; hi: number } }
168
+ /** The move scrub's position within the top shown layer; `max` is that layer's move count, extrusions and
169
+ * travels together. Not fired while the whole layer is shown. */
170
+ | { type: 'moveScrub'; value: { at: number; max: number } }
122
171
  | { type: 'error'; value: string }
123
172
  | { type: 'notice'; value: string }
124
173
 
package/types/worker.d.ts CHANGED
@@ -65,11 +65,12 @@ export type SlicerRequest =
65
65
  | { cmd: 'slaJob'; job: SlaJob }
66
66
  /**
67
67
  * Slice — and note it carries **no `cmd`**: slicing is the worker's default action, and leaving the field off
68
- * is what selects it. `params` must be a JSON **string**; the worker hands it straight to the kernel, which
69
- * parses JSON text. (`createSlicer().slice()` on the direct handle also accepts an object and stringifies it.)
68
+ * is what selects it. `params` accepts a JSON string or an object; the kernel parses JSON text, so an object is
69
+ * stringified for you (it was string-only through 0.2.2, which made this the one call path with a different
70
+ * contract from `createSlicer().slice()` and `createSlicerClient().slice()`).
70
71
  * Replies: `progress` and `layer` while it runs, then `done` — or `error`.
71
72
  */
72
- | { stl: ArrayBuffer; params: string }
73
+ | { stl: ArrayBuffer; params: string | object }
73
74
 
74
75
  export type SlicerResponse =
75
76
  | { type: 'warm' }