three-slicer-viewer 0.3.3 → 0.3.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.
@@ -14,7 +14,10 @@ import { leanSchema as schema } from './data.js'
14
14
  import { FFF_FALLBACKS, AUTO_LINE_WIDTH, SLA_FALLBACKS, MACHINE_LIMITS, BED_FALLBACK } from './kernel_defaults.js'
15
15
 
16
16
  export function schemaDefault(key) { return schema[key]?.default }
17
- export function settingRaw(settings, key) { return (settings && key in settings) ? settings[key] : schemaDefault(key) }
17
+ export function settingRaw(settings, key) {
18
+ if (settings && key in settings) return settings[key]
19
+ return schemaDefault(key)
20
+ }
18
21
  // Normalize to a scalar ([0] for vectors)
19
22
  // A per-extruder column reduced to the single value the kernel's scalar reader wants. The FIRST SET entry, not
20
23
  // literally index 0: the filament card writes one column per extruder and leaves a null wherever that extruder has
@@ -66,8 +69,12 @@ const PASSTHROUGH_NUM = [
66
69
  'scarf_joint_flow_ratio', 'outer_wall_flow_ratio', 'inner_wall_flow_ratio', 'sparse_infill_flow_ratio',
67
70
  'internal_solid_infill_flow_ratio', 'gap_fill_flow_ratio', 'support_flow_ratio', 'support_interface_flow_ratio',
68
71
  'first_layer_flow_ratio',
72
+ // Added to every Z the G-code writes (upstream's GCodeWriter).
73
+ 'z_offset',
69
74
  ]
70
75
  const PASSTHROUGH_BOOL = ['independent_support_layer_height',
76
+ // The machine's E mode and firmware retraction (upstream GCodeWriter), each a printer profile option.
77
+ 'use_relative_e_distances', 'use_firmware_retraction',
71
78
  'detect_thin_wall', 'precise_outer_wall', 'only_one_wall_first_layer', 'alternate_extra_wall',
72
79
  // thick_bridges picks upstream's round bridge thread; set_other_flow_ratios turns the per-role ratios on.
73
80
  'thick_bridges', 'set_other_flow_ratios']
@@ -82,6 +89,28 @@ const PASSTHROUGH_STR = ['wall_sequence', 'wall_direction']
82
89
  // kernel reads its first entry, the wall filament's on the single-material path. filament_max_volumetric_speed caps
83
90
  // every extrusion's speed as upstream does (GCode.cpp:7492).
84
91
  const PASSTHROUGH_NUM_VECTOR = ['filament_map', 'filament_density', 'filament_cost', 'gap_infill_speed', 'filament_max_volumetric_speed']
92
+ // Per-role speed, acceleration and jerk, upstream's GCode::_extrude and travel_to (GCode.cpp:7292-7342, :7393-7473,
93
+ // :8289-8333). Sent under the upstream names, only when the map holds the key (or the map carries a custom G-code,
94
+ // see followsTemplate). A percent option is resolved against the option upstream takes it as a ratio over
95
+ // (PrintConfig ratio_over), the second entry. The kernel keeps print_speed for every role it gets no speed for, and
96
+ // writes no acceleration or jerk at all without default_acceleration / default_jerk.
97
+ const MOTION_KEYS = [
98
+ ['inner_wall_speed'], ['sparse_infill_speed'], ['internal_solid_infill_speed'], ['top_surface_speed'],
99
+ ['support_speed'], ['support_interface_speed'], ['initial_layer_infill_speed'], ['skirt_speed'], ['slow_down_layers'],
100
+ ['small_perimeter_speed', 'outer_wall_speed'], ['small_perimeter_threshold'],
101
+ ['initial_layer_travel_speed', 'travel_speed'],
102
+ ['default_acceleration'], ['outer_wall_acceleration'], ['inner_wall_acceleration'], ['top_surface_acceleration'],
103
+ ['sparse_infill_acceleration', 'default_acceleration'], ['internal_solid_infill_acceleration', 'default_acceleration'],
104
+ ['bridge_acceleration', 'outer_wall_acceleration'], ['initial_layer_acceleration'], ['travel_acceleration'],
105
+ ['initial_layer_travel_acceleration', 'travel_acceleration'],
106
+ ['default_jerk'], ['outer_wall_jerk'], ['inner_wall_jerk'], ['top_surface_jerk'], ['infill_jerk'], ['initial_layer_jerk'],
107
+ ['travel_jerk'], ['initial_layer_travel_jerk', 'travel_jerk'],
108
+ // The writer's clamps (GCodeWriter::apply_print_config) and Klipper's ACCEL_TO_DECEL.
109
+ ['machine_max_acceleration_extruding'], ['machine_max_acceleration_travel'], ['machine_max_acceleration_y'], ['machine_max_jerk_y'],
110
+ ['accel_to_decel_factor'],
111
+ ]
112
+ const MOTION_BOOLS = ['accel_to_decel_enable']
113
+
85
114
  // Material identity. The kernel needs the type for the two decisions upstream makes by material name (PETG's
86
115
  // extra unretract, TPU on the first layer) and the settings id for the footer.
87
116
  const PASSTHROUGH_STR_VECTOR = ['filament_type', 'filament_settings_id']
