three-slicer-viewer 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ // Settings-map transforms that need no vendor catalog: schema-typed coercion, the 3mf project codecs,
2
+ // kernel/SLA parameter derivation, preset application, and the preset-file codecs re-exported at the end.
3
+ //
4
+ // Split out of settings.js so it can ship in the permissive viewer package (packages/RELICENSE.md): nothing
5
+ // in here reads upstream's authored text or the vendor catalogs, both of which stay with the AGPL package.
6
+ // settings.js re-exports all of this, so `three-slicer/settings` is unchanged for every consumer.
7
+
8
+ // Maps the actual values of the (editable) right-hand settings panel -> kernel slice parameters.
9
+ // - Settings state is a sparse map (key->value): only edited keys are stored, otherwise the config-schema default.
10
+ // - For vector types (coFloats/coInts, …) only the first element is used/edited (simplification).
11
+ // The REDUCED schema is enough here: this file reads .type, .default, enum_values and, for the SLA
12
+ // contract, defined_in/line — never a label or a tooltip. Aliased so the call sites read as before.
13
+ import { leanSchema as schema } from './data.js'
14
+
15
+ export function schemaDefault(key) { return schema[key]?.default }
16
+ export function settingRaw(settings, key) { return (settings && key in settings) ? settings[key] : schemaDefault(key) }
17
+ // Normalize to a scalar ([0] for vectors)
18
+ // A per-extruder column reduced to the single value the kernel's scalar reader wants. The FIRST SET entry, not
19
+ // literally index 0: the filament card writes one column per extruder and leaves a null wherever that extruder has
20
+ // no preset, so an assignment that starts at T2 puts a null at index 0. Taking that null made filament_diameter 0,
21
+ // which divides into the extrusion maths and turned the whole slice into NaN — measured as
22
+ // "; filament used [mm] = nan, nan" from a print whose T1 had no material picked.
23
+ export function settingScalar(settings, key) {
24
+ const v = settingRaw(settings, key)
25
+ if (!Array.isArray(v)) return v
26
+ return v.find(entry => entry != null && entry !== '') ?? v[0]
27
+ }
28
+
29
+ // Stage 8: real ported Fill patterns (gyroid TPMS/honeycomb/3dhoneycomb/crosshatch/concentric) + the older approximations
30
+ const KERNEL_PATTERNS = ['rectilinear', 'grid', 'triangles', 'zigzag', 'gyroid', 'gyroid_approx',
31
+ 'honeycomb', '3dhoneycomb', 'crosshatch', 'concentric']
32
+ const KERNEL_SEAMS = ['nearest', 'aligned', 'back', 'random']
33
+
34
+ // ---- Pass-through kernel parameters -----------------------------------------------------------------------
35
+ // The kernel reads 159 parameters (see the reference table in engine/PARAMS.md, generated from its own reader).
36
+ // These are the ones whose schema key carries the SAME name, so the mapping is the identity and the only real
37
+ // decision is WHEN to send them. They are sent only when the settings map actually holds the key — never filled
38
+ // in from the schema default — because the two defaults disagree for several of them:
39
+ // independent_support_layer_height is true in the schema and false in the kernel, printable_height is 100 there
40
+ // and 250 here. Filling one in would silently reslice every existing caller's model. This is the same omission
41
+ // rule the per-feature widths, the support tools and the prime tower keys already follow, and it is what keeps
42
+ // deriveKernelParams({}) producing exactly the parameter set it produced before these keys were mapped.
43
+ const PASSTHROUGH_NUM = [
44
+ // Real (organic) tree support shape. Upstream keeps an _organic spelling and a suffix-less one for most of
45
+ // these, and the kernel accepts both — preferring _organic — so both are passed as written rather than
46
+ // reconciled here. Before this, none of the organic tree support could be tuned from a settings map at all.
47
+ 'tree_support_branch_angle', 'tree_support_branch_angle_organic',
48
+ 'tree_support_branch_diameter', 'tree_support_branch_diameter_organic',
49
+ 'tree_support_branch_distance', 'tree_support_branch_distance_organic',
50
+ 'tree_support_branch_diameter_angle', 'tree_support_angle_slow', 'tree_support_tip_diameter',
51
+ 'tree_support_top_rate', 'tree_support_wall_count',
52
+ 'support_object_first_layer_gap',
53
+ // The tree support's build-volume ceiling. Distinct from bed_height above — that one is the kernel's own
54
+ // off-the-bed check — but both come from this single schema key, because upstream has one option for both jobs.
55
+ 'printable_height',
56
+ // Upstream's *_filament_id family: a 1-based filament index per feature, 0 meaning "whatever tool the region
57
+ // already prints with". slice_mm.cpp honours the wall and sparse-infill ids and reports the rest in the G-code
58
+ // rather than silently dropping them.
59
+ 'outer_wall_filament_id', 'inner_wall_filament_id', 'sparse_infill_filament_id',
60
+ 'top_surface_filament_id', 'bottom_surface_filament_id', 'internal_solid_filament_id',
61
+ ]
62
+ const PASSTHROUGH_BOOL = ['independent_support_layer_height']
63
+ // Per-filament vectors, one entry per filament, passed positionally like every other per-extruder array.
64
+ // filament_map is which physical extruder each filament sits in; density and cost turn extruded millimetres into
65
+ // the grams and currency the G-code footer reports.
66
+ const PASSTHROUGH_NUM_VECTOR = ['filament_map', 'filament_density', 'filament_cost']
67
+ // Material identity. The kernel needs the type for the two decisions upstream makes by material name (PETG's
68
+ // extra unretract, TPU on the first layer) and the settings id for the footer.
69
+ const PASSTHROUGH_STR_VECTOR = ['filament_type', 'filament_settings_id']
70
+
71
+ // Machine limits (the "Motion ability" printer page) -> kernel time-estimate parameters.
72
+ // Declarative on purpose: the kernel collapses X/Y into one axis, so the mapping cannot be a plain pass-through,
73
+ // but adding a limit stays a data row instead of code. The fallback is the kernel's own default, so an unedited
74
+ // profile produces exactly the previous estimate. Upstream leaves machine_max_*_x/y/z/e without a schema default
75
+ // (they only ever come from a printer preset), hence the explicit fallbacks here.
76
+ const MACHINE_LIMITS = {
77
+ machine_max_speed_xy: ['machine_max_speed_x', 500],
78
+ machine_max_speed_z: ['machine_max_speed_z', 12],
79
+ machine_max_speed_e: ['machine_max_speed_e', 30],
80
+ machine_max_accel_xy: ['machine_max_acceleration_x', 5000],
81
+ machine_max_accel_z: ['machine_max_acceleration_z', 500],
82
+ machine_max_accel_e: ['machine_max_acceleration_e', 5000],
83
+ machine_jerk_xy: ['machine_max_jerk_x', 9],
84
+ machine_jerk_z: ['machine_max_jerk_z', 0.4],
85
+ machine_jerk_e: ['machine_max_jerk_e', 2.5],
86
+ machine_accel_print: ['default_acceleration', 5000],
87
+ machine_accel_travel: ['travel_acceleration', 5000],
88
+ machine_accel_retract: ['machine_max_acceleration_retracting', 5000],
89
+ }
90
+ // The schema keys the machine limits are read from — for UI that has to show "which printer characteristics are in play"
91
+ // without hardcoding key strings of its own.
92
+ export const machineLimitKeys = Object.values(MACHINE_LIMITS).map(([key]) => key)
93
+
94
+ // ---- Printer profiles -------------------------------------------------------
95
+
96
+ // ---- Imported project settings (a slicer-written 3mf's Metadata/project_settings.config) --------------------
97
+ // Upstream serializes every option as a STRING (or an array of them, one per extruder) — a bool is "0"/"1" and a
98
+ // float is "0.2". This settings map holds real JS types, and the difference is not cosmetic: `bool()` below reads
99
+ // a value with `!!v`, and `!!"0"` is TRUE — importing raw would turn every disabled option on. So each value is
100
+ // coerced by its schema type, and anything the schema does not know (inherits_group, *_settings_id, version, the
101
+ // whole preset-bookkeeping half of that file) is dropped rather than carried into a map keyed by schema keys.
102
+ // The `inherits` / `different_settings_to_system` machinery upstream applies on top of this (Preset.cpp:2577) is
103
+ // deliberately NOT reproduced: it exists to reconcile a stored preset against a LOCAL vendor preset database of a
104
+ // possibly different version. project_settings.config is already flattened, so taking its values as written is
105
+ // both simpler and closer to what the project author actually sliced.
106
+ const FALSE_STRINGS = new Set(['0', 'false', 'no', 'off', ''])
107
+
108
+ function coerceScalar(type, value) {
109
+ if (typeof value !== 'string') return value
110
+ switch (type) {
111
+ case 'coBool': case 'coBools':
112
+ return !FALSE_STRINGS.has(value.trim().toLowerCase())
113
+ case 'coFloat': case 'coFloats': case 'coInt': case 'coInts':
114
+ return asNumber(value)
115
+ case 'coPercent': case 'coPercents':
116
+ // A percent option's value is the bare number (the schema default of sparse_infill_density is 20, not "20%")
117
+ // and upstream serializes it that way — but the '%' does show up in hand-edited configs, so strip it.
118
+ return asNumber(value.trim().replace(/%$/, ''))
119
+ case 'coFloatOrPercent': case 'coFloatsOrPercents':
120
+ // "50%" must stay a string — the percent IS the value, and Number('50%') is NaN anyway.
121
+ return value.trim().endsWith('%') ? value : asNumber(value)
122
+ case 'coPoint': case 'coPoints':
123
+ return parsePoint(value) ?? value
124
+ case 'coPointsGroups':
125
+ return parsePointGroup(value) ?? value
126
+ default:
127
+ return value // strings and enums — the kernel and the panel both read these as written
128
+ }
129
+ }
130
+
131
+ // Points are the other shape trap, and a quiet one: upstream writes a point as the STRING "XxY", while every
132
+ // consumer here indexes it as a [x, y] pair — so `printable_area[1][0]` on a raw import is the CHARACTER '2' of
133
+ // "256x0", and the bed comes out 2mm wide (measured on a real MakerWorld project: 2 x NaN).
134
+ // Two separators exist in the wild: ConfigOptionPoint::serialize writes 'x', while some Orca options
135
+ // (best_object_pos) write "X,Y". A points GROUP is one string holding a comma-separated LIST of "XxY" points,
136
+ // so there the comma cannot also separate a point's own coordinates — hence two parsers rather than one regex.
137
+ function parsePoint(text) {
138
+ const split = String(text).split('x')
139
+ const pair = split.length === 2 ? split : String(text).split(',')
140
+ if (pair.length !== 2) return null
141
+ const x = Number(pair[0]), y = Number(pair[1])
142
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
143
+ }
144
+ function parsePointGroup(text) {
145
+ const points = String(text).split(',').map(entry => {
146
+ const [a, b] = entry.split('x')
147
+ const x = Number(a), y = Number(b)
148
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
149
+ })
150
+ return points.length && points.every(Boolean) ? points : null
151
+ }
152
+ // Number('') is 0 and Number(' ') is 0, neither of which means "the option was set to zero" — an unparsable
153
+ // value is handed back untouched so the schema default keeps applying instead of a fabricated 0.
154
+ function asNumber(value) {
155
+ if (value.trim() === '') return value
156
+ const n = Number(value)
157
+ return Number.isFinite(n) ? n : value
158
+ }
159
+
160
+ /**
161
+ * A 3mf project's raw `project_settings.config` object -> a settings map this package's own API accepts.
162
+ * Keys the config schema does not define are dropped. Returns `{settings, applied, skipped}`.
163
+ */
164
+ export function normalizeProjectSettings(raw) {
165
+ const settings = {}
166
+ let applied = 0
167
+ const skipped = []
168
+ for (const [key, value] of Object.entries(raw || {})) {
169
+ const type = schema[key]?.type
170
+ if (!type) { skipped.push(key); continue }
171
+ settings[key] = Array.isArray(value) ? value.map(entry => coerceScalar(type, entry)) : coerceScalar(type, value)
172
+ applied++
173
+ }
174
+ return { settings, applied, skipped }
175
+ }
176
+
177
+ // The exact inverse, for WRITING a project_settings.config: every value back to the string shape upstream
178
+ // serializes (bool -> "1"/"0", point -> "XxY", a points group -> "X1xY1,X2xY2"), because that is what every other
179
+ // slicer's reader — and normalizeProjectSettings above — expects to coerce FROM. Writing the JS types raw would
180
+ // hand OrcaSlicer a bool it reads with its own string parser and this package a round-trip that only works here.
181
+ // Keys the schema does not define are dropped for the same reason normalize drops them: they could not be typed.
182
+ const pointToText = (point) => (Array.isArray(point) ? `${point[0]}x${point[1]}` : String(point))
183
+
184
+ function serializeScalar(type, value) {
185
+ if (value == null) return ''
186
+ switch (type) {
187
+ case 'coBool': case 'coBools':
188
+ return value ? '1' : '0'
189
+ case 'coPoint': case 'coPoints':
190
+ return pointToText(value)
191
+ case 'coPointsGroups':
192
+ return Array.isArray(value) ? value.map(pointToText).join(',') : String(value)
193
+ default:
194
+ return String(value) // numbers, percents, strings, enums — String() is upstream's own spelling for all of them
195
+ }
196
+ }
197
+
198
+ /** A settings map (real JS types) -> the all-strings object a 3mf's `project_settings.config` stores. */
199
+ export function serializeProjectSettings(settings) {
200
+ const out = {}
201
+ for (const [key, value] of Object.entries(settings || {})) {
202
+ const type = schema[key]?.type
203
+ if (!type) continue
204
+ // A coPoint's value IS an array ([x, y]) — the generic array path would serialize its two numbers separately.
205
+ out[key] = (type !== 'coPoint' && Array.isArray(value))
206
+ ? value.map(entry => serializeScalar(type, entry))
207
+ : serializeScalar(type, value)
208
+ }
209
+ return out
210
+ }
211
+
212
+
213
+ // Right-panel settings values -> kernel parameters (derived from schema keys)
214
+ // opts.plate: which plate's entry to take from per-plate array options (wipe_tower_x/y — upstream coFloats,
215
+ // one entry per plate). Defaults to 0, so every existing caller reads exactly what it always did.
216
+ export function deriveKernelParams(settings, opts) {
217
+ const plate = opts?.plate ?? 0
218
+ const S = k => settingScalar(settings, k)
219
+ const num = (k, d) => { const v = Number(S(k)); return Number.isFinite(v) ? v : d }
220
+ const bool = (k, d) => { const v = settingRaw(settings, k); return typeof v === 'boolean' ? v : (Array.isArray(v) ? !!v[0] : (v == null ? d : !!v)) }
221
+ // Filament retraction overrides: upstream lets a material override the machine's retraction (TPU wants a
222
+ // different pull-back than PLA on the same printer). These schema keys are nullable and carry no default of
223
+ // their own — "unset" means keep the machine value — so this reads the settings map directly rather than
224
+ // through settingScalar, whose schema fallback would not distinguish the two.
225
+ const override = (filamentKey, machineKey, fallback) => {
226
+ const raw = settings?.[filamentKey]
227
+ const value = Number(Array.isArray(raw) ? raw[0] : raw)
228
+ return Number.isFinite(value) ? value : num(machineKey, fallback)
229
+ }
230
+
231
+ let line_width = num('line_width', 0); if (!line_width) line_width = 0.42 // 0=auto -> default 0.42
232
+
233
+ let pat = String(S('sparse_infill_pattern') ?? 'rectilinear')
234
+ if (!KERNEL_PATTERNS.includes(pat)) pat = 'rectilinear' // pattern unsupported by the kernel -> rectilinear
235
+
236
+ let seam = String(S('seam_position') ?? 'back')
237
+ if (seam === 'aligned_back') seam = 'back'
238
+ if (!KERNEL_SEAMS.includes(seam)) seam = 'back'
239
+
240
+ // Support style: schema enum (default/grid/snug/organic/tree_slim/tree_strong/tree_hybrid)
241
+ // -> kernel grid|tree|tree_lite. Since stage 18 the tree/organic family maps to the real organic TreeSupport ('tree',
242
+ // generate_tree_support_3D -> real type5 branch toolpaths); everything else maps to grid.
243
+ // support_type participates too, as upstream's UI does: a tree(auto)/tree(manual) TYPE whose style is
244
+ // still 'default' is a tree request — before this, an imported Orca project saved with tree(auto) and
245
+ // the default style silently sliced grid. An explicit non-tree style keeps winning over the type.
246
+ const styleRaw = String(S('support_style') ?? 'default')
247
+ const typeRaw = String(S('support_type') ?? 'normal(auto)')
248
+ const support_style =
249
+ /tree|organic/i.test(styleRaw) || (/tree/i.test(typeRaw) && styleRaw === 'default') ? 'tree' : 'grid'
250
+
251
+ // Bed: bounding box of the first rectangle in printable_area
252
+ const pa = settingRaw(settings, 'printable_area')
253
+ let bed_width = 256, bed_depth = 256
254
+ if (Array.isArray(pa) && pa.length) {
255
+ const xs = pa.map(p => p[0]), ys = pa.map(p => p[1])
256
+ bed_width = Math.max(...xs) - Math.min(...xs); bed_depth = Math.max(...ys) - Math.min(...ys)
257
+ }
258
+
259
+ // Stage 21: per-feature widths — passed to the kernel verbatim (including strings like "120%"). Unedited keys are omitted -> the kernel derives auto(=line_width).
260
+ // The 0=auto semantics are handled by the kernel's resolve_lw (upstream Flow formula). Defaults unchanged (no feature set -> line_width).
261
+ const widths = {}
262
+ for (const k of ['outer_wall_line_width','inner_wall_line_width','top_surface_line_width',
263
+ 'sparse_infill_line_width','internal_solid_infill_line_width','initial_layer_line_width']) {
264
+ const v = S(k); if (v != null && v !== '') widths[k] = v
265
+ }
266
+ const machine = {}
267
+ for (const [param, [key, fallback]] of Object.entries(MACHINE_LIMITS)) machine[param] = num(key, fallback)
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 — indexed by opts.plate. A hole (null entry) means
287
+ // nobody chose a position for THAT plate, so the automatic placement stands there; a scalar applies to every
288
+ // plate (the pre-array form, kept readable so old saved settings keep meaning what they meant).
289
+ const towerSettings = {}
290
+ {
291
+ const width = num('prime_tower_width', 0)
292
+ if (width > 0) towerSettings.prime_tower_width = width
293
+ // Read the map itself, NOT settingRaw: wipe_tower_x/y carry an upstream schema default of (15, 220), which is
294
+ // a coordinate off the front of a 200mm bed. Taking it as "the user chose a position" put the tower outside
295
+ // the plate and disabled the automatic placement entirely — measured as a tower at z=-127.5 on a 200mm bed.
296
+ // Absent from the map means nobody chose one, and the placement beside the model stands.
297
+ const chosen = (key) => { const raw = settings?.[key]
298
+ const v = Array.isArray(raw) ? raw[plate] : raw
299
+ const n = Number(v)
300
+ return (v != null && v !== '' && Number.isFinite(n)) ? n : null }
301
+ const towerX = chosen('wipe_tower_x'), towerY = chosen('wipe_tower_y')
302
+ if (towerX != null) towerSettings.prime_tower_x = towerX
303
+ if (towerY != null) towerSettings.prime_tower_y = towerY
304
+ // The purging volumes table (flat N×N, mm³) and its scalars. Sent verbatim — the kernel indexes it [from*N+to].
305
+ const matrix = settingRaw(settings, 'flush_volumes_matrix')
306
+ if (Array.isArray(matrix) && matrix.length >= 4) towerSettings.flush_volumes_matrix = matrix.map(Number)
307
+ // Upstream defaults enable_prime_tower to false and relies on flushing into the model; the kernel defaults it
308
+ // to true because that was the only destination it had. Both are sent only when the map carries them, so an
309
+ // untouched project keeps whatever the kernel decides.
310
+ if ('enable_prime_tower' in (settings ?? {})) towerSettings.enable_prime_tower = !!settings.enable_prime_tower
311
+ if ('flush_into_infill' in (settings ?? {})) towerSettings.flush_into_infill = !!settings.flush_into_infill
312
+ const multiplier = num('flush_multiplier', 0)
313
+ if (multiplier > 0) towerSettings.flush_multiplier = multiplier
314
+ const prime = num('prime_volume', 0)
315
+ if (prime > 0) towerSettings.prime_volume = prime
316
+ }
317
+
318
+ // Pass-through parameters (see PASSTHROUGH_* above). `present` reads the settings MAP, not settingRaw, because
319
+ // settingRaw's schema fallback is exactly what must not happen here — an unedited key has to stay absent.
320
+ const passthrough = {}
321
+ {
322
+ const present = (key) => settings != null && key in settings && settings[key] != null && settings[key] !== ''
323
+ for (const key of PASSTHROUGH_NUM) {
324
+ if (!present(key)) continue
325
+ const value = Number(S(key))
326
+ if (Number.isFinite(value)) passthrough[key] = value
327
+ }
328
+ for (const key of PASSTHROUGH_BOOL) if (present(key)) passthrough[key] = bool(key, false)
329
+ for (const key of PASSTHROUGH_NUM_VECTOR) {
330
+ if (!present(key)) continue
331
+ const raw = settings[key]
332
+ passthrough[key] = (Array.isArray(raw) ? raw : [raw]).map(entry => { const n = Number(entry); return Number.isFinite(n) ? n : 0 })
333
+ }
334
+ for (const key of PASSTHROUGH_STR_VECTOR) {
335
+ if (!present(key)) continue
336
+ const raw = settings[key]
337
+ passthrough[key] = (Array.isArray(raw) ? raw : [raw]).map(entry => String(entry ?? ''))
338
+ }
339
+ // support_line_width is coFloatOrPercent, but the kernel reads it with the plain number reader rather than the
340
+ // percent-aware jwidth_raw the per-feature widths get. A "120%" would reach strtod as a quoted string and
341
+ // parse to 0 == auto — the setting silently ignored. Resolved here against the nozzle, which is the same
342
+ // thing jwidth_raw does for the widths that do get the percent-aware reader.
343
+ if (present('support_line_width')) {
344
+ const text = String(settingScalar(settings, 'support_line_width')).trim()
345
+ const width = text.endsWith('%') ? (parseFloat(text) / 100) * num('nozzle_diameter', 0.4) : Number(text)
346
+ if (Number.isFinite(width) && width > 0) passthrough.support_line_width = width
347
+ }
348
+ }
349
+
350
+ const perExtruder = {}
351
+ for (const [param, key, scalar] of [
352
+ ['extruder_nozzle_temp', 'nozzle_temperature', num('nozzle_temperature', 200)],
353
+ ['extruder_filament_diameter', 'filament_diameter', num('filament_diameter', 1.75)],
354
+ ['extruder_flow_ratio', 'filament_flow_ratio', num('filament_flow_ratio', 1.0)],
355
+ ['extruder_retract_length', 'filament_retraction_length', override('filament_retraction_length', 'retraction_length', 0.8)],
356
+ ['extruder_retract_speed', 'filament_retraction_speed', override('filament_retraction_speed', 'retraction_speed', 30)],
357
+ ['extruder_z_hop', 'filament_z_hop', override('filament_z_hop', 'z_hop', 0.4)],
358
+ ]) {
359
+ const raw = settings?.[key]
360
+ if (!Array.isArray(raw) || raw.length < 2) continue
361
+ perExtruder[param] = raw.map(v => {
362
+ if (v == null || v === '') return scalar // null survives a JSON round-trip; Number(null) would be 0
363
+ const n = Number(Array.isArray(v) ? v[0] : v)
364
+ return Number.isFinite(n) ? n : scalar
365
+ })
366
+ }
367
+
368
+ return {
369
+ ...widths,
370
+ ...machine,
371
+ ...passthrough, // present only for keys the settings map actually holds (see PASSTHROUGH_* above)
372
+ layer_height: num('layer_height', 0.2),
373
+ first_layer_height: num('initial_layer_print_height', 0.2),
374
+ line_width,
375
+ wall_loops: num('wall_loops', 2),
376
+ infill_density: num('sparse_infill_density', 20) / 100, // % → 0~1
377
+ sparse_infill_pattern: pat,
378
+ infill_angle: num('infill_direction', 45),
379
+ top_shell_layers: num('top_shell_layers', 4),
380
+ bottom_shell_layers: num('bottom_shell_layers', 3),
381
+ seam_position: seam,
382
+ skirt_loops: num('skirt_loops', 1),
383
+ skirt_distance: num('skirt_distance', 2),
384
+ skirt_height: num('skirt_height', 1), // stage 33: the kernel used to hardcode the first layer
385
+ brim_width: num('brim_width', 0),
386
+ brim_object_gap: num('brim_object_gap', 0), // stage 33: the kernel used to hardcode w*0.5
387
+ ...perExtruder,
388
+ // Prime tower. These were the one part of multi-material the settings map could not reach: the kernel read its
389
+ // own prime_tower_* parameters and nothing mapped the upstream keys onto them, so the tower's size and place
390
+ // were whatever the viewer decided. Only sent when the user actually set them — an unset width keeps the
391
+ // kernel's own default, and an unset position lets the viewer's auto-placement stand.
392
+ ...towerSettings,
393
+ retract_length: override('filament_retraction_length', 'retraction_length', 0.8), // vector[0]
394
+ retraction_minimum_travel: override('filament_retraction_minimum_travel', 'retraction_minimum_travel', 2), // stage 33: used to be the kernel constant 2.0
395
+ gcode_resolution: num('resolution', 0.01), // stage 33: tree-support path simplification tolerance
396
+ retract_speed: override('filament_retraction_speed', 'retraction_speed', 30),
397
+ z_hop: override('filament_z_hop', 'z_hop', 0.4),
398
+ travel_speed: num('travel_speed', 120),
399
+ first_layer_speed: num('initial_layer_speed', 30),
400
+ print_speed: num('outer_wall_speed', 60),
401
+ nozzle_diameter: num('nozzle_diameter', 0.4),
402
+ filament_diameter: num('filament_diameter', 1.75),
403
+ flow_ratio: num('filament_flow_ratio', 1.0),
404
+ nozzle_temp: num('nozzle_temperature', 200),
405
+ bed_temp: num('hot_plate_temp', 45), // no bed_temperature key -> hot_plate_temp
406
+ bed_width, bed_depth,
407
+ bed_height: num('printable_height', 0), // 0 = profile states no ceiling -> kernel skips the check
408
+ // Explicitly-set only: the schema default here is upstream's generic "G28 / G1 Z5" preamble, and falling back
409
+ // to it would rewrite the emitted G-code for every caller. The kernel keeps its own preamble when these are empty.
410
+ machine_start_gcode: String(settings?.machine_start_gcode ?? ''),
411
+ machine_end_gcode: String(settings?.machine_end_gcode ?? ''),
412
+ enable_support: bool('enable_support', false),
413
+ support_threshold_angle: num('support_threshold_angle', 30),
414
+ support_top_z_distance: num('support_top_z_distance', 0.2),
415
+ support_bottom_z_distance: num('support_bottom_z_distance', 0.2), // stage 32: support bottom z-gap (default 0.2 = equivalent to current behavior)
416
+ support_xy_distance: num('support_object_xy_distance', 0.35),
417
+ support_interface_top_layers: num('support_interface_top_layers', 2),
418
+ // Stage 33: support keys added when the kernel hardcoding was removed (upstream schema key names kept)
419
+ support_angle: num('support_angle', 0),
420
+ support_base_pattern: String(S('support_base_pattern') ?? 'default'),
421
+ support_interface_pattern: String(S('support_interface_pattern') ?? 'auto'),
422
+ support_interface_spacing: num('support_interface_spacing', 0.5),
423
+ support_base_pattern_spacing: num('support_base_pattern_spacing', 2.5),
424
+ support_remove_small_overhang: bool('support_remove_small_overhang', true),
425
+ bridge_no_support: bool('bridge_no_support', false),
426
+ support_expansion: num('support_expansion', 0),
427
+ support_threshold_overlap: (() => { const v = settingRaw(settings, 'support_threshold_overlap')
428
+ if (typeof v === 'string' && v.trim().endsWith('%')) return parseFloat(v) / 100
429
+ const n = Number(v); return Number.isFinite(n) ? n : 0.5 })(), // "50%" -> 0.5 (ratio of extrusion width)
430
+ support_on_build_plate_only: bool('support_on_build_plate_only', false),
431
+ support_interface_bottom_layers: num('support_interface_bottom_layers', 0),
432
+ ...supportTools, // present only when a support extruder is mapped (see above)
433
+ raft_layers: num('raft_layers', 0),
434
+ raft_expansion: num('raft_expansion', 1.5), // stage 33: the kernel used to hardcode +3.0
435
+ raft_contact_distance: num('raft_contact_distance', 0.1), // stage 33: previously ignored by the kernel
436
+ fan_speed: num('fan_max_speed', 100), // no fan_speed key -> fan_max_speed
437
+ close_fan_the_first_x_layers: num('close_fan_the_first_x_layers', 1),
438
+ full_fan_speed_layer: num('full_fan_speed_layer', 0),
439
+ slow_down_layer_time: num('slow_down_layer_time', 5),
440
+ enable_arc_fitting: bool('enable_arc_fitting', false),
441
+ spiral_mode: bool('spiral_mode', false),
442
+ // New in stage 5 (derived from the matching schema keys)
443
+ seam_slope_type: String(S('seam_slope_type') ?? 'none'), // none|external|all -> scarf seam
444
+ enable_pressure_advance: bool('enable_pressure_advance', false),
445
+ pressure_advance: num('pressure_advance', 0.02),
446
+ support_style, // grid|tree_lite (mapped above)
447
+ bridge_speed: num('bridge_speed', 25),
448
+ // New in stage 6 (derived from the matching schema keys). The MM parameters (extruder_count/mm_group_split) are
449
+ // injected at onSlice time from the viewer's per-object extruder assignment (not included here).
450
+ ironing_type: String(S('ironing_type') ?? 'no ironing'), // no ironing|top|topmost|solid (the top family = on)
451
+ ironing_spacing: num('ironing_spacing', 0.1),
452
+ ironing_flow: num('ironing_flow', 10),
453
+ ironing_speed: num('ironing_speed', 20),
454
+ reduce_crossing_wall: bool('reduce_crossing_wall', false),
455
+ max_volumetric_extrusion_rate_slope: num('max_volumetric_extrusion_rate_slope', 0),
456
+ // Stage 7: wall generator classic|arachne (arachne = the real ported OrcaSlicer WallToolPaths, variable width)
457
+ wall_generator: (String(S('wall_generator') ?? 'classic') === 'arachne') ? 'arachne' : 'classic',
458
+ // support_density has no matching schema key, so the kernel default (0.15) is used; scarf_length likewise uses the kernel default (10mm)
459
+ }
460
+ }
461
+
462
+ // ---- Printer technology (FFF vs SLA routing) ----------------------------------------------------------------
463
+ // The technology is a property of the printer profile, exactly as upstream models it: one settings map, two
464
+ // disjoint key families, and the profile's technology key decides which family the UI shows and which slicing
465
+ // path runs. The schema already carries the key (inherited from the upstream lineage), so an imported preset
466
+ // that names a resin printer routes itself.
467
+ export function printerTechnology(settings) {
468
+ return String(settingRaw(settings, 'printer_technology') ?? 'FFF').toUpperCase() === 'SLA' ? 'SLA' : 'FFF'
469
+ }
470
+
471
+ // SLA settings provenance table. Rows come from the schema's `defined_in` marker so a new resin option cannot
472
+ // silently leave the UI/bridge without a documented omission/default decision. Explicit rows cover upstream
473
+ // expression defaults the extractor cannot evaluate (support_tree_type and branching's bridge limits).
474
+ const SLA_SETTING_OVERRIDES = {
475
+ layer_height: { omission: 'resin-default', default: 0.05, provenance: 'shared key; FFF schema default is not used' },
476
+ support_tree_type: { default: 'default', enum: ['default', 'branching', 'organic'], provenance: 'upstream enum expression' },
477
+ branchingsupport_pillar_connection_mode: { default: 'dynamic', enum: ['zigzag', 'cross', 'dynamic'], provenance: 'upstream enum expression' },
478
+ sla_archive_format: { default: 'SL1', enum: ['SL1'], provenance: 'Original Prusa SL1 archive' },
479
+ }
480
+
481
+ const SLA_SCHEMA_ROWS = Object.fromEntries(Object.entries(schema)
482
+ .filter(([key, meta]) => key === 'printer_technology' || String(meta.defined_in ?? '').includes('sla_'))
483
+ .map(([key, meta]) => [key, {
484
+ source: 'config-schema.json',
485
+ omission: 'schema-default',
486
+ default: meta.default,
487
+ ...(meta.enum_values ? { enum: [...meta.enum_values] } : {}),
488
+ provenance: `${meta.defined_in}:${meta.line}`,
489
+ }]))
490
+
491
+ export const SLA_SETTING_CONTRACT = Object.freeze(Object.fromEntries(
492
+ Object.entries({ ...SLA_SCHEMA_ROWS, ...SLA_SETTING_OVERRIDES }).map(([key, row]) => [key, Object.freeze({
493
+ ...row,
494
+ ...(SLA_SETTING_OVERRIDES[key] ?? {}),
495
+ })]),
496
+ ))
497
+ export const slaSettingContract = SLA_SETTING_CONTRACT
498
+ export const slaSettingKeys = Object.freeze(Object.keys(SLA_SETTING_CONTRACT))
499
+
500
+ // Right-panel settings values -> SLA slice parameters (sla_core.js) + the raster/export geometry the viewer's
501
+ // SL1 writer needs. A separate function, NOT a branch inside deriveKernelParams: that one feeds the WASM kernel
502
+ // and its empty-map key count is a pinned invariant — this one feeds the JS contour slicer and owes bytes to
503
+ // nobody yet. Keys the schema does not define (the resin display's pixel grid, the per-layer height override)
504
+ // are read from the map directly, so they follow the same omission-friendly rule: absent means the default.
505
+ export function deriveSlaParams(settings) {
506
+ const S = k => settingScalar(settings, k)
507
+ const num = (k, d) => { const v = Number(S(k)); return Number.isFinite(v) && v > 0 ? v : d }
508
+
509
+ // Read from the MAP, not through the schema fallback: the key is shared with FFF and its schema default is that
510
+ // technology's opinion (0.2mm) — four resin layers thick. Absent means the resin default here.
511
+ const rawLayerHeight = settings?.layer_height
512
+ const mapLayerHeight = Number(Array.isArray(rawLayerHeight) ? rawLayerHeight[0] : rawLayerHeight)
513
+ const layer_height = Number.isFinite(mapLayerHeight) && mapLayerHeight > 0 ? mapLayerHeight : 0.05
514
+ // Zero is a legal fade count (no fade band), so this one cannot go through the positives-only reader.
515
+ const fadedRaw = Number(S('faded_layers'))
516
+ const requestedSupportTree = String(S('support_tree_type') ?? 'default').toLowerCase()
517
+ const support_tree_type = ['default', 'branching', 'organic'].includes(requestedSupportTree)
518
+ ? requestedSupportTree
519
+ : 'default'
520
+ return {
521
+ layer_height,
522
+ initial_layer_height: num('initial_layer_height', layer_height),
523
+ exposure_time: num('exposure_time', 7),
524
+ initial_exposure_time: num('initial_exposure_time', 35),
525
+ faded_layers: Number.isFinite(fadedRaw) && fadedRaw >= 0 ? fadedRaw : 10,
526
+ // The physical display and its pixel grid — what turns millimetres into raster pixels at export time.
527
+ // Fallbacks are the SL1's own panel, the reference machine of the format this exports to.
528
+ display_width: num('display_width', 120.96),
529
+ display_height: num('display_height', 68.04),
530
+ display_pixels_x: num('display_pixels_x', 2560),
531
+ display_pixels_y: num('display_pixels_y', 1440),
532
+ // Panel mounting: the whole SL1 family is portrait with X mirrored (projection through the vat floor);
533
+ // the raster writer swaps the image axes for portrait exactly like upstream SL1.cpp create_raster.
534
+ display_orientation: String(S('display_orientation') ?? 'portrait') === 'landscape' ? 'landscape' : 'portrait',
535
+ display_mirror_x: settingRaw(settings, 'display_mirror_x') === undefined ? true : !!settingRaw(settings, 'display_mirror_x'),
536
+ display_mirror_y: !!settingRaw(settings, 'display_mirror_y'),
537
+ // SL1 archive identity fields (config.ini): written as-is, empty when the host never set a named
538
+ // profile — the same thing upstream produces then. material_print_speed defaults to 'fast', the value
539
+ // every Prusa vendor SL1 material carries (-> expUserProfile 0 in the archive).
540
+ material_print_speed: String(S('material_print_speed') ?? 'fast'),
541
+ printer_model: String(S('printer_model') ?? ''),
542
+ printer_variant: String(S('printer_variant') ?? ''),
543
+ printer_settings_id: String(S('printer_settings_id') ?? ''),
544
+ sla_print_settings_id: String(S('sla_print_settings_id') ?? ''),
545
+ sla_material_settings_id: String(S('sla_material_settings_id') ?? ''),
546
+ // The kernel's over-bed check and centring read bed dimensions; for a resin printer the printable area IS
547
+ // the display, so the display doubles as the bed the shared parser sees.
548
+ bed_width: num('display_width', 120.96),
549
+ bed_depth: num('display_height', 68.04),
550
+ // Support generation (kernel slice_sla): the grid-pillar generator's shape parameters, all schema keys the
551
+ // SLA extraction pass brought in — schema defaults rule, an edit wins.
552
+ supports_enable: !!settingRaw(settings, 'supports_enable'),
553
+ pad_enable: !!settingRaw(settings, 'pad_enable'),
554
+ // Passed through so a requested hollow is REFUSED (typed kernel error) instead of silently sliced solid —
555
+ // the OpenVDB chain hollowing needs is not ported, and a solid print labeled hollowed is the worst outcome.
556
+ hollowing_enable: !!settingRaw(settings, 'hollowing_enable'),
557
+ support_buildplate_only: !!settingRaw(settings, 'support_buildplate_only'),
558
+ support_pillar_diameter: num('support_pillar_diameter', 1.0),
559
+ support_head_front_diameter: num('support_head_front_diameter', 0.4),
560
+ support_head_width: num('support_head_width', 1.0),
561
+ support_head_penetration: num('support_head_penetration', 0.2),
562
+ support_points_density_relative: num('support_points_density_relative', 100),
563
+ support_critical_angle: num('support_critical_angle', 45),
564
+ support_tree_type,
565
+ // The tree shape (upstream SupportTreeConfig): bridge reach, bracing distance, the base foot cone, the
566
+ // pillar connection style, and how far the object is lifted onto its supports. Elevation may legally be
567
+ // ZERO (print directly on the plate), so it cannot go through the positives-only reader.
568
+ support_max_bridge_length: num('support_max_bridge_length', 15),
569
+ support_max_pillar_link_distance: num('support_max_pillar_link_distance', 10),
570
+ support_base_diameter: num('support_base_diameter', 4),
571
+ support_base_height: num('support_base_height', 1),
572
+ // The rest of upstream make_support_cfg (SLAPrint.cpp:50) — small-pillar percent arrives as "50%" so the
573
+ // percent sign is stripped here; widening factor and base safety distance are legally ZERO (zero safety
574
+ // falls back to the kernel's own safety_distance), so neither can go through the positives-only reader.
575
+ support_small_pillar_diameter_percent:
576
+ (() => { const v = Number(String(S('support_small_pillar_diameter_percent') ?? '').replace('%', '')); return Number.isFinite(v) && v > 0 ? v : 50 })(),
577
+ support_pillar_widening_factor:
578
+ (() => { const v = Number(S('support_pillar_widening_factor')); return Number.isFinite(v) && v >= 0 ? v : 0 })(),
579
+ support_base_safety_distance:
580
+ (() => { const v = Number(S('support_base_safety_distance')); return Number.isFinite(v) && v >= 0 ? v : 1 })(),
581
+ support_max_bridges_on_pillar: num('support_max_bridges_on_pillar', 3),
582
+ support_max_weight_on_model: num('support_max_weight_on_model', 10),
583
+ // The generator's input slices are gap-closed with this radius, exactly like upstream slice_mesh_ex.
584
+ slice_closing_radius: num('slice_closing_radius', 0.049),
585
+ support_object_elevation: (() => { const v = Number(S('support_object_elevation')); return Number.isFinite(v) && v >= 0 ? v : 5 })(),
586
+ support_pillar_connection_mode: String(S('support_pillar_connection_mode') ?? 'dynamic'),
587
+ // The pad (upstream make_pad_cfg): wall height is legally ZERO (flat pad, no cavity), so it cannot go
588
+ // through the positives-only reader.
589
+ pad_brim_size: num('pad_brim_size', 1.6),
590
+ pad_wall_thickness: num('pad_wall_thickness', 2),
591
+ pad_wall_height:
592
+ (() => { const v = Number(S('pad_wall_height')); return Number.isFinite(v) && v >= 0 ? v : 0 })(),
593
+ pad_wall_slope: num('pad_wall_slope', 90),
594
+ pad_max_merge_distance: num('pad_max_merge_distance', 50),
595
+ pad_around_object: !!settingRaw(settings, 'pad_around_object'),
596
+ // Embed mode (upstream PadConfig::EmbedObject): the ring's gap and the connector sticks that keep the
597
+ // object attached to the pad. Read even when embed is off — the kernel ignores them then.
598
+ pad_around_object_everywhere: !!settingRaw(settings, 'pad_around_object_everywhere'),
599
+ pad_object_gap: num('pad_object_gap', 1),
600
+ pad_object_connector_width: num('pad_object_connector_width', 0.5),
601
+ pad_object_connector_stride: num('pad_object_connector_stride', 10),
602
+ pad_object_connector_penetration: num('pad_object_connector_penetration', 0.3),
603
+ }
604
+ }
605
+
606
+ // ---- Preset application -------------------------------------------------------------------------------------
607
+ // The clear-then-merge every preset consumer needs and each demo used to rewrite: a preset carries only the
608
+ // keys it sets, so merging one over another leaves the previous pick's leftovers underneath (an ABS chamber
609
+ // temperature under a PLA pick). `keys` is the catalog's own key set (filaments.keys, processApi.keys,
610
+ // printerKeys) — cleared first, so applying a material never disturbs a process pick and vice versa.
611
+ // A null/undefined preset (a failed settingsFor lookup) applies NOTHING — the previous pick stays whole,
612
+ // because half-clearing on a bad name would be worse than doing nothing.
613
+ export function applyPreset(settings, preset, keys) {
614
+ if (!preset) return settings ?? {}
615
+ const next = { ...(settings ?? {}) }
616
+ for (const key of keys ?? []) delete next[key]
617
+ return Object.assign(next, preset)
618
+ }
619
+
620
+ // ---- Ignored-key reporting ----------------------------------------------------------------------------------
621
+ // The FFF derivation consumes a curated key set (generated by probing — types/gen_kernel_params.mjs). Every
622
+ // other key in a settings map is accepted and silently does nothing to a slice, which is exactly how a
623
+ // support_type request once sliced grid with no warning. This is the inspection surface for that class:
624
+ // which of MY map's keys does deriveKernelParams actually read?
625
+ // Scope: the FFF kernel derivation ONLY. A key ignored here can still be real elsewhere — the SLA derivation
626
+ // (slaSettingKeys), the viewer (enable_prime_tower, printable_area), or project metadata — so treat the
627
+ // result as "has no effect on an FFF slice", not "useless".
628
+ import { KERNEL_SETTING_KEYS } from './kernel_setting_keys.js'
629
+ export const kernelSettingKeys = Object.freeze([...KERNEL_SETTING_KEYS])
630
+ const KERNEL_SETTING_KEY_SET = new Set(KERNEL_SETTING_KEYS)
631
+ export function ignoredKernelSettings(settings) {
632
+ return Object.keys(settings ?? {}).filter(key => !KERNEL_SETTING_KEY_SET.has(key)).sort()
633
+ }
634
+
635
+ // ---- Preset files (the .json OrcaSlicer imports/exports) ----------------------------------------------------
636
+ // Re-exported here rather than given their own subpath: they are settings-map transforms like the 3mf project
637
+ // codecs above, and a consumer that has one hand on `settings` should not need a second import path for them.
638
+ export { writePresetFile, readPresetFile, presetFileText, presetOptionKeys } from './preset_file.js'