three-slicer 0.1.3 → 0.1.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/data/ui-tree.json CHANGED
@@ -670,14 +670,33 @@
670
670
  "group": "Retraction",
671
671
  "line": 4000,
672
672
  "options": [
673
- "<custom-widget line:4033>"
673
+ "filament_retraction_length",
674
+ "filament_z_hop",
675
+ "filament_z_hop_types",
676
+ "filament_retract_lift_above",
677
+ "filament_retract_lift_below",
678
+ "filament_retract_lift_enforce",
679
+ "filament_retraction_speed",
680
+ "filament_deretraction_speed",
681
+ "filament_retract_restart_extra",
682
+ "filament_retraction_minimum_travel",
683
+ "filament_retract_when_changing_layer",
684
+ "filament_wipe",
685
+ "filament_wipe_distance",
686
+ "filament_retract_before_wipe",
687
+ "filament_retract_after_wipe",
688
+ "filament_long_retractions_when_cut",
689
+ "filament_retraction_distances_when_cut"
674
690
  ]
675
691
  },
676
692
  {
677
693
  "group": "Ironing",
678
694
  "line": 4061,
679
695
  "options": [
680
- "<custom-widget line:4138>"
696
+ "filament_ironing_flow",
697
+ "filament_ironing_spacing",
698
+ "filament_ironing_inset",
699
+ "filament_ironing_speed"
681
700
  ]
682
701
  }
683
702
  ]
package/engine/README.md CHANGED
@@ -17,7 +17,7 @@ npm i three-slicer
17
17
  ```js
18
18
  import { createSlicer } from 'three-slicer'
19
19
 
20
- const slicer = await createSlicer() // loads the WASM kernel (~3.4 MB, base64-inlined)
20
+ const slicer = await createSlicer() // loads the WASM kernel (~3.3 MB, base64-inlined)
21
21
 
22
22
  // Batch: full result at once
23
23
  const r = slicer.slice(stlArrayBuffer, { layer_height: 0.2, wall_loops: 2 })
@@ -37,15 +37,67 @@ Off-main-thread (browser): `new Worker(engineWorkerURL(), { type: 'module' })`
37
37
 
38
38
  Two levels — use whichever fits:
39
39
 
40
- 1. **Kernel params (this package's native contract)** — a flat JSON object of ~50 keys (`layer_height`, `wall_loops`, `infill_density` 0–1, `sparse_infill_pattern`, `enable_support`, `wall_generator: 'classic' | 'arachne'`, …). See `src/settings.js` for the full shape.
41
- 2. **OrcaSlicer settings map** — a sparse `{schemaKey: value}` map using original OrcaSlicer option keys (907 defined in `@three-slicer/data`); unset keys fall back to schema defaults:
40
+ 1. **Kernel params (this package's native contract)** — a flat JSON object of ~90 keys (`layer_height`, `wall_loops`, `infill_density` 0–1, `sparse_infill_pattern`, `enable_support`, `wall_generator: 'classic' | 'arachne'`, …). See `src/settings.js` for the full shape.
41
+ 2. **OrcaSlicer settings map** — a sparse `{schemaKey: value}` map using original OrcaSlicer option keys (923 defined in `@three-slicer/data`); unset keys fall back to schema defaults:
42
42
 
43
43
  ```js
44
44
  import { deriveKernelParams } from 'three-slicer/settings'
45
45
  slicer.slice(stl, deriveKernelParams({ layer_height: 0.25, sparse_infill_density: 15 }))
