three-slicer-viewer 0.3.4 → 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.
package/dist/index.js CHANGED
@@ -4864,7 +4864,7 @@ function Dp({ error: e }) {
4864
4864
  return /* @__PURE__ */ i.createElement("div", { className: "slice-err side-warn", "data-testid": "slice-err" }, t, r && /* @__PURE__ */ i.createElement("pre", { className: "slice-err-detail" }, r));
4865
4865
  }
4866
4866
  function Gp() {
4867
- return "0.3.4";
4867
+ return "0.3.5";
4868
4868
  }
4869
4869
  const Br = Gp();
4870
4870
  function Up({ on: e = !0 }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-slicer-viewer",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "The three-slicer viewer without the slicer — model loading, G-code parsing, GPU toolpath rendering and the settings transforms, under MIT. Plug a slicer in through the `slicer` prop.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
@@ -143,7 +146,8 @@ function coerceScalar(type, value) {
143
146
  return asNumber(value.trim().replace(/%$/, ''))
144
147
  case 'coFloatOrPercent': case 'coFloatsOrPercents':
145
148
  // "50%" must stay a string — the percent IS the value, and Number('50%') is NaN anyway.
146
- return value.trim().endsWith('%') ? value : asNumber(value)
149
+ if (value.trim().endsWith('%')) return value
150
+ return asNumber(value)
147
151
  case 'coPoint': case 'coPoints':
148
152
  return parsePoint(value) ?? value
149
153
  case 'coPointsGroups':
@@ -161,25 +165,34 @@ function coerceScalar(type, value) {
161
165
  // so there the comma cannot also separate a point's own coordinates — hence two parsers rather than one regex.
162
166
  function parsePoint(text) {
163
167
  const split = String(text).split('x')
164
- const pair = split.length === 2 ? split : String(text).split(',')
168
+ let pair = split
169
+ if (split.length !== 2) pair = String(text).split(',')
165
170
  if (pair.length !== 2) return null
166
171
  const x = Number(pair[0]), y = Number(pair[1])
167
- return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
172
+ if (Number.isFinite(x) && Number.isFinite(y)) return [x, y]
173
+ return null
168
174
  }
169
175
  function parsePointGroup(text) {
170
176
  const points = String(text).split(',').map(entry => {
171
177
  const [a, b] = entry.split('x')
172
178
  const x = Number(a), y = Number(b)
173
- return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null
179
+ if (Number.isFinite(x) && Number.isFinite(y)) return [x, y]
180
+ return null
174
181
  })
175
- return points.length && points.every(Boolean) ? points : null
182
+ if (points.length && points.every(Boolean)) return points
183
+ return null
176
184
  }
177
185
  // Number('') is 0 and Number(' ') is 0, neither of which means "the option was set to zero" — an unparsable
178
186
  // value is handed back untouched so the schema default keeps applying instead of a fabricated 0.
179
187
  function asNumber(value) {
180
188
  if (value.trim() === '') return value
181
- const n = Number(value)
182
- 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
183
196
  }
184
197
 
185
198
  /**
@@ -193,7 +206,8 @@ export function normalizeProjectSettings(raw) {
193
206
  for (const [key, value] of Object.entries(raw || {})) {
194
207
  const type = schema[key]?.type
195
208
  if (!type) { skipped.push(key); continue }
196
- 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)
197
211
  applied++
198
212
  }
199
213
  return { settings, applied, skipped }
@@ -204,17 +218,22 @@ export function normalizeProjectSettings(raw) {
204
218
  // slicer's reader — and normalizeProjectSettings above — expects to coerce FROM. Writing the JS types raw would
205
219
  // hand OrcaSlicer a bool it reads with its own string parser and this package a round-trip that only works here.
206
220
  // Keys the schema does not define are dropped for the same reason normalize drops them: they could not be typed.
207
- 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
+ }
208
225
 
209
226
  function serializeScalar(type, value) {
210
227
  if (value == null) return ''
211
228
  switch (type) {
212
229
  case 'coBool': case 'coBools':
213
- return value ? '1' : '0'
230
+ if (value) return '1'
231
+ return '0'
214
232
  case 'coPoint': case 'coPoints':
215
233
  return pointToText(value)
216
234
  case 'coPointsGroups':
217
- return Array.isArray(value) ? value.map(pointToText).join(',') : String(value)
235
+ if (Array.isArray(value)) return value.map(pointToText).join(',')
236
+ return String(value)
218
237
  default:
219
238
  return String(value) // numbers, percents, strings, enums — String() is upstream's own spelling for all of them
220
239
  }
@@ -227,9 +246,8 @@ export function serializeProjectSettings(settings) {
227
246
  const type = schema[key]?.type
228
247
  if (!type) continue
229
248
  // A coPoint's value IS an array ([x, y]) — the generic array path would serialize its two numbers separately.
230
- out[key] = (type !== 'coPoint' && Array.isArray(value))
231
- ? value.map(entry => serializeScalar(type, entry))
232
- : 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)
233
251
  }
234
252
  return out
235
253
  }
@@ -300,6 +318,22 @@ const BED_TEMP_KEYS = {
300
318
  'Engineering Plate': 'eng_plate_temp', 'High Temp Plate': 'hot_plate_temp', 'Textured PEI Plate': 'textured_plate_temp',
301
319
  }
302
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
335
+ }
336
+
303
337
  // Right-panel settings values -> kernel parameters (derived from schema keys)
304
338
  // opts.plate: which plate's entry to take from per-plate array options (wipe_tower_x/y — upstream coFloats,
305
339
  // one entry per plate). Defaults to 0, so every existing caller reads exactly what it always did.
@@ -313,16 +347,23 @@ export function deriveKernelParams(settings, opts) {
313
347
  const presentOrTemplate = (key) => followsTemplate || (settings != null && key in settings && settings[key] != null && settings[key] !== '')
314
348
  const S = k => settingScalar(settings, k)
315
349
  const str = k => String(S(k) ?? FFF_FALLBACKS[k])
316
- const num = (k, d = FFF_FALLBACKS[k]) => { const v = Number(S(k)); return Number.isFinite(v) ? v : d }
317
- 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
+ }
318
358
  // Filament retraction overrides: upstream lets a material override the machine's retraction (TPU wants a
319
359
  // different pull-back than PLA on the same printer). These schema keys are nullable and carry no default of
320
360
  // their own — "unset" means keep the machine value — so this reads the settings map directly rather than
321
361
  // through settingScalar, whose schema fallback would not distinguish the two.
322
362
  const override = (filamentKey, machineKey, fallback = FFF_FALLBACKS[machineKey]) => {
323
363
  const raw = settings?.[filamentKey]
324
- const value = Number(Array.isArray(raw) ? raw[0] : raw)
325
- 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)
326
367
  }
327
368
 
328
369
  let line_width = num('line_width'); if (!line_width) line_width = AUTO_LINE_WIDTH // 0 = auto
@@ -364,7 +405,7 @@ export function deriveKernelParams(settings, opts) {
364
405
  }
365
406
 
366
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).
367
- // 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).
368
409
  const widths = {}
369
410
  for (const k of ['outer_wall_line_width','inner_wall_line_width','top_surface_line_width',
370
411
  'sparse_infill_line_width','internal_solid_infill_line_width','initial_layer_line_width']) {
@@ -402,9 +443,11 @@ export function deriveKernelParams(settings, opts) {
402
443
  // the plate and disabled the automatic placement entirely — measured as a tower at z=-127.5 on a 200mm bed.
403
444
  // Absent from the map means nobody chose one, and the placement beside the model stands.
404
445
  const chosen = (key) => { const raw = settings?.[key]
405
- const v = Array.isArray(raw) ? raw[plate] : raw
446
+ let v = raw
447
+ if (Array.isArray(raw)) v = raw[plate]
406
448
  const n = Number(v)
407
- return (v != null && v !== '' && Number.isFinite(n)) ? n : null }
449
+ if (v != null && v !== '' && Number.isFinite(n)) return n
450
+ return null }
408
451
  const towerX = chosen('wipe_tower_x'), towerY = chosen('wipe_tower_y')
409
452
  if (towerX != null) towerSettings.prime_tower_x = towerX
410
453
  if (towerY != null) towerSettings.prime_tower_y = towerY
@@ -442,20 +485,22 @@ export function deriveKernelParams(settings, opts) {
442
485
  for (const key of PASSTHROUGH_NUM_VECTOR) {
443
486
  if (!present(key)) continue
444
487
  const raw = settings[key]
445
- 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))
446
489
  }
447
490
  for (const key of PASSTHROUGH_STR_VECTOR) {
448
491
  if (!present(key)) continue
449
492
  const raw = settings[key]
450
- passthrough[key] = (Array.isArray(raw) ? raw : [raw]).map(entry => String(entry ?? ''))
493
+ passthrough[key] = asList(raw).map(entry => String(entry ?? ''))
451
494
  }
452
495
  // support_line_width is coFloatOrPercent, but the kernel reads it with the plain number reader rather than the
453
- // 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
454
497
  // parse to 0 == auto — the setting silently ignored. Resolved here against the nozzle, which is the same
455
- // 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.
456
499
  if (present('support_line_width')) {
457
500
  const text = String(settingScalar(settings, 'support_line_width')).trim()
458
- 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)
459
504
  if (Number.isFinite(width) && width > 0) passthrough.support_line_width = width
460
505
  }
461
506
  }
@@ -524,8 +569,7 @@ export function deriveKernelParams(settings, opts) {
524
569
  if (!Array.isArray(raw) || raw.length < 2) continue
525
570
  perExtruder[param] = raw.map(v => {
526
571
  if (v == null || v === '') return scalar // null survives a JSON round-trip; Number(null) would be 0
527
- const n = Number(Array.isArray(v) ? v[0] : v)
528
- return Number.isFinite(n) ? n : scalar
572
+ return finiteOr(Number(firstEntryOf(v)), scalar)
529
573
  })
530
574
  }
531
575
 
@@ -595,7 +639,7 @@ export function deriveKernelParams(settings, opts) {
595
639
  support_expansion: num('support_expansion'),
596
640
  support_threshold_overlap: (() => { const v = settingRaw(settings, 'support_threshold_overlap')
597
641
  if (typeof v === 'string' && v.trim().endsWith('%')) return parseFloat(v) / 100
598
- 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)
599
643
  support_on_build_plate_only: bool('support_on_build_plate_only'),
600
644
  support_interface_bottom_layers: num('support_interface_bottom_layers'),
601
645
  ...supportTools, // present only when a support extruder is mapped (see above)
@@ -623,7 +667,7 @@ export function deriveKernelParams(settings, opts) {
623
667
  reduce_crossing_wall: bool('reduce_crossing_wall'),
624
668
  max_volumetric_extrusion_rate_slope: num('max_volumetric_extrusion_rate_slope'),
625
669
  // Stage 7: wall generator classic|arachne (arachne = the real ported OrcaSlicer WallToolPaths, variable width)
626
- wall_generator: (str('wall_generator') === 'arachne') ? 'arachne' : 'classic',
670
+ wall_generator: allowedOr(str('wall_generator'), ['arachne'], 'classic'),
627
671
  // support_density has no matching schema key, so the kernel default (0.15) is used; scarf_length likewise uses the kernel default (10mm)
628
672
  }
629
673
  }
@@ -634,7 +678,8 @@ export function deriveKernelParams(settings, opts) {
634
678
  // path runs. The schema already carries the key (inherited from the upstream lineage), so an imported preset
635
679
  // that names a resin printer routes itself.
636
680
  export function printerTechnology(settings) {
637
- 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'
638
683
  }
639
684
 
640
685
  // SLA settings provenance table. Rows come from the schema's `defined_in` marker so a new resin option cannot
@@ -649,13 +694,12 @@ const SLA_SETTING_OVERRIDES = {
649
694
 
650
695
  const SLA_SCHEMA_ROWS = Object.fromEntries(Object.entries(schema)
651
696
  .filter(([key, meta]) => key === 'printer_technology' || String(meta.defined_in ?? '').includes('sla_'))
652
- .map(([key, meta]) => [key, {
653
- source: 'config-schema.json',
654
- omission: 'schema-default',
655
- default: meta.default,
656
- ...(meta.enum_values ? { enum: [...meta.enum_values] } : {}),
657
- provenance: `${meta.defined_in}:${meta.line}`,
658
- }]))
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
+ }))
659
703
 
660
704
  export const SLA_SETTING_CONTRACT = Object.freeze(Object.fromEntries(
661
705
  Object.entries({ ...SLA_SCHEMA_ROWS, ...SLA_SETTING_OVERRIDES }).map(([key, row]) => [key, Object.freeze({
@@ -671,27 +715,40 @@ export const slaSettingKeys = Object.freeze(Object.keys(SLA_SETTING_CONTRACT))
671
715
  // and its empty-map key count is a pinned invariant — this one feeds the JS contour slicer and owes bytes to
672
716
  // nobody yet. Keys the schema does not define (the resin display's pixel grid, the per-layer height override)
673
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
+ }
674
733
  export function deriveSlaParams(settings) {
675
734
  const S = k => settingScalar(settings, k)
676
- 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)
677
736
 
678
737
  // Read from the MAP, not through the schema fallback: the key is shared with FFF and its schema default is that
679
738
  // technology's opinion (0.2mm) — four resin layers thick. Absent means the resin default here.
680
739
  const rawLayerHeight = settings?.layer_height
681
- const mapLayerHeight = Number(Array.isArray(rawLayerHeight) ? rawLayerHeight[0] : rawLayerHeight)
682
- 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)
683
742
  // Zero is a legal fade count (no fade band), so this one cannot go through the positives-only reader.
684
743
  const fadedRaw = Number(S('faded_layers'))
685
744
  const requestedSupportTree = String(S('support_tree_type') ?? 'default').toLowerCase()
686
- const support_tree_type = ['default', 'branching', 'organic'].includes(requestedSupportTree)
687
- ? requestedSupportTree
688
- : 'default'
745
+ const support_tree_type = allowedOr(requestedSupportTree, ['default', 'branching', 'organic'], 'default')
689
746
  return {
690
747
  layer_height,
691
748
  initial_layer_height: num('initial_layer_height', layer_height),
692
749
  exposure_time: num('exposure_time'),
693
750
  initial_exposure_time: num('initial_exposure_time'),
694
- faded_layers: Number.isFinite(fadedRaw) && fadedRaw >= 0 ? fadedRaw : 10,
751
+ faded_layers: nonNegativeOr(fadedRaw, 10),
695
752
  // The physical display and its pixel grid — what turns millimetres into raster pixels at export time.
696
753
  // Fallbacks are the SL1's own panel, the reference machine of the format this exports to.
697
754
  display_width: num('display_width'),
@@ -700,8 +757,8 @@ export function deriveSlaParams(settings) {
700
757
  display_pixels_y: num('display_pixels_y'),
701
758
  // Panel mounting: the whole SL1 family is portrait with X mirrored (projection through the vat floor);
702
759
  // the raster writer swaps the image axes for portrait exactly like upstream SL1.cpp create_raster.
703
- display_orientation: String(S('display_orientation') ?? 'portrait') === 'landscape' ? 'landscape' : 'portrait',
704
- 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')),
705
762
  display_mirror_y: !!settingRaw(settings, 'display_mirror_y'),
706
763
  // SL1 archive identity fields (config.ini): written as-is, empty when the host never set a named
707
764
  // profile — the same thing upstream produces then. material_print_speed defaults to 'fast', the value
@@ -742,23 +799,23 @@ export function deriveSlaParams(settings) {
742
799
  // percent sign is stripped here; widening factor and base safety distance are legally ZERO (zero safety
743
800
  // falls back to the kernel's own safety_distance), so neither can go through the positives-only reader.
744
801
  support_small_pillar_diameter_percent:
745
- (() => { 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),
746
803
  support_pillar_widening_factor:
747
- (() => { 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),
748
805
  support_base_safety_distance:
749
- (() => { 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),
750
807
  support_max_bridges_on_pillar: num('support_max_bridges_on_pillar'),
751
808
  support_max_weight_on_model: num('support_max_weight_on_model'),
752
809
  // The generator's input slices are gap-closed with this radius, exactly like upstream slice_mesh_ex.
753
810
  slice_closing_radius: num('slice_closing_radius'),
754
- 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),
755
812
  support_pillar_connection_mode: String(S('support_pillar_connection_mode') ?? 'dynamic'),
756
813
  // The pad (upstream make_pad_cfg): wall height is legally ZERO (flat pad, no cavity), so it cannot go
757
814
  // through the positives-only reader.
758
815
  pad_brim_size: num('pad_brim_size'),
759
816
  pad_wall_thickness: num('pad_wall_thickness'),
760
817
  pad_wall_height:
761
- (() => { const v = Number(S('pad_wall_height')); return Number.isFinite(v) && v >= 0 ? v : 0 })(),
818
+ nonNegativeOr(Number(S('pad_wall_height')), 0),
762
819
  pad_wall_slope: num('pad_wall_slope'),
763
820
  pad_max_merge_distance: num('pad_max_merge_distance'),
764
821
  pad_around_object: !!settingRaw(settings, 'pad_around_object'),