@@ -117,7 +146,8 @@ function coerceScalar(type, value) {
117
146
  return asNumber(value.trim().replace(/%$/, ''))
118
147
  case 'coFloatOrPercent': case 'coFloatsOrPercents':
119
148
  // "50%" must stay a string — the percent IS the value, and Number('50%') is NaN anyway.
120
- return value.trim().endsWith('%') ? value : asNumber(value)
149
+ if (value.trim().endsWith('%')) return value
150
+ return asNumber(value)
121
151
  case 'coPoint': case 'coPoints':
122
152
  return parsePoint(value) ?? value
123
153
  case 'coPointsGroups':
@@ -135,25 +165,34 @@ function coerceScalar(type, value) {
135
165
  // so there the comma cannot also separate a point's own coordinates — hence two parsers rather than one regex.
136
166
  function parsePoint(text) {
137
167
  const split = String(text).split('x')
138
- const pair = split.length === 2 ? split : String(text).split(',')
168
+ let pair = split
169
+ if (split.length !== 2) pair = String(text).split(',')
139
170
  if (pair.length !== 2) return null
140
171
  const x = Number(pair[0]), y = Number(pair[1])
141
- return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
172
+ if (Number.isFinite(x) && Number.isFinite(y)) return [x, y]
173
+ return null
142
174
  }
143
175
  function parsePointGroup(text) {
144
176
  const points = String(text).split(',').map(entry => {
145
177
  const [a, b] = entry.split('x')
146
178
  const x = Number(a), y = Number(b)
147
- return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
179
+ if (Number.isFinite(x) && Number.isFinite(y)) return [x, y]
180
+ return null
148
181
  })
149
- return points.length && points.every(Boolean) ? points : null
182
+ if (points.length && points.every(Boolean)) return points
183
+ return null
150
184
  }
151
185
  // Number('') is 0 and Number(' ') is 0, neither of which means "the option was set to zero" — an unparsable
152
186
  // value is handed back untouched so the schema default keeps applying instead of a fabricated 0.
153
187
  function asNumber(value) {
154
188
  if (value.trim() === '') return value
155
- const n = Number(value)
156
- return Number.isFinite(n) ? n : value
189
+ return finiteOr(Number(value), value)
190
+ }
191
+ // The number when it is finite, otherwise the fallback. The fallback is evaluated by the caller, so it is only
192
+ // used for values that are already at hand.
193
+ function finiteOr(number, fallback) {
194
+ if (Number.isFinite(number)) return number
195
+ return fallback
157
196
  }
158
197
 
159
198
  /**
@@ -167,7 +206,8 @@ export function normalizeProjectSettings(raw) {
167
206
  for (const [key, value] of Object.entries(raw || {})) {
168
207
  const type = schema[key]?.type
169
208
  if (!type) { skipped.push(key); continue }
170
- settings[key] = Array.isArray(value) ? value.map(entry => coerceScalar(type, entry)) : coerceScalar(type, value)
209
+ if (Array.isArray(value)) settings[key] = value.map(entry => coerceScalar(type, entry))
210
+ else settings[key] = coerceScalar(type, value)
171
211
  applied++
172
212
  }
173
213
  return { settings, applied, skipped }
@@ -178,17 +218,22 @@ export function normalizeProjectSettings(raw) {
178
218
  // slicer's reader — and normalizeProjectSettings above — expects to coerce FROM. Writing the JS types raw would
179
219
  // hand OrcaSlicer a bool it reads with its own string parser and this package a round-trip that only works here.
180
220
  // Keys the schema does not define are dropped for the same reason normalize drops them: they could not be typed.
181
- const pointToText = (point) => (Array.isArray(point) ? `${point[0]}x${point[1]}` : String(point))
221
+ const pointToText = (point) => {
222
+ if (Array.isArray(point)) return `${point[0]}x${point[1]}`
223
+ return String(point)
224
+ }
182
225
 
183
226
  function serializeScalar(type, value) {
184
227
  if (value == null) return ''
185
228
  switch (type) {
186
229
  case 'coBool': case 'coBools':
187
- return value ? '1' : '0'
230
+ if (value) return '1'
231
+ return '0'
188
232
  case 'coPoint': case 'coPoints':
189
233
  return pointToText(value)
190
234
  case 'coPointsGroups':
191
- return Array.isArray(value) ? value.map(pointToText).join(',') : String(value)
235
+ if (Array.isArray(value)) return value.map(pointToText).join(',')
236
+ return String(value)
192
237
  default:
193
238
  return String(value) // numbers, percents, strings, enums — String() is upstream's own spelling for all of them
194
239
  }
@@ -201,9 +246,8 @@ export function serializeProjectSettings(settings) {
201
246
  const type = schema[key]?.type
202
247
  if (!type) continue
203
248
  // A coPoint's value IS an array ([x, y]) — the generic array path would serialize its two numbers separately.
204
- out[key] = (type !== 'coPoint' && Array.isArray(value))
205
- ? value.map(entry => serializeScalar(type, entry))
206
- : serializeScalar(type, value)
249
+ if (type !== 'coPoint' && Array.isArray(value)) out[key] = value.map(entry => serializeScalar(type, entry))
250
+ else out[key] = serializeScalar(type, value)
207
251
  }
208
252
  return out
209
253
  }
@@ -215,33 +259,111 @@ export function serializeProjectSettings(settings) {
215
259
  // project_settings.config stores (serializeProjectSettings), as one JSON string: the kernel's flat key search
216
260
  // never looks inside a string value, so no key in it can be taken for a kernel parameter the map omits.
217
261
  // The kernel names the same templates in custom_gcode.cpp; test_custom_gcode.mjs checks each key here is expanded there.
218
- export const CUSTOM_GCODE_KEYS = ['machine_start_gcode', 'machine_end_gcode', 'filament_end_gcode']
262
+ export const CUSTOM_GCODE_KEYS = ['machine_start_gcode', 'machine_end_gcode', 'filament_end_gcode', 'filament_start_gcode',
263
+ 'before_layer_change_gcode', 'layer_change_gcode', 'time_lapse_gcode', 'file_start_gcode', 'change_filament_gcode']
219
264
 
220
- function placeholderConfig(settings, plate) {
265
+ // Whether the map carries a printer's custom G-code — the switch between the kernel's own raw path and upstream's
266
+ // full semantics, in which every option exists with its schema default (custom_gcode_bridge reads them that way).
267
+ function hasCustomGcode(settings) {
221
268
  const hasText = key => [settings?.[key]].flat().some(value => String(value ?? '').trim() !== '')
222
- if (!CUSTOM_GCODE_KEYS.some(hasText)) return {}
269
+ return CUSTOM_GCODE_KEYS.some(hasText)
270
+ }
271
+
272
+ // What a printer profile asks of a tool change that the template path does not do yet. The slice is not refused —
273
+ // that would stop every multi-material print — but each key whose value would change the output is named
274
+ // (PROFILE_PARTIAL_SUPPORT), so the setting is not ignored silently. A key the map omits reads as upstream's schema
275
+ // default, as the template path does (custom_gcode_bridge).
276
+ // ponytail: tool changes only, the gaps listed in custom_gcode.h. Overhang speeds (no ExtrusionQualityEstimator) are
277
+ // on in every profile, so a notice for them would show on every slice; add them when the notice has a better home.
278
+ export const PARTIAL_SUPPORT_CODE = 'PROFILE_PARTIAL_SUPPORT'
279
+ const TOOL_CHANGE_GAPS = [
280
+ { key: 'retract_length_toolchange', isSet: values => values.some(value => parseFloat(value) > 0),
281
+ effect: 'the retraction before a tool change uses retraction_length' },
282
+ { key: 'ooze_prevention', isSet: values => values.some(value => value === true || value === 1 || value === '1' || value === 'true'),
283
+ effect: 'idle tools are not parked at a standby temperature' },
284
+ ]
285
+ export function partialSupport(settings, { toolCount = 1 } = {}) {
286
+ if (!hasCustomGcode(settings) || toolCount < 2) return []
287
+ const gaps = []
288
+ for (const gap of TOOL_CHANGE_GAPS) {
289
+ let value = settings?.[gap.key]
290
+ if (value == null || value === '') value = schemaDefault(gap.key)
291
+ if (gap.isSet([value].flat())) gaps.push({ code: PARTIAL_SUPPORT_CODE, key: gap.key, effect: gap.effect })
292
+ }
293
+ return gaps
294
+ }
295
+
296
+ function placeholderConfig(settings, plate, objectNames) {
297
+ if (!hasCustomGcode(settings)) return {}
223
298
  // upstream's Print::get_plate_number_formatted: the 1-based plate, zero-padded to two digits
224
299
  const plateNumber = String(plate + 1).padStart(2, '0')
225
- return { placeholder_config: JSON.stringify({ ...serializeProjectSettings(settings), $plate_number: plateNumber }) }
300
+ // $object_names: the printable objects in merge order, from which the kernel derives upstream's object
301
+ // placeholders (input_filename_base, first_object_name, num_objects — PrintBase::update_object_placeholders).
302
+ const objects = {}
303
+ if (objectNames?.length) objects.$object_names = objectNames.map(name => String(name ?? ''))
304
+ // The plate type the kernel's own temperatures follow (deriveKernelParams), so the template's bed temperature and
305
+ // the second layer's agree about what the bed was heated to.
306
+ const bedType = { curr_bed_type: settings?.curr_bed_type ?? DEFAULT_BED_TYPE }
307
+ return { placeholder_config: JSON.stringify({ ...serializeProjectSettings({ ...settings, ...bedType }), $plate_number: plateNumber, ...objects }) }
308
+ }
309
+
310
+ // The bed temperature option of each plate type, upstream's get_bed_temp_key (PrintConfig.hpp:573). The first-layer
311
+ // option is the same key with `_initial_layer` (get_bed_temp_1st_layer_key).
312
+ // The plate type a printer gets when it names none: upstream's Plater writes Preset::get_default_bed_type to
313
+ // curr_bed_type on every printer change, and that falls back to btPEI (Preset.cpp:993). The schema's own default
314
+ // ("Cool Plate") is never what a sliced printer runs with. hot_plate_temp is also the option this kernel always read.
315
+ export const DEFAULT_BED_TYPE = 'High Temp Plate'
316
+ const BED_TEMP_KEYS = {
317
+ 'Supertack Plate': 'supertack_plate_temp', 'Cool Plate': 'cool_plate_temp', 'Textured Cool Plate': 'textured_cool_plate_temp',
318
+ 'Engineering Plate': 'eng_plate_temp', 'High Temp Plate': 'hot_plate_temp', 'Textured PEI Plate': 'textured_plate_temp',
319
+ }
320
+
321
+ // A per-extruder column's first entry; a scalar is its own first entry.
322
+ function firstEntryOf(value) {
323
+ if (Array.isArray(value)) return value[0]
324
+ return value
325
+ }
326
+ // A per-extruder column as a list; a scalar becomes a one-entry list.
327
+ function asList(value) {
328
+ if (Array.isArray(value)) return value
329
+ return [value]
330
+ }
331
+ // The value when it is one of the accepted ones, otherwise the fallback.
332
+ function allowedOr(value, accepted, fallback) {
333
+ if (accepted.includes(value)) return value
334
+ return fallback
226
335
  }
227
336
 
228
337
  // Right-panel settings values -> kernel parameters (derived from schema keys)
229
338
  // opts.plate: which plate's entry to take from per-plate array options (wipe_tower_x/y — upstream coFloats,
230
339
  // one entry per plate). Defaults to 0, so every existing caller reads exactly what it always did.
340
+ // opts.objectNames: the merged objects' names, for the custom G-code's object placeholders (see placeholderConfig).
231
341
  export function deriveKernelParams(settings, opts) {
232
342
  const plate = opts?.plate ?? 0
343
+ // With a printer's custom G-code the kernel expands it against every option at its default (custom_gcode_bridge),
344
+ // so the machine options it reads itself take their defaults too — otherwise the two halves of one G-code would
345
+ // disagree. Without one, the omission rule: only keys the map holds are sent.
346
+ const followsTemplate = hasCustomGcode(settings)
347
+ const presentOrTemplate = (key) => followsTemplate || (settings != null && key in settings && settings[key] != null && settings[key] !== '')
233
348
  const S = k => settingScalar(settings, k)
234
349
  const str = k => String(S(k) ?? FFF_FALLBACKS[k])
235
- const num = (k, d = FFF_FALLBACKS[k]) => { const v = Number(S(k)); return Number.isFinite(v) ? v : d }
236
- const bool = (k, d = FFF_FALLBACKS[k]) => { const v = settingRaw(settings, k); return typeof v === 'boolean' ? v : (Array.isArray(v) ? !!v[0] : (v == null ? d : !!v)) }
350
+ const num = (k, d = FFF_FALLBACKS[k]) => finiteOr(Number(S(k)), d)
351
+ const bool = (k, d = FFF_FALLBACKS[k]) => {
352
+ const v = settingRaw(settings, k)
353
+ if (typeof v === 'boolean') return v
354
+ if (Array.isArray(v)) return !!v[0]
355
+ if (v == null) return d
356
+ return !!v
357
+ }
237
358
  // Filament retraction overrides: upstream lets a material override the machine's retraction (TPU wants a
238
359
  // different pull-back than PLA on the same printer). These schema keys are nullable and carry no default of
239
360
  // their own — "unset" means keep the machine value — so this reads the settings map directly rather than
240
361
  // through settingScalar, whose schema fallback would not distinguish the two.
241
362
  const override = (filamentKey, machineKey, fallback = FFF_FALLBACKS[machineKey]) => {
242
363
  const raw = settings?.[filamentKey]
243
- const value = Number(Array.isArray(raw) ? raw[0] : raw)
244
- return Number.isFinite(value) ? value : num(machineKey, fallback)
364
+ const value = Number(firstEntryOf(raw))
365
+ if (Number.isFinite(value)) return value
366
+ return num(machineKey, fallback)
245
367
  }
246
368
 
247
369
  let line_width = num('line_width'); if (!line_width) line_width = AUTO_LINE_WIDTH // 0 = auto
@@ -269,16 +391,21 @@ export function deriveKernelParams(settings, opts) {
269
391
  // style never reached the kernel and every tree style sliced organic.
270
392
  const tree_style = { tree_slim: 'slim', tree_strong: 'strong', tree_hybrid: 'hybrid' }[styleRaw]
271
393
 
272
- // Bed: bounding box of the first rectangle in printable_area
394
+ // Bed: bounding box of the first rectangle in printable_area. Its lower-left corner is sent only when it is not
395
+ // (0,0) — a delta bed centred on the origin, or an offset cartesian one — so every bed that starts at the corner
396
+ // keeps exactly the parameters it had (see bedCenter below for what the kernel does with it).
273
397
  const pa = settingRaw(settings, 'printable_area')
274
398
  let bed_width = BED_FALLBACK.width, bed_depth = BED_FALLBACK.depth
399
+ const bedOriginParams = {} // read back through bedOrigin/bedCenter (bed_frame.js)
275
400
  if (Array.isArray(pa) && pa.length) {
276
401
  const xs = pa.map(p => p[0]), ys = pa.map(p => p[1])
277
402
  bed_width = Math.max(...xs) - Math.min(...xs); bed_depth = Math.max(...ys) - Math.min(...ys)
403
+ if (Math.min(...xs) !== 0) bedOriginParams.bed_origin_x = Math.min(...xs)
404
+ if (Math.min(...ys) !== 0) bedOriginParams.bed_origin_y = Math.min(...ys)
278
405
  }
279
406
 
280
407
  // Stage 21: per-feature widths — passed to the kernel verbatim (including strings like "120%"). Unedited keys are omitted -> the kernel derives auto(=line_width).
281
- // The 0=auto semantics are handled by the kernel's resolve_lw (upstream Flow formula). Defaults unchanged (no feature set -> line_width).
408
+ // The 0=auto semantics are handled by the kernel's resolve_line_width (upstream Flow formula). Defaults unchanged (no feature set -> line_width).
282
409
  const widths = {}
283
410
  for (const k of ['outer_wall_line_width','inner_wall_line_width','top_surface_line_width',
284
411
  'sparse_infill_line_width','internal_solid_infill_line_width','initial_layer_line_width']) {
@@ -316,9 +443,11 @@ export function deriveKernelParams(settings, opts) {
316
443
  // the plate and disabled the automatic placement entirely — measured as a tower at z=-127.5 on a 200mm bed.
317
444
  // Absent from the map means nobody chose one, and the placement beside the model stands.
318
445
  const chosen = (key) => { const raw = settings?.[key]
319
- const v = Array.isArray(raw) ? raw[plate] : raw
446
+ let v = raw
447
+ if (Array.isArray(raw)) v = raw[plate]
320
448
  const n = Number(v)
321
- return (v != null && v !== '' && Number.isFinite(n)) ? n : null }
449
+ if (v != null && v !== '' && Number.isFinite(n)) return n
450
+ return null }
322
451
  const towerX = chosen('wipe_tower_x'), towerY = chosen('wipe_tower_y')
323
452
  if (towerX != null) towerSettings.prime_tower_x = towerX
324
453
  if (towerY != null) towerSettings.prime_tower_y = towerY
@@ -356,31 +485,80 @@ export function deriveKernelParams(settings, opts) {
356
485
  for (const key of PASSTHROUGH_NUM_VECTOR) {
357
486
  if (!present(key)) continue
358
487
  const raw = settings[key]
359
- passthrough[key] = (Array.isArray(raw) ? raw : [raw]).map(entry => { const n = Number(entry); return Number.isFinite(n) ? n : 0 })
488
+ passthrough[key] = asList(raw).map(entry => finiteOr(Number(entry), 0))
360
489
  }
361
490
  for (const key of PASSTHROUGH_STR_VECTOR) {
362
491
  if (!present(key)) continue
363
492
  const raw = settings[key]
364
- passthrough[key] = (Array.isArray(raw) ? raw : [raw]).map(entry => String(entry ?? ''))
493
+ passthrough[key] = asList(raw).map(entry => String(entry ?? ''))
365
494
  }
366
495
  // support_line_width is coFloatOrPercent, but the kernel reads it with the plain number reader rather than the
367
- // percent-aware jwidth_raw the per-feature widths get. A "120%" would reach strtod as a quoted string and
496
+ // percent-aware json_width_raw the per-feature widths get. A "120%" would reach strtod as a quoted string and
368
497
  // parse to 0 == auto — the setting silently ignored. Resolved here against the nozzle, which is the same
369
- // thing jwidth_raw does for the widths that do get the percent-aware reader.
498
+ // thing json_width_raw does for the widths that do get the percent-aware reader.
370
499
  if (present('support_line_width')) {
371
500
  const text = String(settingScalar(settings, 'support_line_width')).trim()
372
- const width = text.endsWith('%') ? (parseFloat(text) / 100) * num('nozzle_diameter') : Number(text)
501
+ let width
502
+ if (text.endsWith('%')) width = (parseFloat(text) / 100) * num('nozzle_diameter')
503
+ else width = Number(text)
373
504
  if (Number.isFinite(width) && width > 0) passthrough.support_line_width = width
374
505
  }
375
506
  }
376
507
 
508
+ const motion = {}
509
+ for (const [key, ratioOver] of MOTION_KEYS) {
510
+ if (!presentOrTemplate(key)) continue
511
+ const raw = settingScalar(settings, key)
512
+ let value = Number(raw)
513
+ // A percent of another option (ratio_over) resolves against it; a plain coPercent (accel_to_decel_factor "50%")
514
+ // is the number itself.
515
+ if (typeof raw === 'string' && raw.trim().endsWith('%')) {
516
+ value = parseFloat(raw)
517
+ if (ratioOver) value = value / 100 * num(ratioOver, 0)
518
+ }
519
+ if (Number.isFinite(value)) motion[key] = value
520
+ }
521
+ for (const key of MOTION_BOOLS) if (presentOrTemplate(key)) motion[key] = bool(key, false)
522
+
523
+ // emit_machine_limits_to_gcode: the machine's limits written into the G-code (upstream print_machine_envelope),
524
+ // as the 16 numbers machine_writer.h reads in this order. Sent only when the profile turns it on.
525
+ const envelope = {}
526
+ if (presentOrTemplate('emit_machine_limits_to_gcode') && bool('emit_machine_limits_to_gcode', false)) {
527
+ envelope.machine_envelope = ['machine_max_acceleration_x', 'machine_max_acceleration_y', 'machine_max_acceleration_z',
528
+ 'machine_max_acceleration_e', 'machine_max_speed_x', 'machine_max_speed_y', 'machine_max_speed_z', 'machine_max_speed_e',
529
+ 'machine_max_acceleration_extruding', 'machine_max_acceleration_retracting', 'machine_max_acceleration_travel',
530
+ 'machine_max_jerk_x', 'machine_max_jerk_y', 'machine_max_jerk_z', 'machine_max_jerk_e', 'machine_max_junction_deviation',
531
+ ].map(key => num(key, 0))
532
+ }
533
+
377
534
  // Sent only for the three non-organic styles (see tree_style above), so an empty map still derives the same keys.
378
535
  const treeStyle = {}
379
536
  if (tree_style) treeStyle.tree_style = tree_style
380
537
 
538
+ // Temperatures. The bed's option follows the plate type upstream does (get_bed_temp_key). The first layer's own
539
+ // temperatures (nozzle_temperature_initial_layer, <plate>_temp_initial_layer) start the print and the kernel
540
+ // switches at the second layer, as upstream's process_layer does (GCode.cpp:5631). With a printer's custom
541
+ // G-code the start block's temperatures come from custom_gcode_bridge, which reads every option with its default,
542
+ // so the kernel takes the first-layer ones the same way or the two would disagree about what the print started
543
+ // at. Without one, only keys the map holds are sent. A map without curr_bed_type is on DEFAULT_BED_TYPE.
544
+ const bedTempKey = BED_TEMP_KEYS[settings?.curr_bed_type ?? DEFAULT_BED_TYPE] ?? 'hot_plate_temp'
545
+ const printerCommands = { bed_temp: num(bedTempKey, FFF_FALLBACKS.hot_plate_temp) }
546
+ if (presentOrTemplate('nozzle_temperature_initial_layer')) printerCommands.first_layer_nozzle_temp = num('nozzle_temperature_initial_layer')
547
+ if (presentOrTemplate(`${bedTempKey}_initial_layer`)) printerCommands.first_layer_bed_temp = num(`${bedTempKey}_initial_layer`, FFF_FALLBACKS.hot_plate_temp)
548
+ // Whether one nozzle carries every filament (upstream single_extruder_multi_material): the second-layer switch
549
+ // then names no tool and sets only the loaded one.
550
+ if (presentOrTemplate('single_extruder_multi_material')) printerCommands.single_extruder_multi_material = bool('single_extruder_multi_material', true)
551
+ // No retraction for a travel inside the infill (upstream GCode::needs_retraction); upstream's default is on.
552
+ if (presentOrTemplate('reduce_infill_retraction')) printerCommands.reduce_infill_retraction = bool('reduce_infill_retraction', true)
553
+ // The firmware flavor formats the machine commands (machine_writer.h); upstream's default is "marlin".
554
+ if (presentOrTemplate('gcode_flavor')) printerCommands.gcode_flavor = str('gcode_flavor')
555
+ // disable_m73 false: the G-code carries upstream's M73 progress placeholders (finalizeGcode fills them).
556
+ if (presentOrTemplate('disable_m73')) printerCommands.disable_m73 = bool('disable_m73', false)
557
+
381
558
  const perExtruder = {}
382
559
  for (const [param, key, scalar] of [
383
560
  ['extruder_nozzle_temp', 'nozzle_temperature', num('nozzle_temperature')],
561
+ ['extruder_first_layer_temp', 'nozzle_temperature_initial_layer', num('nozzle_temperature_initial_layer')],
384
562
  ['extruder_filament_diameter', 'filament_diameter', num('filament_diameter')],
385
563
  ['extruder_flow_ratio', 'filament_flow_ratio', num('filament_flow_ratio')],
386
564
  ['extruder_retract_length', 'filament_retraction_length', override('filament_retraction_length', 'retraction_length')],
@@ -391,8 +569,7 @@ export function deriveKernelParams(settings, opts) {
391
569
  if (!Array.isArray(raw) || raw.length < 2) continue
392
570
  perExtruder[param] = raw.map(v => {
393
571
  if (v == null || v === '') return scalar // null survives a JSON round-trip; Number(null) would be 0
394
- const n = Number(Array.isArray(v) ? v[0] : v)
395
- return Number.isFinite(n) ? n : scalar
572
+ return finiteOr(Number(firstEntryOf(v)), scalar)
396
573
  })
397
574
  }
398
575
 
@@ -401,6 +578,8 @@ export function deriveKernelParams(settings, opts) {
401
578
  ...machine,
402
579
  ...passthrough, // present only for keys the settings map actually holds (see PASSTHROUGH_* above)
403
580
  ...treeStyle,
581
+ ...envelope,
582
+ ...motion,
404
583
  layer_height: num('layer_height'),
405
584
  first_layer_height: num('initial_layer_print_height'),
406
585
  line_width,
@@ -434,14 +613,15 @@ export function deriveKernelParams(settings, opts) {
434
613
  filament_diameter: num('filament_diameter'),
435
614
  flow_ratio: num('filament_flow_ratio'),
436
615
  nozzle_temp: num('nozzle_temperature'),
437
- bed_temp: num('hot_plate_temp'), // no bed_temperature key -> hot_plate_temp
616
+ ...printerCommands, // temperatures (bed by plate type, the first layer's own) and the flavor
438
617
  bed_width, bed_depth,
618
+ ...bedOriginParams,
439
619
  bed_height: num('printable_height'), // 0 = profile states no ceiling -> kernel skips the check
440
620
  // Explicitly-set only: the schema default here is upstream's generic "G28 / G1 Z5" preamble, and falling back
441
621
  // to it would rewrite the emitted G-code for every caller. The kernel keeps its own preamble when these are empty.
442
622
  machine_start_gcode: String(settings?.machine_start_gcode ?? ''),
443
623
  machine_end_gcode: String(settings?.machine_end_gcode ?? ''),
444
- ...placeholderConfig(settings, plate),
624
+ ...placeholderConfig(settings, plate, opts?.objectNames),
445
625
  enable_support: bool('enable_support'),
446
626
  support_threshold_angle: num('support_threshold_angle'),
447
627
  support_top_z_distance: num('support_top_z_distance'),
@@ -459,7 +639,7 @@ export function deriveKernelParams(settings, opts) {
459
639
  support_expansion: num('support_expansion'),
460
640
  support_threshold_overlap: (() => { const v = settingRaw(settings, 'support_threshold_overlap')
461
641
  if (typeof v === 'string' && v.trim().endsWith('%')) return parseFloat(v) / 100
462
- const n = Number(v); return Number.isFinite(n) ? n : 0.5 })(), // "50%" -> 0.5 (ratio of extrusion width)
642
+ return finiteOr(Number(v), 0.5) })(), // "50%" -> 0.5 (ratio of extrusion width)
463
643
  support_on_build_plate_only: bool('support_on_build_plate_only'),
464
644
  support_interface_bottom_layers: num('support_interface_bottom_layers'),
465
645
  ...supportTools, // present only when a support extruder is mapped (see above)
@@ -487,7 +667,7 @@ export function deriveKernelParams(settings, opts) {
487
667
  reduce_crossing_wall: bool('reduce_crossing_wall'),
488
668
  max_volumetric_extrusion_rate_slope: num('max_volumetric_extrusion_rate_slope'),
489
669
  // Stage 7: wall generator classic|arachne (arachne = the real ported OrcaSlicer WallToolPaths, variable width)
490
- wall_generator: (str('wall_generator') === 'arachne') ? 'arachne' : 'classic',
670
+ wall_generator: allowedOr(str('wall_generator'), ['arachne'], 'classic'),
491
671
  // support_density has no matching schema key, so the kernel default (0.15) is used; scarf_length likewise uses the kernel default (10mm)
492
672
  }
493
673
  }
@@ -498,7 +678,8 @@ export function deriveKernelParams(settings, opts) {
498
678
  // path runs. The schema already carries the key (inherited from the upstream lineage), so an imported preset
499
679
  // that names a resin printer routes itself.
500
680
  export function printerTechnology(settings) {
501
- return String(settingRaw(settings, 'printer_technology') ?? 'FFF').toUpperCase() === 'SLA' ? 'SLA' : 'FFF'
681
+ if (String(settingRaw(settings, 'printer_technology') ?? 'FFF').toUpperCase() === 'SLA') return 'SLA'
682
+ return 'FFF'
502
683
  }
503
684
 
504
685
  // SLA settings provenance table. Rows come from the schema's `defined_in` marker so a new resin option cannot
@@ -513,13 +694,12 @@ const SLA_SETTING_OVERRIDES = {
513
694
 
514
695
  const SLA_SCHEMA_ROWS = Object.fromEntries(Object.entries(schema)
515
696
  .filter(([key, meta]) => key === 'printer_technology' || String(meta.defined_in ?? '').includes('sla_'))
516
- .map(([key, meta]) => [key, {
517
- source: 'config-schema.json',
518
- omission: 'schema-default',
519
- default: meta.default,
520
- ...(meta.enum_values ? { enum: [...meta.enum_values] } : {}),
521
- provenance: `${meta.defined_in}:${meta.line}`,
522
- }]))
697
+ .map(([key, meta]) => {
698
+ const row = { source: 'config-schema.json', omission: 'schema-default', default: meta.default }
699
+ if (meta.enum_values) row.enum = [...meta.enum_values]
700
+ row.provenance = `${meta.defined_in}:${meta.line}`
701
+ return [key, row]
702
+ }))
523
703
 
524
704
  export const SLA_SETTING_CONTRACT = Object.freeze(Object.fromEntries(
525
705
  Object.entries({ ...SLA_SCHEMA_ROWS, ...SLA_SETTING_OVERRIDES }).map(([key, row]) => [key, Object.freeze({
@@ -535,27 +715,40 @@ export const slaSettingKeys = Object.freeze(Object.keys(SLA_SETTING_CONTRACT))
535
715
  // and its empty-map key count is a pinned invariant — this one feeds the JS contour slicer and owes bytes to
536
716
  // nobody yet. Keys the schema does not define (the resin display's pixel grid, the per-layer height override)
537
717
  // are read from the map directly, so they follow the same omission-friendly rule: absent means the default.
718
+ // The number when it is finite and above zero, otherwise the fallback.
719
+ function positiveOr(number, fallback) {
720
+ if (Number.isFinite(number) && number > 0) return number
721
+ return fallback
722
+ }
723
+ // The number when it is finite and not negative (zero is legal), otherwise the fallback.
724
+ function nonNegativeOr(number, fallback) {
725
+ if (Number.isFinite(number) && number >= 0) return number
726
+ return fallback
727
+ }
728
+ // A flag whose absence means on.
729
+ function trueUnlessSet(value) {
730
+ if (value === undefined) return true
731
+ return !!value
732
+ }
538
733
  export function deriveSlaParams(settings) {
539
734
  const S = k => settingScalar(settings, k)
540
- const num = (k, d = SLA_FALLBACKS[k]) => { const v = Number(S(k)); return Number.isFinite(v) && v > 0 ? v : d }
735
+ const num = (k, d = SLA_FALLBACKS[k]) => positiveOr(Number(S(k)), d)
541
736
 
542
737
  // Read from the MAP, not through the schema fallback: the key is shared with FFF and its schema default is that
543
738
  // technology's opinion (0.2mm) — four resin layers thick. Absent means the resin default here.
544
739
  const rawLayerHeight = settings?.layer_height
545
- const mapLayerHeight = Number(Array.isArray(rawLayerHeight) ? rawLayerHeight[0] : rawLayerHeight)
546
- const layer_height = Number.isFinite(mapLayerHeight) && mapLayerHeight > 0 ? mapLayerHeight : SLA_FALLBACKS.layer_height
740
+ const mapLayerHeight = Number(firstEntryOf(rawLayerHeight))
741
+ const layer_height = positiveOr(mapLayerHeight, SLA_FALLBACKS.layer_height)
547
742
  // Zero is a legal fade count (no fade band), so this one cannot go through the positives-only reader.
548
743
  const fadedRaw = Number(S('faded_layers'))
549
744
  const requestedSupportTree = String(S('support_tree_type') ?? 'default').toLowerCase()
550
- const support_tree_type = ['default', 'branching', 'organic'].includes(requestedSupportTree)
551
- ? requestedSupportTree
552
- : 'default'
745
+ const support_tree_type = allowedOr(requestedSupportTree, ['default', 'branching', 'organic'], 'default')
553
746
  return {
554
747
  layer_height,
555
748
  initial_layer_height: num('initial_layer_height', layer_height),
556
749
  exposure_time: num('exposure_time'),
557
750
  initial_exposure_time: num('initial_exposure_time'),
558
- faded_layers: Number.isFinite(fadedRaw) && fadedRaw >= 0 ? fadedRaw : 10,
751
+ faded_layers: nonNegativeOr(fadedRaw, 10),
559
752
  // The physical display and its pixel grid — what turns millimetres into raster pixels at export time.
560
753
  // Fallbacks are the SL1's own panel, the reference machine of the format this exports to.
561
754
  display_width: num('display_width'),
@@ -564,8 +757,8 @@ export function deriveSlaParams(settings) {
564
757
  display_pixels_y: num('display_pixels_y'),
565
758
  // Panel mounting: the whole SL1 family is portrait with X mirrored (projection through the vat floor);
566
759
  // the raster writer swaps the image axes for portrait exactly like upstream SL1.cpp create_raster.
567
- display_orientation: String(S('display_orientation') ?? 'portrait') === 'landscape' ? 'landscape' : 'portrait',
568
- display_mirror_x: settingRaw(settings, 'display_mirror_x') === undefined ? true : !!settingRaw(settings, 'display_mirror_x'),
760
+ display_orientation: allowedOr(String(S('display_orientation') ?? 'portrait'), ['landscape'], 'portrait'),
761
+ display_mirror_x: trueUnlessSet(settingRaw(settings, 'display_mirror_x')),
569
762
  display_mirror_y: !!settingRaw(settings, 'display_mirror_y'),
570
763
  // SL1 archive identity fields (config.ini): written as-is, empty when the host never set a named
571
764
  // profile — the same thing upstream produces then. material_print_speed defaults to 'fast', the value
@@ -606,23 +799,23 @@ export function deriveSlaParams(settings) {
606
799
  // percent sign is stripped here; widening factor and base safety distance are legally ZERO (zero safety
607
800
  // falls back to the kernel's own safety_distance), so neither can go through the positives-only reader.
608
801
  support_small_pillar_diameter_percent:
609
- (() => { const v = Number(String(S('support_small_pillar_diameter_percent') ?? '').replace('%', '')); return Number.isFinite(v) && v > 0 ? v : 50 })(),
802
+ positiveOr(Number(String(S('support_small_pillar_diameter_percent') ?? '').replace('%', '')), 50),
610
803
  support_pillar_widening_factor:
611
- (() => { const v = Number(S('support_pillar_widening_factor')); return Number.isFinite(v) && v >= 0 ? v : 0 })(),
804
+ nonNegativeOr(Number(S('support_pillar_widening_factor')), 0),
612
805
  support_base_safety_distance:
613
- (() => { const v = Number(S('support_base_safety_distance')); return Number.isFinite(v) && v >= 0 ? v : 1 })(),
806
+ nonNegativeOr(Number(S('support_base_safety_distance')), 1),
614
807
  support_max_bridges_on_pillar: num('support_max_bridges_on_pillar'),
615
808
  support_max_weight_on_model: num('support_max_weight_on_model'),
616
809
  // The generator's input slices are gap-closed with this radius, exactly like upstream slice_mesh_ex.
617
810
  slice_closing_radius: num('slice_closing_radius'),
618
- support_object_elevation: (() => { const v = Number(S('support_object_elevation')); return Number.isFinite(v) && v >= 0 ? v : 5 })(),
811
+ support_object_elevation: nonNegativeOr(Number(S('support_object_elevation')), 5),
619
812
  support_pillar_connection_mode: String(S('support_pillar_connection_mode') ?? 'dynamic'),
620
813
  // The pad (upstream make_pad_cfg): wall height is legally ZERO (flat pad, no cavity), so it cannot go
621
814
  // through the positives-only reader.
622
815
  pad_brim_size: num('pad_brim_size'),
623
816
  pad_wall_thickness: num('pad_wall_thickness'),
624
817
  pad_wall_height:
625
- (() => { const v = Number(S('pad_wall_height')); return Number.isFinite(v) && v >= 0 ? v : 0 })(),
818
+ nonNegativeOr(Number(S('pad_wall_height')), 0),
626
819
  pad_wall_slope: num('pad_wall_slope'),
627
820
  pad_max_merge_distance: num('pad_max_merge_distance'),
628
821
  pad_around_object: !!settingRaw(settings, 'pad_around_object'),
package/types/gcode.d.ts CHANGED
@@ -9,6 +9,13 @@ export interface GcodeLayer {
9
9
  widths: Float32Array
10
10
  }
11
11
 
12
+ /**
13
+ * Fills the placeholders a slice leaves for its finished estimate, as upstream's GCodeProcessor post-process does:
14
+ * the M73 progress lines (`;_GP_FIRST_LINE_M73_PLACEHOLDER` / `;_GP_LAST_LINE_M73_PLACEHOLDER`, one M73 per layer
15
+ * start) and file_start_gcode's `@PRINT_TIME_SEC@` / `@USED_FILAMENT_LENGTH@`. A G-code without them is returned as is.
16
+ */
17
+ export function finalizeGcode(gcode: string, stats: { time_estimate?: number; filament_mm?: number; layer_times?: number[] } | null | undefined): string
18
+
12
19
  export interface ParseGcodeResult {
13
20
  layers: GcodeLayer[]
14
21
  stats: {
@@ -53,3 +60,9 @@ export interface ParseGcodeOptions {
53
60
  * Recovery is lossy where G-code carries no data: unknown roles become wall(1), widths are derived from E.
54
61
  */
55
62
  export function parseGcode(text: string, opts?: ParseGcodeOptions): ParseGcodeResult
63
+
64
+ /**
65
+ * A slice as it is shown: its exported G-code parsed back (start and end G-code extrusions included),
66
+ * moved from printer coordinates into the slice's plate-local frame by taking the bed centre off.
67
+ */
68
+ export function previewFromGcode(gcode: string, opts?: { center?: { x: number; y: number }; filamentDiameter?: number; layerHeight?: number }): ParseGcodeResult
@@ -16,7 +16,20 @@ export function settingScalar(settings: SlicerSettings | null | undefined, key:
16
16
  * ponytail: 53 returned keys, so Record instead of listing each — kernel params are passed straight to slice(),
17
17
  * not an API consumers read field by field. Expand it when field access is actually needed.
18
18
  */
19
- export function deriveKernelParams(settings: SlicerSettings | null | undefined, opts?: { plate?: number }): Record<string, unknown>
19
+ export function deriveKernelParams(settings: SlicerSettings | null | undefined, opts?: { plate?: number; objectNames?: string[] }): Record<string, unknown>
20
+ /**
21
+ * The printable area's lower-left corner and its centre in printer coordinates, from deriveKernelParams' output.
22
+ * The kernel slices in a plate frame centred on the bed and adds the centre on output, so a stored printer
23
+ * coordinate (wipe_tower_x/y, a 3mf position, G-code) converts to that frame by subtracting `bedCenter`.
24
+ */
25
+ export function bedOrigin(params: Record<string, unknown> | null | undefined): { x: number; y: number }
26
+ /** Code of a printer profile setting the template path reads but does not do yet. */
27
+ export const PARTIAL_SUPPORT_CODE: 'PROFILE_PARTIAL_SUPPORT'
28
+ /** The tool change settings a profile sets that the template path does not apply (empty off the template path or
29
+ * with one tool), each with what happens instead. */
30
+ export function partialSupport(settings: SlicerSettings | null | undefined, opts?: { toolCount?: number }):
31
+ { code: 'PROFILE_PARTIAL_SUPPORT'; key: string; effect: string }[]
32
+ export function bedCenter(params: Record<string, unknown> | null | undefined): { x: number; y: number }
20
33
 
21
34
  /**
22
35
  * Clear-then-merge preset application: deletes `keys` (the catalog's own key set) from `settings`, then
@@ -39,6 +39,7 @@ export interface SegmentMeta {
39
39
 
40
40
  export interface SegmentData {
41
41
  position: Float32Array
42
+ /** vec4 per vertex: [height, width, heading of the extrusion joined at this endpoint (its own when none), packed colour] */
42
43
  hwa: Float32Array
43
44
  segIndex: Uint32Array
44
45
  nV: number
@@ -110,6 +111,11 @@ export interface ToolpathHandle {
110
111
  setVisibleLayers(n: number): void
111
112
  setTravelVisible(visible: boolean): void
112
113
  setColors(color: Float32Array): void
114
+ /** Draws the top shown layer up to its move `at`, extrusions and travels in print order; null draws the whole
115
+ * range again. Returns where the nozzle is (moveCursor) and the layer walked, or null. */
116
+ setMoveRange(at: number | null): { point: number[] | null; segCount: number; travCount: number; onTravel: boolean; layer: number } | null
117
+ /** The SegmentData the handle was built from. */
118
+ data: SegmentData
113
119
  dispose(): void
114
120
  nSeg: number
115
121
  layerCount: number