46
46
  ```
47
47
 
48
- **Known limit:** `deriveKernelParams` maps a curated whitelist of ~50 schema keys to kernel params. Editing other schema keys has no slicing effect (they exist for UI/metadata). Vector options use their first element only.
48
+ **Known limit:** `deriveKernelParams` maps a curated whitelist of 92 schema keys to kernel params. Editing other schema keys has no slicing effect (they exist for UI/metadata). Scalar options use their first element only; the filament options listed under *Materials* below are the exception — those keep every extruder's entry.
49
+
50
+ ## Materials and multi-material
51
+
52
+ A material is a **filament preset**: a set of schema values (temperatures, flow, diameter, cooling, retraction/z-hop overrides) that a printer profile declares itself compatible with. Read the catalog through the facade, never by decoding `three-slicer/data/filaments.js` by hand:
53
+
54
+ ```js
55
+ import { filamentPresets, deriveKernelParams } from 'three-slicer/settings'
56
+
57
+ const filaments = await filamentPresets() // lazy — the artifact loads on first call
58
+ filaments.listFor('Bambu Lab X1 Carbon 0.4 nozzle') // [{name, type, vendor}, …]
59
+ filaments.recommendedFor('Bambu Lab X1 Carbon 0.4 nozzle') // the vendor's shortlist, filtered to the compatible set
60
+ Object.assign(settings, filaments.settingsFor('Bambu PLA Basic @BBL X1C'))
61
+ ```
62
+
63
+ **One material per extruder.** Upstream stores every filament option as one entry per extruder, so a multi-material settings map writes each extruder's material at its own index:
64
+
65
+ ```js
66
+ const params = {
67
+ ...deriveKernelParams({
68
+ nozzle_temperature: [255, 220], // T0 ABS, T1 PLA
69
+ filament_flow_ratio: [0.95, 1.0],
70
+ }),
71
+ // -> { extruder_nozzle_temp: [255, 220], extruder_flow_ratio: [0.95, 1.0], … }
72
+ extruder_count: 2, // not a derived key — set it on the params directly
73
+ }
74
+ ```
75
+
76
+ The kernel reads those arrays **positionally** and reloads the whole loaded-filament set — diameter, flow, retraction length/speed, z-hop, plus an `M109` when the temperatures actually disagree — at every `T` change. A single-element (or absent) option produces **no** `extruder_*` array at all, and the kernel then reads its scalars exactly as before, so a single-material slice is byte-identical to what it was before this feature existed.
77
+
78
+ `support_filament` / `support_interface_filament` are 1-based filament indices (`0` = "Default", keep the loaded tool) selecting which extruder prints the support base/raft and the support interface. `0` is omitted from the params rather than sent, so it emits no `T` command anywhere and leaves the support G-code byte-identical.
79
+
80
+ ### Painting a region onto another extruder
81
+
82
+ Painting is the only way a **single** object can print in two materials — there is no triangle boundary to split on, only painted facets. The painted facets of each state are projected per layer and the sliced polygon is partitioned against them; where two extruders claim the same area the higher-numbered one wins (a total order on a small integer, so the result never depends on paint or clipper ordering).
83
+
84
+ Painting states are upstream's `EnforcerBlockerType`, one enum serving two jobs: `1` = ENFORCER = **Extruder1**, `2` = BLOCKER = **Extruder2**, `3..16` = Extruder3..16. The state-addressed protocol lives on the worker (`three-slicer/worker`); the direct handle's `slice.paint({enforcer})` is the original boolean pair only.
85
+
86
+ ```js
87
+ worker.postMessage({ cmd: 'prepare', stl }) // -> {type:'prepared', facets}
88
+ worker.postMessage({ cmd: 'paint', state: 3, facet, hx, hy, hz, cx, cy, cz, radius, states: [1,2,3] })
89
+ // -> {type:'painted', enf, blk, counts: {1: n, 2: n, 3: n}}
90
+ worker.postMessage({ cmd: 'erase', facet, hx, hy, hz, cx, cy, cz, radius }) // back to the default extruder
91
+ ```
92
+
93
+ `erase` is its **own command**, not `{cmd:'paint', state: 0}`: embind coerces a JS `false` to the int `0` == NONE, and the legacy blocker brush sends exactly that `false` — so the state path rejects `0` outright and only `erase`, which takes no state argument, can clear anything. A message without `state`/`states` gets the original reply verbatim, so existing listeners are unaffected.
94
+
95
+ Two consequences worth knowing before you build on this:
96
+
97
+ - Because one selector serves both jobs, **a support BLOCKER paint and an Extruder2 paint are the same mark**, and a facet can hold only one of them. The two brushes are therefore mutually exclusive, which is a data constraint, not a UI simplification.
98
+ - The painted multi-material path is taken only when support is **off** — it emits no support at all. **Paint and support cannot currently produce two materials together.**
99
+
100
+ Results carry the split: `stats.filament_mm_by_tool` (indexed by tool number, one slot per extruder, sums to `filament_mm`) and `stats.filament_mm_purge` (the prime/wipe tower share, already included in the per-tool figures). In the streamed toolpath, `paths[k+3]` encodes `role + tool * 16` — mask with `& 15` for the role and `>>> 4` for the tool.
49
101
 
50
102
  ## License
51
103
 
@@ -16,3 +16,5 @@ export { default as printers } from 'three-slicer/data/printers.json' with { typ
16
16
  // other direction: a dynamic JSON import needs the attribute in Node, and that same attribute makes browsers
17
17
  // reject the text/javascript response a dev server gives for .json.
18
18
  export const loadProcesses = () => import('three-slicer/data/processes.js').then(m => m.default)
19
+ // Same story for the filament presets (~540 KB, only needed once a material picker opens).
20
+ export const loadFilaments = () => import('three-slicer/data/filaments.js').then(m => m.default)
@@ -1,12 +1,21 @@
1
1
  // Maps the actual values of the (editable) right-hand settings panel -> kernel slice parameters.
2
2
  // - Settings state is a sparse map (key->value): only edited keys are stored, otherwise the config-schema default.
3
3
  // - For vector types (coFloats/coInts, …) only the first element is used/edited (simplification).
4
- import { schema, printers, loadProcesses } from './data.js'
4
+ import { schema, printers, loadProcesses, loadFilaments } from './data.js'
5
5
 
6
6
  export function schemaDefault(key) { return schema[key]?.default }
7
7
  export function settingRaw(settings, key) { return (settings && key in settings) ? settings[key] : schemaDefault(key) }
8
8
  // Normalize to a scalar ([0] for vectors)
9
- export function settingScalar(settings, key) { const v = settingRaw(settings, key); return Array.isArray(v) ? v[0] : v }
9
+ // A per-extruder column reduced to the single value the kernel's scalar reader wants. The FIRST SET entry, not
10
+ // literally index 0: the filament card writes one column per extruder and leaves a null wherever that extruder has
11
+ // no preset, so an assignment that starts at T2 puts a null at index 0. Taking that null made filament_diameter 0,
12
+ // which divides into the extrusion maths and turned the whole slice into NaN — measured as
13
+ // "; filament used [mm] = nan, nan" from a print whose T1 had no material picked.
14
+ export function settingScalar(settings, key) {
15
+ const v = settingRaw(settings, key)
16
+ if (!Array.isArray(v)) return v
17
+ return v.find(entry => entry != null && entry !== '') ?? v[0]
18
+ }
10
19
 
11
20
  // Stage 8: real ported Fill patterns (gyroid TPMS/honeycomb/3dhoneycomb/crosshatch/concentric) + the older approximations
12
21
  const KERNEL_PATTERNS = ['rectilinear', 'grid', 'triangles', 'zigzag', 'gyroid', 'gyroid_approx',
@@ -67,6 +76,45 @@ export function processPresets() {
67
76
  return processesPromise
68
77
  }
69
78
 
79
+ // Filament (material) presets — same lazy facade as the process presets above, over the ~540 KB filaments.js.
80
+ // Entries come out as objects rather than bare names because a material picker groups by type, and the type
81
+ // label only exists in this file. Both lists are in upstream order: byPrinter is the compatible set,
82
+ // defaultsByModel the vendor's recommendation (a subset, so the two overlap by design).
83
+ let filamentsPromise = null
84
+ export function filamentPresets() {
85
+ filamentsPromise ??= loadFilaments().then(data => {
86
+ // type/vendor are empty strings when the profile chain declares neither — the picker buckets those itself
87
+ const view = i => { const [name, , type, vendor] = data.presets[i]; return { name, type, vendor } }
88
+ return {
89
+ /** Every key a filament preset can set — clear these before applying a different material */
90
+ keys: data.keys,
91
+ /** Every material in the catalog — for a picker shown before any printer is chosen */
92
+ all: () => data.presets.map((_, i) => view(i)),
93
+ /** Materials compatible with a printer profile, as `{name, type, vendor}` */
94
+ listFor: (printerProfileName) => (data.byPrinter[printerProfileName] ?? []).map(view),
95
+ /** The vendor's recommended materials for that printer's model, same shape as listFor.
96
+ * Filtered to the compatible set, which is not redundant: the recommendation is declared on the machine
97
+ * *model* and so is nozzle-agnostic, while its entries name nozzle-specific presets ("… @Kobra 3 0.4 nozzle").
98
+ * On a 0.2 nozzle profile 41% of the raw entries (1749 of 4259 across all printers) name a material whose
99
+ * own compatible list excludes it — offering those would apply a preset upstream considers incompatible. */
100
+ recommendedFor: (printerProfileName) => {
101
+ const compatible = new Set(data.byPrinter[printerProfileName] ?? [])
102
+ return (data.defaultsByModel[printerEntry(printerProfileName)?.[2] ?? ''] ?? [])
103
+ .filter(i => compatible.has(i)).map(view)
104
+ },
105
+ /** The settings a material applies, ready to merge. `null` when unknown. */
106
+ settingsFor: (presetName) => {
107
+ const preset = data.presets.find(([name]) => name === presetName)
108
+ if (!preset) return null
109
+ const row = data.sets[preset[1]], out = {}
110
+ data.keys.forEach((key, i) => { if (row[i] != null) out[key] = row[i] })
111
+ return out
112
+ },
113
+ }
114
+ })
115
+ return filamentsPromise
116
+ }
117
+
70
118
  function printerEntry(profileName) {
71
119
  for (const models of Object.values(printers.byVendor)) {
72
120
  const entry = models[profileName]
@@ -88,12 +136,102 @@ export function printerSettings(profileName) {
88
136
  /** The vendor's recommended process preset for a printer, or '' when the profile names none. */
89
137
  export function printerDefaultPreset(profileName) { return printerEntry(profileName)?.[3] ?? '' }
90
138
 
139
+ // ---- Imported project settings (a slicer-written 3mf's Metadata/project_settings.config) --------------------
140
+ // Upstream serializes every option as a STRING (or an array of them, one per extruder) — a bool is "0"/"1" and a
141
+ // float is "0.2". This settings map holds real JS types, and the difference is not cosmetic: `bool()` below reads
142
+ // a value with `!!v`, and `!!"0"` is TRUE — importing raw would turn every disabled option on. So each value is
143
+ // coerced by its schema type, and anything the schema does not know (inherits_group, *_settings_id, version, the
144
+ // whole preset-bookkeeping half of that file) is dropped rather than carried into a map keyed by schema keys.
145
+ // The `inherits` / `different_settings_to_system` machinery upstream applies on top of this (Preset.cpp:2577) is
146
+ // deliberately NOT reproduced: it exists to reconcile a stored preset against a LOCAL vendor preset database of a
147
+ // possibly different version. project_settings.config is already flattened, so taking its values as written is
148
+ // both simpler and closer to what the project author actually sliced.
149
+ const FALSE_STRINGS = new Set(['0', 'false', 'no', 'off', ''])
150
+
151
+ function coerceScalar(type, value) {
152
+ if (typeof value !== 'string') return value
153
+ switch (type) {
154
+ case 'coBool': case 'coBools':
155
+ return !FALSE_STRINGS.has(value.trim().toLowerCase())
156
+ case 'coFloat': case 'coFloats': case 'coInt': case 'coInts':
157
+ return asNumber(value)
158
+ case 'coPercent': case 'coPercents':
159
+ // A percent option's value is the bare number (the schema default of sparse_infill_density is 20, not "20%")
160
+ // and upstream serializes it that way — but the '%' does show up in hand-edited configs, so strip it.
161
+ return asNumber(value.trim().replace(/%$/, ''))
162
+ case 'coFloatOrPercent': case 'coFloatsOrPercents':
163
+ // "50%" must stay a string — the percent IS the value, and Number('50%') is NaN anyway.
164
+ return value.trim().endsWith('%') ? value : asNumber(value)
165
+ case 'coPoint': case 'coPoints':
166
+ return parsePoint(value) ?? value
167
+ case 'coPointsGroups':
168
+ return parsePointGroup(value) ?? value
169
+ default:
170
+ return value // strings and enums — the kernel and the panel both read these as written
171
+ }
172
+ }
173
+
174
+ // Points are the other shape trap, and a quiet one: upstream writes a point as the STRING "XxY", while every
175
+ // consumer here indexes it as a [x, y] pair — so `printable_area[1][0]` on a raw import is the CHARACTER '2' of
176
+ // "256x0", and the bed comes out 2mm wide (measured on a real MakerWorld project: 2 x NaN).
177
+ // Two separators exist in the wild: ConfigOptionPoint::serialize writes 'x', while some Orca options
178
+ // (best_object_pos) write "X,Y". A points GROUP is one string holding a comma-separated LIST of "XxY" points,
179
+ // so there the comma cannot also separate a point's own coordinates — hence two parsers rather than one regex.
180
+ function parsePoint(text) {
181
+ const split = String(text).split('x')
182
+ const pair = split.length === 2 ? split : String(text).split(',')
183
+ if (pair.length !== 2) return null
184
+ const x = Number(pair[0]), y = Number(pair[1])
185
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
186
+ }
187
+ function parsePointGroup(text) {
188
+ const points = String(text).split(',').map(entry => {
189
+ const [a, b] = entry.split('x')
190
+ const x = Number(a), y = Number(b)
191
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
192
+ })
193
+ return points.length && points.every(Boolean) ? points : null
194
+ }
195
+ // Number('') is 0 and Number(' ') is 0, neither of which means "the option was set to zero" — an unparsable
196
+ // value is handed back untouched so the schema default keeps applying instead of a fabricated 0.
197
+ function asNumber(value) {
198
+ if (value.trim() === '') return value
199
+ const n = Number(value)
200
+ return Number.isFinite(n) ? n : value
201
+ }
202
+
203
+ /**
204
+ * A 3mf project's raw `project_settings.config` object -> a settings map this package's own API accepts.
205
+ * Keys the config schema does not define are dropped. Returns `{settings, applied, skipped}`.
206
+ */
207
+ export function normalizeProjectSettings(raw) {
208
+ const settings = {}
209
+ let applied = 0
210
+ const skipped = []
211
+ for (const [key, value] of Object.entries(raw || {})) {
212
+ const type = schema[key]?.type
213
+ if (!type) { skipped.push(key); continue }
214
+ settings[key] = Array.isArray(value) ? value.map(entry => coerceScalar(type, entry)) : coerceScalar(type, value)
215
+ applied++
216
+ }
217
+ return { settings, applied, skipped }
218
+ }
219
+
91
220
 
92
221
  // Right-panel settings values -> kernel parameters (derived from schema keys)
93
222
  export function deriveKernelParams(settings) {
94
223
  const S = k => settingScalar(settings, k)
95
224
  const num = (k, d) => { const v = Number(S(k)); return Number.isFinite(v) ? v : d }
96
225
  const bool = (k, d) => { const v = settingRaw(settings, k); return typeof v === 'boolean' ? v : (Array.isArray(v) ? !!v[0] : (v == null ? d : !!v)) }
226
+ // Filament retraction overrides: upstream lets a material override the machine's retraction (TPU wants a
227
+ // different pull-back than PLA on the same printer). These schema keys are nullable and carry no default of
228
+ // their own — "unset" means keep the machine value — so this reads the settings map directly rather than
229
+ // through settingScalar, whose schema fallback would not distinguish the two.
230
+ const override = (filamentKey, machineKey, fallback) => {
231
+ const raw = settings?.[filamentKey]
232
+ const value = Number(Array.isArray(raw) ? raw[0] : raw)
233
+ return Number.isFinite(value) ? value : num(machineKey, fallback)
234
+ }
97
235
 
98
236
  let line_width = num('line_width', 0); if (!line_width) line_width = 0.42 // 0=auto -> default 0.42
99
237
 
@@ -128,6 +266,69 @@ export function deriveKernelParams(settings) {
128
266
  const machine = {}
129
267
  for (const [param, [key, fallback]] of Object.entries(MACHINE_LIMITS)) machine[param] = num(key, fallback)
130
268
 
269
+ // Support filament mapping: which extruder prints the support base/raft, and which prints the support interface.
270
+ // Upstream's "Default" is the value 0 = keep whatever tool is current, and an absent key means the same thing to
271
+ // the kernel (its JSON reader falls back to its own 0 default). Unmapped supports are therefore omitted rather
272
+ // than sent as 0, so a settings map that maps neither produces byte-for-byte the parameters it produced before
273
+ // these keys existed — same reason the per-feature widths above are omitted when unedited.
274
+ const supportTools = {}
275
+ for (const key of ['support_filament', 'support_interface_filament']) {
276
+ const extruderIndex = num(key, 0)
277
+ if (extruderIndex > 0) supportTools[key] = extruderIndex
278
+ }
279
+
280
+ // Multi-material: upstream stores every filament option as one entry per extruder (coFloats), and the material
281
+ // picker writes each extruder's material at its own index. Only a second entry makes these arrays appear, so a
282
+ // single-material slice sends exactly the keys it always did — the kernel then reads its scalars as before.
283
+ // A hole (a material that overrides nothing) is filled with the value tool 0 resolved to, because the kernel
284
+ // reads the array positionally and cannot tell "absent" from "0".
285
+ // Prime tower keys, omitted when unset so the kernel/viewer defaults stay in charge (same rule as supportTools).
286
+ // wipe_tower_x/y are coFloats upstream — one entry per plate — so the scalar reduction takes the first set one.
287
+ const towerSettings = {}
288
+ {
289
+ const width = num('prime_tower_width', 0)
290
+ if (width > 0) towerSettings.prime_tower_width = width
291
+ // Read the map itself, NOT settingRaw: wipe_tower_x/y carry an upstream schema default of (15, 220), which is
292
+ // a coordinate off the front of a 200mm bed. Taking it as "the user chose a position" put the tower outside
293
+ // the plate and disabled the automatic placement entirely — measured as a tower at z=-127.5 on a 200mm bed.
294
+ // Absent from the map means nobody chose one, and the placement beside the model stands.
295
+ const chosen = (key) => { const v = settings?.[key]; const n = Number(Array.isArray(v) ? v[0] : v)
296
+ return (v != null && v !== '' && Number.isFinite(n)) ? n : null }
297
+ const towerX = chosen('wipe_tower_x'), towerY = chosen('wipe_tower_y')
298
+ if (towerX != null) towerSettings.prime_tower_x = towerX
299
+ if (towerY != null) towerSettings.prime_tower_y = towerY
300
+ // The purging volumes table (flat N×N, mm³) and its scalars. Sent verbatim — the kernel indexes it [from*N+to].
301
+ const matrix = settingRaw(settings, 'flush_volumes_matrix')
302
+ if (Array.isArray(matrix) && matrix.length >= 4) towerSettings.flush_volumes_matrix = matrix.map(Number)
303
+ // Upstream defaults enable_prime_tower to false and relies on flushing into the model; the kernel defaults it
304
+ // to true because that was the only destination it had. Both are sent only when the map carries them, so an
305
+ // untouched project keeps whatever the kernel decides.
306
+ if ('enable_prime_tower' in (settings ?? {})) towerSettings.enable_prime_tower = !!settings.enable_prime_tower
307
+ if ('flush_into_infill' in (settings ?? {})) towerSettings.flush_into_infill = !!settings.flush_into_infill
308
+ const multiplier = num('flush_multiplier', 0)
309
+ if (multiplier > 0) towerSettings.flush_multiplier = multiplier
310
+ const prime = num('prime_volume', 0)
311
+ if (prime > 0) towerSettings.prime_volume = prime
312
+ }
313
+
314
+ const perExtruder = {}
315
+ for (const [param, key, scalar] of [
316
+ ['extruder_nozzle_temp', 'nozzle_temperature', num('nozzle_temperature', 200)],
317
+ ['extruder_filament_diameter', 'filament_diameter', num('filament_diameter', 1.75)],
318
+ ['extruder_flow_ratio', 'filament_flow_ratio', num('filament_flow_ratio', 1.0)],
319
+ ['extruder_retract_length', 'filament_retraction_length', override('filament_retraction_length', 'retraction_length', 0.8)],
320
+ ['extruder_retract_speed', 'filament_retraction_speed', override('filament_retraction_speed', 'retraction_speed', 30)],
321
+ ['extruder_z_hop', 'filament_z_hop', override('filament_z_hop', 'z_hop', 0.4)],
322
+ ]) {
323
+ const raw = settings?.[key]
324
+ if (!Array.isArray(raw) || raw.length < 2) continue
325
+ perExtruder[param] = raw.map(v => {
326
+ if (v == null || v === '') return scalar // null survives a JSON round-trip; Number(null) would be 0
327
+ const n = Number(Array.isArray(v) ? v[0] : v)
328
+ return Number.isFinite(n) ? n : scalar
329
+ })
330
+ }
331
+
131
332
  return {
132
333
  ...widths,
133
334
  ...machine,
@@ -146,11 +347,17 @@ export function deriveKernelParams(settings) {
146
347
  skirt_height: num('skirt_height', 1), // stage 33: the kernel used to hardcode the first layer
147
348
  brim_width: num('brim_width', 0),
148
349
  brim_object_gap: num('brim_object_gap', 0), // stage 33: the kernel used to hardcode w*0.5
149
- retract_length: num('retraction_length', 0.8), // vector[0]
150
- retraction_minimum_travel: num('retraction_minimum_travel', 2), // stage 33: used to be the kernel constant 2.0
350
+ ...perExtruder,
351
+ // Prime tower. These were the one part of multi-material the settings map could not reach: the kernel read its
352
+ // own prime_tower_* parameters and nothing mapped the upstream keys onto them, so the tower's size and place
353
+ // were whatever the viewer decided. Only sent when the user actually set them — an unset width keeps the
354
+ // kernel's own default, and an unset position lets the viewer's auto-placement stand.
355
+ ...towerSettings,
356
+ retract_length: override('filament_retraction_length', 'retraction_length', 0.8), // vector[0]
357
+ retraction_minimum_travel: override('filament_retraction_minimum_travel', 'retraction_minimum_travel', 2), // stage 33: used to be the kernel constant 2.0
151
358
  gcode_resolution: num('resolution', 0.01), // stage 33: tree-support path simplification tolerance
152
- retract_speed: num('retraction_speed', 30),
153
- z_hop: num('z_hop', 0.4),
359
+ retract_speed: override('filament_retraction_speed', 'retraction_speed', 30),
360
+ z_hop: override('filament_z_hop', 'z_hop', 0.4),
154
361
  travel_speed: num('travel_speed', 120),
155
362
  first_layer_speed: num('initial_layer_speed', 30),
156
363
  print_speed: num('outer_wall_speed', 60),
@@ -185,6 +392,7 @@ export function deriveKernelParams(settings) {
185
392
  const n = Number(v); return Number.isFinite(n) ? n : 0.5 })(), // "50%" -> 0.5 (ratio of extrusion width)
186
393
  support_on_build_plate_only: bool('support_on_build_plate_only', false),
187
394
  support_interface_bottom_layers: num('support_interface_bottom_layers', 0),
395
+ ...supportTools, // present only when a support extruder is mapped (see above)
188
396
  raft_layers: num('raft_layers', 0),
189
397
  raft_expansion: num('raft_expansion', 1.5), // stage 33: the kernel used to hardcode +3.0
190
398
  raft_contact_distance: num('raft_contact_distance', 0.1), // stage 33: previously ignored by the kernel
@@ -27,6 +27,85 @@ const loadCore = async () => {
27
27
 
28
28
  let modPromise = null
29
29
 
30
+ // Stage 20 painting states = upstream's EnforcerBlockerType (see packages/wasm-core/selector_bridge.h):
31
+ // 0=NONE, 1=ENFORCER(==Extruder1), 2=BLOCKER(==Extruder2), 3..16=Extruder3..Extruder16.
32
+ // The kernel exposes both a boolean pair (selector_paint/…_count/…_overlay) and integer-state twins (…_state);
33
+ // the boolean wrappers are NOT redundant, they are what maps false -> BLOCKER(2) on the C++ side.
34
+ const PAINT_STATE_ENFORCER = 1
35
+ const PAINT_STATE_BLOCKER = 2
36
+ const PAINT_STATE_MAX = 16
37
+
38
+ // A state is only a state when it is literally an integer in [1,16]. This predicate is the hazard guard: embind
39
+ // coerces a JS `false` to the int 0 == NONE, so a boolean that leaked into the state path would paint nothing and
40
+ // silently turn blocker painting into a no-op. `Number.isInteger(false)` is false, so a boolean can never pass here
41
+ // and never reaches the *_state entry points — it stays on the boolean binding that means BLOCKER.
42
+ const isPaintState = (value) => Number.isInteger(value) && value >= PAINT_STATE_ENFORCER && value <= PAINT_STATE_MAX
43
+
44
+ // Stands in for an overlay the request did not ask for. Shared and never transferred (the transfer list drops
45
+ // zero-length buffers), so the one instance stays valid for every reply that needs a placeholder.
46
+ const EMPTY_OVERLAY = new Float32Array(0)
47
+
48
+ // Throws (-> the catch below posts {type:'error'}) rather than clamping: a bad state is a caller bug, and silently
49
+ // substituting one would paint the wrong extruder, which is far harder to notice than an error banner.
50
+ const validatedPaintStates = (states) => {
51
+ for (const state of states)
52
+ if (!isPaintState(state)) throw new Error(`paint state must be an integer 1..${PAINT_STATE_MAX} (got ${JSON.stringify(state)})`)
53
+ return states
54
+ }
55
+
56
+ // Which states a message wants reported per-state. null == a legacy message (no `state`/`states` field) -> the reply
57
+ // must stay exactly {enf, blk} / {enf, blk} as it was before the protocol was widened, because the in-app support
58
+ // flow (viewer use_slicer.js) reads those two fields and nothing else.
59
+ const reportedPaintStates = (message, paintedState) => {
60
+ if (Array.isArray(message.states)) return validatedPaintStates(message.states)
61
+ return paintedState === null ? null : [paintedState]
62
+ }
63
+
64
+ // The state a paint message asks for: an explicit `state` wins, otherwise null meaning "use the legacy boolean path".
65
+ // Note the check is on presence-and-integerness, never on truthiness — see isPaintState above.
66
+ const explicitPaintState = (message) => {
67
+ if (message.state === undefined || message.state === null) return null
68
+ validatedPaintStates([message.state])
69
+ return message.state
70
+ }
71
+
72
+ // Brush cursor shape (selector_bridge.h CURSOR_*). Sent as a name because the message is a protocol a host writes
73
+ // by hand; the integer stays on this side of it. Anything unknown is the sphere the brush has always used.
74
+ const cursorShapeOf = (message) => (message.cursor === 'circle' ? 1 : 0)
75
+
76
+ // The tools that are not the radius brush: upstream's Smart fill and Bucket fill, plus its single-triangle POINTER
77
+ // cursor — which is literally bucket fill with propagation off, so the three share one dispatch. None of them takes
78
+ // a radius or a camera: what they select comes from the mesh's own topology, which is why they are a branch rather
79
+ // than more optional fields on the brush call. `state === null` means erase (the entry points with no state
80
+ // argument), matching how the brush splits paint from erase.
81
+ const FILL_TOOLS = new Set(['smart', 'bucket', 'triangle'])
82
+ const applyFill = (Module, message, state) => {
83
+ const angle = Number.isFinite(message.angle) ? message.angle : 30
84
+ if (message.tool === 'smart') {
85
+ if (state === null) Module.selector_seed_fill_erase(message.facet, message.hx, message.hy, message.hz, angle)
86
+ else Module.selector_seed_fill(message.facet, message.hx, message.hy, message.hz, angle, state)
87
+ return
88
+ }
89
+ // Upstream passes -1 as the angle when it is not propagating (GLGizmoPainterBase.cpp ~861): with a single facet
90
+ // there is no neighbour to measure an angle against, and -1 is how that call says "no angle limit".
91
+ const propagate = message.tool === 'bucket'
92
+ const bucketAngle = propagate ? angle : -1
93
+ if (state === null) Module.selector_bucket_fill_erase(message.facet, message.hx, message.hy, message.hz, bucketAngle, propagate)
94
+ else Module.selector_bucket_fill(message.facet, message.hx, message.hy, message.hz, bucketAngle, propagate, state)
95
+ }
96
+
97
+ // paint and erase both mutate the same selector, so both have to re-read every count the caller asked about: an
98
+ // erase over a T3 region lowers T3 exactly the way overpainting it with T2 would, and a reply carrying only the
99
+ // state that was written would leave the other chips showing stale numbers. `enf`/`blk` stay unconditional so a
100
+ // listener predating the per-state map keeps working; `counts` appears only when the message asked for states,
101
+ // which is what keeps a legacy paint reply byte-identical to what it was before the protocol was widened.
102
+ const paintedReply = (Module, message, paintedState) => {
103
+ const reply = { type: 'painted', enf: Module.selector_painted_count(true), blk: Module.selector_painted_count(false) }
104
+ const states = reportedPaintStates(message, paintedState)
105
+ if (states) reply.counts = Object.fromEntries(states.map(state => [state, Module.selector_painted_count_state(state)]))
106
+ return reply
107
+ }
108
+
30
109
  self.onmessage = async (e) => {
31
110
  const d = e.data
32
111
  try {
@@ -35,11 +114,81 @@ self.onmessage = async (e) => {
35
114
  // Warmup: only load the kernel (+ spawn the mt pthread pool) ahead of time — removes the perceived load on the first slice
36
115
  if (d.cmd === 'warmup') { self.postMessage({ type: 'warm' }); return }
37
116
  // Stage 20: manual support painting — selector state persists in this worker Module (slicing uses the same Module).
38
- if (d.cmd === 'prepare') { Module.selector_prepare(new Uint8Array(d.stl)); self.postMessage({ type: 'prepared', facets: Module.selector_facet_count() }); return }
39
- if (d.cmd === 'paint') { Module.selector_paint(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, d.enforcer);
40
- self.postMessage({ type: 'painted', enf: Module.selector_painted_count(true), blk: Module.selector_painted_count(false) }); return }
41
- if (d.cmd === 'clear') { Module.selector_clear(); self.postMessage({ type: 'painted', enf: 0, blk: 0 }); return }
42
- if (d.cmd === 'overlay') { self.postMessage({ type: 'overlay', enf: Array.from(Module.selector_overlay(true)), blk: Array.from(Module.selector_overlay(false)) }); return }
117
+ // `keepPaint` says the mesh is the same model in a new place, so the marks carry over — the reply reports whether
118
+ // they actually did, since a face-count change makes the kernel fall back to a clean registration.
119
+ if (d.cmd === 'prepare') {
120
+ const kept = d.keepPaint ? Module.selector_reprepare(new Uint8Array(d.stl)) : (Module.selector_prepare(new Uint8Array(d.stl)), false)
121
+ self.postMessage({ type: 'prepared', facets: Module.selector_facet_count(), kept })
122
+ return
123
+ }
124
+ // paint: legacy {enforcer:boolean} stays on the boolean binding (false == BLOCKER); {state:1..16} reaches any
125
+ // extruder through the state binding. `enf`/`blk` are always reported so an old listener keeps working, and the
126
+ // per-state `counts` map is added ONLY when the message asked for states (keeps the legacy reply byte-identical).
127
+ if (d.cmd === 'paint') {
128
+ const paintedState = explicitPaintState(d)
129
+ if (FILL_TOOLS.has(d.tool)) {
130
+ // The fills are newer than the boolean protocol, so there is no legacy reading to fall back to — a fill
131
+ // without an explicit integer state is a malformed message, not an enforcer stroke.
132
+ if (paintedState === null) throw new Error(`a '${d.tool}' fill needs an explicit integer state`)
133
+ applyFill(Module, d, paintedState)
134
+ }
135
+ 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)
136
+ else Module.selector_paint_shape(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius, paintedState, cursorShapeOf(d))
137
+ self.postMessage(paintedReply(Module, d, paintedState)); return
138
+ }
139
+ // erase: the brush writes NONE, returning the brushed facets to the default extruder (upstream's shift+drag).
140
+ // It is its OWN command and NOT `{cmd:'paint', state:0}`, because 0 is the one integer a boolean can become:
141
+ // embind coerces a JS `false` to 0 == NONE, so widening isPaintState to admit 0 would let a stray boolean on
142
+ // the state path erase silently — the exact failure that guard was written to stop (see isPaintState above).
143
+ // A boolean cannot forge the string 'erase', and the kernel binding it reaches takes no state argument at all,
144
+ // so there is nothing left for a coercion to slip through. The reply is a normal 'painted' one: an erase is a
145
+ // change to the same counts, and the viewer's listeners must not need to know which brush produced it.
146
+ if (d.cmd === 'erase') {
147
+ if (FILL_TOOLS.has(d.tool)) applyFill(Module, d, null)
148
+ else Module.selector_erase(d.facet, d.hx, d.hy, d.hz, d.cx, d.cy, d.cz, d.radius)
149
+ self.postMessage(paintedReply(Module, d, null)); return
150
+ }
151
+ // importPaint: load painting out of a 3mf. `facets` is an Int32Array of facet indices in the CURRENT selector's
152
+ // numbering and `hex` the newline-joined per-facet split-tree strings the 3mf carried, in the same order.
153
+ // It REPLACES every mark (the kernel's deserialize resets first), so it belongs immediately after a 'prepare'
154
+ // and nowhere else — an import in the middle of a session would silently discard what the user had painted.
155
+ // A kernel built before this binding existed simply has no selector_import_paint; the reply says applied:0
156
+ // rather than throwing, because losing a 3mf's paint must not also lose its geometry.
157
+ if (d.cmd === 'importPaint') {
158
+ const applied = Module.selector_import_paint ? Module.selector_import_paint(d.facets, d.hex ?? '') : 0
159
+ const reply = paintedReply(Module, d, null)
160
+ reply.applied = applied
161
+ self.postMessage(reply); return
162
+ }
163
+ // clear wipes every state at once, so each requested count is 0 by construction — no need to ask the kernel back.
164
+ if (d.cmd === 'clear') {
165
+ Module.selector_clear()
166
+ const reply = { type: 'painted', enf: 0, blk: 0 }
167
+ const states = reportedPaintStates(d, null)
168
+ if (states) reply.counts = Object.fromEntries(states.map(state => [state, 0]))
169
+ self.postMessage(reply); return
170
+ }
171
+ // overlay: `enf`/`blk` (states 1/2) are always sent because the viewer's overlay rebuild consumes exactly those two;
172
+ // `overlays` adds the requested states, so an MMU caller can ask for 3..16 without losing the support overlays.
173
+ // The kernel already hands back a detached Float32Array (bindings.cpp to_f32 does typed_memory_view().slice()),
174
+ // so the arrays are posted as they are and their buffers TRANSFERRED. The previous Array.from() turned each one
175
+ // into a plain JS Array, which the structured clone then had to copy element by element — measured at 1.1 ms of
176
+ // a 3.6 ms stroke sample on a 40k-triangle mesh, and growing with the painted area because the whole overlay is
177
+ // resent every sample. Transferring costs nothing and the arrays are this worker's own copies, so nothing here
178
+ // can observe them being neutered.
179
+ if (d.cmd === 'overlay') {
180
+ const states = reportedPaintStates(d, null)
181
+ // enf/blk stay unconditional for the listener that predates the per-state map. When the request names states,
182
+ // they are the ONLY ones read, so building the other two would be pure waste on the hot path.
183
+ const wanted = states && !states.includes(PAINT_STATE_ENFORCER) && !states.includes(PAINT_STATE_BLOCKER)
184
+ const reply = { type: 'overlay',
185
+ enf: wanted ? EMPTY_OVERLAY : Module.selector_overlay(true),
186
+ blk: wanted ? EMPTY_OVERLAY : Module.selector_overlay(false) }
187
+ if (states) reply.overlays = Object.fromEntries(states.map(state => [state, Module.selector_overlay_state(state)]))
188
+ const transfer = [reply.enf, reply.blk, ...Object.values(reply.overlays ?? {})]
189
+ .filter(a => a?.buffer && a.byteLength > 0).map(a => a.buffer)
190
+ self.postMessage(reply, transfer); return
191
+ }
43
192
 
44
193
  if (d.stall) return // stage-30 test hook: simulate a hang -> verifies the main-thread watchdog fires (not set in production)
45
194
  // mt (SAB): share the address of the real support progress counter (u32) with main once — even while the kernel is
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-slicer",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Browser/WASM 3D-printing slicer reverse-engineered from OrcaSlicer: slicing engine (Arachne walls, tree/grid supports, multi-material), React viewer with GPU toolpath preview, and schema-driven settings UI.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -41,6 +41,10 @@
41
41
  "types": "./types/viewer-loaders.d.ts",
42
42
  "default": "./viewer/dist/model_loaders.js"
43
43
  },
44
+ "./viewer/gcode": {
45
+ "types": "./types/viewer-gcode.d.ts",
46
+ "default": "./viewer/dist/gcode_parse.js"
47
+ },
44
48
  "./components": {
45
49
  "types": "./types/components.d.ts",
46
50
  "default": "./components/dist/SettingsPanel.js"
@@ -69,6 +73,7 @@
69
73
  },
70
74
  "files": [
71
75
  "engine",
76
+ "!engine/**/.omc",
72
77
  "types",
73
78
  "components/dist",
74
79
  "viewer/dist",
@@ -80,6 +85,7 @@
80
85
  "data/invalidation-map.json",
81
86
  "data/printers.json",
82
87
  "data/processes.js",
88
+ "data/filaments.js",
83
89
  "README.md",
84
90
  "LICENSE.txt"
85
91
  ],
@@ -97,8 +103,15 @@
97
103
  "wasm",
98
104
  "orcaslicer",
99
105
  "three",
106
+ "three.js",
100
107
  "react",
101
108
  "arachne",
102
- "tree-support"
109
+ "tree-support",
110
+ "three-slicer",
111
+ "slicer",
112
+ "3d",
113
+ "mesh",
114
+ "clipping"
103
115
  ]
104
116
  }
117
+