three-slicer-viewer 0.3.4 → 0.4.0

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/COMPONENTS.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # three-slicer-viewer/components — the settings form
2
2
 
3
- (Published as `three-slicer-viewer/components`, MIT. `three-slicer/components` is the same component with upstream's
4
- schema, tab tree and toggle rules plugged in; everything below applies to both.)
3
+ (Published as `three-slicer-viewer/components`, MIT. Upstream's labels, tab tree and toggle rules come from
4
+ `three-slicer` through the `schema`, `uiTree` and `toggle` props, as below; without them every field is labelled by
5
+ its key and nothing is disabled.)
5
6
 
6
7
  Reusable React components for the browser slicer. Props-driven — no global state, no React context, no router coupling (proven standalone by the tarball consumer gate `packages/pack_check.sh`).
7
8
 
@@ -12,16 +13,20 @@ An OrcaSlicer-style settings form generated from `three-slicer/data` (schema + U
12
13
  The tab set follows the printer profile's technology: a settings map whose `printer_technology` says SLA swaps the FFF tabs for the SLA ones (print/material/printer builders carry the technology in their upstream builder name, which is what the filter reads). No prop needed — change the profile in the shared settings map and the panel follows.
13
14
 
14
15
  ```bash
15
- npm i three-slicer react react-dom # react is an OPTIONAL peer — npm does not install it for you
16
+ npm i three-slicer three-slicer-viewer react react-dom # same version for the two; react is an OPTIONAL peer — npm does not install it for you
16
17
  ```
17
18
 
18
19
  ```jsx
19
20
  import { useState } from 'react'
20
- import SettingsPanel from 'three-slicer/components'
21
+ import SettingsPanel from 'three-slicer-viewer/components'
22
+ import { schema, uiTree } from 'three-slicer/data' // upstream's labels, tooltips and tab tree (AGPL)
23
+ import { makeCfg, disabledKeys } from 'three-slicer/toggle' // the evaluator bound to upstream's rules (AGPL)
24
+
25
+ const toggle = { makeCfg, disabledKeys }
21
26
 
22
27
  function App() {
23
28
  const [settings, setSettings] = useState({}) // sparse map: edited keys only; missing = schema default
24
- return <SettingsPanel settings={settings} setSettings={setSettings} />
29
+ return <SettingsPanel schema={schema} uiTree={uiTree} toggle={toggle} settings={settings} setSettings={setSettings} />
25
30
  }
26
31
  ```
27
32
 
@@ -36,11 +41,11 @@ Props:
36
41
  | `customWidgets` | `{ [schemaKey]: Component }` (optional) | Replace schema-driven widgets per key (e.g. `{ printable_area: MyBedEditor }`) |
37
42
  | `only` | `{ builder, page? }` (optional) | Pins the panel to one builder/page and drops the search/page/mode chrome — how `<Viewport/>`'s `motionPanel` slot is meant to be filled |
38
43
 
39
- The same `settings` object feeds `deriveKernelParams()` in `three-slicer` and the `<Viewport/>` in `three-slicer/viewer` — one state connects all three.
44
+ The same `settings` object feeds `deriveKernelParams()` in `three-slicer` and `three-slicer-viewer`'s `<Viewport/>` — one state connects all three.
40
45
 
41
46
  Ships transpiled ESM (`dist/`); react is an optional peer dependency. The panel arrives fully styled: its stylesheet is inlined into the bundle and injected into a Shadow DOM root, so host CSS neither styles it nor collides with it.
42
47
 
43
- The 3D viewport lives in its own package: `three-slicer/viewer`.
48
+ The 3D viewport is the package's main entry, `three-slicer-viewer` ([VIEWER.md](VIEWER.md)).
44
49
 
45
50
  ## License
46
51
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ commercial products.
6
6
 
7
7
  This is [`three-slicer`](https://www.npmjs.com/package/three-slicer) with the kernel taken out. It displays and
8
8
  exports; it does not slice. Slicing is plugged in through two props — `slicer` (a hook driving a kernel) and
9
- `catalog` (vendor presets) — which `three-slicer/viewer` supplies. Install `three-slicer` for that, and note that
9
+ `catalog` (vendor presets) — which `three-slicer` supplies (below). Install `three-slicer` for that, and note that
10
10
  doing so puts your application under AGPL.
11
11
 
12
12
  Developed in the [`Three_Slicer`](https://github.com/kimgh06/Three_Slicer) monorepo (`viewer-package/`), where it
@@ -41,7 +41,7 @@ const slicer = (deps) => useSlicer({ ...deps, makeWorker: makeSlicerWorker })
41
41
  import SettingsPanel from 'three-slicer-viewer/components'
42
42
 
43
43
  // Bare: fields labelled by key, nothing disabled. Pass `schema`, `uiTree` and `toggle` for labels, tabs and
44
- // enable/disable rules — three-slicer/components does that with OrcaSlicer's data; a host may pass its own.
44
+ // enable/disable rules — three-slicer/data and /toggle carry OrcaSlicer's; a host may pass its own.
45
45
  <SettingsPanel settings={settings} setSettings={setSettings} />
46
46
  ```
47
47
 
package/VIEWER.md CHANGED
@@ -1,26 +1,30 @@
1
1
  # three-slicer-viewer — the viewer
2
2
 
3
- (Published as `three-slicer-viewer`, MIT. `three-slicer/viewer` is the same component with the WASM kernel and the
4
- vendor catalog plugged in; everything below applies to both.)
3
+ (Published as `three-slicer-viewer`, MIT. Slicing and the vendor presets come from `three-slicer` through the
4
+ `slicer` and `catalog` props, as below; without them the viewer loads, displays and exports, but never slices.)
5
5
 
6
6
  3D slicer viewer as a React component: three.js viewport (orbit/transform gizmos), model import (STL/OBJ/3MF/AMF/PLY + pluggable formats such as STEP, multi-object, drag & drop), Web Worker slicing via `three-slicer`, support and material painting, per-extruder filament presets, multi-plate, and a GPU-instanced volumetric toolpath preview ported from OrcaSlicer's libvgcode (millions of segments in a single draw path, per-feature colors, layer range slider, G-code export).
7
7
 
8
8
  ![The Prepare tab — drag-and-drop viewport with the printer, filament and process cards in the right sidebar](../../slice-sidebar.png)
9
9
 
10
10
  ```bash
11
- npm i three-slicer react react-dom three # the three are optional peers; the viewer needs all of them
11
+ npm i three-slicer three-slicer-viewer react react-dom three # same version for the two; the last three are optional peers the viewer needs
12
12
  ```
13
13
 
14
14
  ```jsx
15
15
  import { useState } from 'react'
16
- import Viewport from 'three-slicer/viewer'
16
+ import Viewport, { useSlicer } from 'three-slicer-viewer'
17
+ import { makeSlicerWorker } from 'three-slicer/client' // the WASM kernel's worker (AGPL)
18
+ import { bundledCatalog } from 'three-slicer/settings' // OrcaSlicer's vendor presets (AGPL)
19
+
20
+ const slicer = (deps) => useSlicer({ ...deps, makeWorker: makeSlicerWorker }) // module-level: it is a hook
17
21
 
18
22
  function App() {
19
23
  const [settings, setSettings] = useState({}) // OrcaSlicer schema keys; sparse — defaults fill the rest
20
24
  // The container matters — see Layout below.
21
25
  return (
22
26
  <div style={{ position: 'relative', width: '100%', height: '100vh' }}>
23
- <Viewport settings={settings} setSettings={setSettings} />
27
+ <Viewport slicer={slicer} catalog={bundledCatalog} settings={settings} setSettings={setSettings} />
24
28
  </div>
25
29
  )
26
30
  }
@@ -37,7 +41,7 @@ the initial containing block instead, which usually looks like the viewer coveri
37
41
 
38
42
  There is no width or height prop: the component always fills that container, and resizes with it.
39
43
 
40
- Slice parameters are derived from `settings` via `three-slicer`'s schema mapping — pair it with `three-slicer/components`' `<SettingsPanel/>` sharing the same state for a full slicer UI.
44
+ Slice parameters are derived from `settings` via `three-slicer`'s schema mapping — pair it with `three-slicer-viewer/components`' `<SettingsPanel/>` ([COMPONENTS.md](COMPONENTS.md)) sharing the same state for a full slicer UI.
41
45
 
42
46
  A `.3mf` written by a slicer (OrcaSlicer/BambuStudio save, MakerWorld download) imports as a **project**: plate layout, project settings, and support/material painting are restored, not just the meshes. Where a facet carries both paint kinds, material paint wins and the dropped support paint is reported.
43
47
 
@@ -47,9 +51,9 @@ next to the material picker) takes a **function** `(settings, setSettings) => Re
47
51
  card binds each extruder's per-extruder values.
48
52
 
49
53
  Subpath exports for custom UIs (framework-free, no React):
50
- - `three-slicer/viewer/toolpath` — GPU toolpath renderer (`buildSegmentData`, `makeToolpath`, view-type colorers)
51
- - `three-slicer/viewer/loaders` — model loaders returning unified triangle soup
52
- - `three-slicer/viewer/gcode` — `parseGcode(text)`, G-code back into the layer stream the renderer consumes
54
+ - `three-slicer-viewer/toolpath` — GPU toolpath renderer (`buildSegmentData`, `makeToolpath`, view-type colorers)
55
+ - `three-slicer-viewer/loaders` — model loaders returning unified triangle soup
56
+ - `three-slicer-viewer/gcode` — `parseGcode(text)`, G-code back into the layer stream the renderer consumes
53
57
 
54
58
  ## Embedding it without the panels
55
59
 
@@ -233,7 +237,7 @@ Two limits to expect while using it:
233
237
  - Painting a region onto a second extruder only takes effect with **support turned off**. The painted multi-material path emits no support, so a support-enabled slice deliberately stays on the single-material path (where painted support keeps working as before).
234
238
  - On a machine with two or more extruders, a support *blocker* paint and an *Extruder 2* paint are the same mark and cannot be told apart.
235
239
 
236
- If you consume `three-slicer/viewer/toolpath` directly, note that the stride-8 role field `paths[k+3]` now carries the printing extruder alongside the role as `role + tool * 16`. Mask it — `& 15` for the role, `>>> 4` for the tool. Streams produced before this encoding are entirely below 16 and decode to their own role with tool 0, so older data still reads correctly.
240
+ If you consume `three-slicer-viewer/toolpath` directly, note that the stride-8 role field `paths[k+3]` now carries the printing extruder alongside the role as `role + tool * 16`. Mask it — `& 15` for the role, `>>> 4` for the tool. Streams produced before this encoding are entirely below 16 and decode to their own role with tool 0, so older data still reads correctly.
237
241
 
238
242
  ## Turning behaviour off
239
243
 
@@ -336,7 +340,7 @@ The component owns its scene, and the props are the whole interface — there is
336
340
  - Everything else the component owns — camera, selection, plate count, paint state — is reported through `onEvent`
337
341
  and settable only by the user.
338
342
 
339
- If you need programmatic control over the scene, build on the subpath exports (`three-slicer/viewer/toolpath`,
343
+ If you need programmatic control over the scene, build on the subpath exports (`three-slicer-viewer/toolpath`,
340
344
  `/loaders`, `/gcode`) and `three-slicer/client` rather than on `<Viewport/>`.
341
345
 
342
346
  ## Multithreaded slicing (automatic)
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.4.0";
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.4.0",
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'),
@@ -1,12 +1,12 @@
1
- // three-slicer/components
1
+ // three-slicer-viewer/components
2
2
  import type * as React from 'react'
3
3
  import type { SlicerSettings } from './settings-keys.d.ts'
4
4
 
5
5
  export interface SettingsPanelProps {
6
6
  /**
7
7
  * The config schema to render from. Defaults to this package's reduced schema — types, defaults, enum values,
8
- * layout flags and units, but no label or tooltip, so a field is labelled by its key. `three-slicer/components`
9
- * passes the full schema; a host may pass its own (a translation, its own vocabulary).
8
+ * layout flags and units, but no label or tooltip, so a field is labelled by its key. Pass
9
+ * three-slicer/data's full schema for upstream's labels; a host may pass its own (a translation, its own vocabulary).
10
10
  */
11
11
  schema?: Record<string, Record<string, unknown>>
12
12
  /** The tab/page/group tree. Defaults to one flat page per builder over this package's ui-tree keys. */
package/types/gcode.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- // three-slicer/viewer/gcode — G-code text -> kernel-shaped layer stream (renderable without slicing).
2
- // Feed the returned layers straight into buildSegmentData (three-slicer/viewer/toolpath).
1
+ // three-slicer-viewer/gcode — G-code text -> kernel-shaped layer stream (renderable without slicing).
2
+ // Feed the returned layers straight into buildSegmentData (three-slicer-viewer/toolpath).
3
3
 
4
4
  export interface GcodeLayer {
5
5
  z: number
@@ -1,5 +1,4 @@
1
1
  // three-slicer-viewer — kernel layers -> GPU-instanced toolpath.
2
- // Also published as three-slicer/viewer/toolpath, which re-exports this package.
3
2
  // Does not import three: makeToolpath takes the THREE namespace as an argument (guaranteeing a single instance).
4
3
 
5
4
  /** The 24 triangle indices of the 8-vertex diamond cross-section (from SegmentTemplate.cpp:18) */
package/types/viewer.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // three-slicer/viewer
1
+ // three-slicer-viewer
2
2
  import type * as React from 'react'
3
3
  import type { SlicerSettings } from './settings-keys.d.ts'
4
4
  import type { ProcessPresetsApi, FilamentPresetsApi } from './settings.d.ts'
@@ -114,8 +114,8 @@ export interface ViewportProps {
114
114
  */
115
115
  slicer?: (deps: Record<string, unknown>) => SlicerHandle
116
116
  /**
117
- * The vendor preset catalog. This package ships none — nothing passed means empty pickers. `three-slicer/viewer`
118
- * passes its bundled OrcaSlicer extraction; a host with its own fleet passes its own (a partial object is
117
+ * The vendor preset catalog. This package ships none — nothing passed means empty pickers. `three-slicer/settings`'
118
+ * `bundledCatalog` is the OrcaSlicer extraction; a host with its own fleet passes its own (a partial object is
119
119
  * merged over the empty catalog).
120
120
  */
121
121
  catalog?: Catalog | null
@@ -254,5 +254,5 @@ export function resolveCatalog(catalog?: Partial<Catalog> | null): Catalog
254
254
  export function useNoopSlicer(): SlicerHandle
255
255
 
256
256
  /** The slicing hook, unbound. `deps.makeWorker` must return a Worker speaking this package's slice protocol —
257
- * three-slicer/viewer supplies its WASM kernel worker and re-exports the bound hook. */
257
+ * three-slicer/client's makeSlicerWorker is the WASM kernel's. */
258
258
  export function useSlicer(deps: Record<string, unknown> & { makeWorker: () => Worker }): SlicerHandle