three-slicer 0.1.6 → 0.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +310 -22
  3. package/THIRD-PARTY-NOTICES.md +37 -0
  4. package/components/README.md +2 -0
  5. package/components/dist/SettingsPanel.js +92 -92
  6. package/data/config-schema.json +698 -0
  7. package/data/filaments.js +1 -1
  8. package/data/preset-keys.json +1 -0
  9. package/data/printers.json +1 -1
  10. package/data/processes.js +1 -1
  11. package/data/ui-tree.json +342 -0
  12. package/engine/README.md +279 -3
  13. package/engine/examples/sla_headless.mjs +51 -0
  14. package/engine/index.d.ts +77 -5
  15. package/engine/index.js +15 -4
  16. package/engine/src/client.js +165 -0
  17. package/engine/src/data.js +3 -0
  18. package/engine/src/preset_file.js +99 -0
  19. package/engine/src/settings.js +232 -0
  20. package/engine/src/sla_core.js +188 -0
  21. package/engine/src/sla_request.js +185 -0
  22. package/engine/src/slicer.worker.js +76 -4
  23. package/engine/src/slicer_core.js +0 -0
  24. package/engine/src/slicer_core.mt.js +0 -0
  25. package/package.json +26 -11
  26. package/types/client.d.ts +88 -0
  27. package/types/components.d.ts +10 -0
  28. package/types/data/preset-keys.json.d.ts +20 -0
  29. package/types/data/printers.json.d.ts +2 -2
  30. package/types/data.d.ts +4 -0
  31. package/types/settings-keys.d.ts +107 -1
  32. package/types/settings.d.ts +116 -1
  33. package/types/sla.d.ts +167 -0
  34. package/types/viewer.d.ts +59 -1
  35. package/types/worker.d.ts +95 -0
  36. package/viewer/README.md +163 -3
  37. package/viewer/dist/Viewport.js +3633 -3137
  38. package/viewer/dist/model_loaders.js +1 -1
  39. package/viewer/dist/parse_3mf-C2X_W442.js +300 -0
  40. package/viewer/dist/parse_3mf.worker.js +1 -1
  41. package/viewer/styles.css +5 -0
  42. package/types/gen_settings_types.mjs +0 -70
  43. package/viewer/dist/parse_3mf-8oCnCJNK.js +0 -216
@@ -0,0 +1,51 @@
1
+ // three-slicer SLA (resin) headless example: slice a cube with supports + pad through the public API,
2
+ // then show the typed capability refusal — an unsupported request errors, it never approximates.
3
+ // run: node packages/engine/examples/sla_headless.mjs
4
+ import { createSlicer } from 'three-slicer'
5
+ import { deriveSlaParams, printerTechnology } from 'three-slicer/settings'
6
+
7
+ // minimal binary-STL writer for a 10mm cube. Winding matters here, unlike the FFF example: the SLA
8
+ // support generator reads facet normals to find downward faces, so every face is CCW seen from outside.
9
+ function boxSTL() {
10
+ const vertices = [[-5,-5,0],[5,-5,0],[5,5,0],[-5,5,0],[-5,-5,10],[5,-5,10],[5,5,10],[-5,5,10]]
11
+ const faces = [[0,2,1],[0,3,2],[4,5,6],[4,6,7],[0,1,5],[0,5,4],[1,2,6],[1,6,5],[2,3,7],[2,7,6],[3,0,4],[3,4,7]]
12
+ const buf = Buffer.alloc(84 + faces.length * 50)
13
+ buf.writeUInt32LE(faces.length, 80)
14
+ faces.forEach((face, i) => {
15
+ let off = 84 + i * 50 + 12
16
+ for (const vi of face) for (const v of vertices[vi]) { buf.writeFloatLE(v, off); off += 4 }
17
+ })
18
+ return buf
19
+ }
20
+ const cube = boxSTL()
21
+
22
+ // A sparse settings map, the same shape the UI holds. printer_technology is what routes to slice_sla.
23
+ const settings = {
24
+ printer_technology: 'SLA',
25
+ layer_height: 0.05,
26
+ exposure_time: 7,
27
+ supports_enable: true,
28
+ support_object_elevation: 5,
29
+ pad_enable: true,
30
+ }
31
+ console.log(`technology: ${printerTechnology(settings)}`)
32
+
33
+ const slicer = await createSlicer()
34
+ const params = deriveSlaParams(settings) // fills display/support/pad defaults (SL1 reference machine)
35
+
36
+ // (a) SLA slice — no G-code; masks, meshes and resin stats instead.
37
+ const r = slicer.sliceSla(cube, params)
38
+ if (r.error) { console.error('FAIL', r.error); process.exit(1) }
39
+ console.log(`sla : layers=${r.stats.layers} (lift=${r.stats.lift_layers}) points=${r.stats.support_points}`
40
+ + ` pad_layers=${r.stats.pad_layers} resin=${r.stats.resin_ml.toFixed(2)}ml`)
41
+ console.log(`meshes: support=${r.support_mesh.length / 9} tris, pad=${r.pad_mesh.length / 9} tris`)
42
+
43
+ // (b) the capability gate: hollowing is not ported, so the request is REFUSED with a stable code —
44
+ // a solid slice answered to a hollow request would be a mislabeled print.
45
+ const refused = slicer.sliceSla(cube, deriveSlaParams({ ...settings, hollowing_enable: true }))
46
+ console.log(`gate : ${refused.error}`)
47
+
48
+ if (r.stats.support_points > 0 && r.stats.pad_layers > 0 && /^SLA_UNSUPPORTED_HOLLOWING/.test(refused.error ?? ''))
49
+ console.log('OK — SLA slices headlessly via the public API')
50
+ else { console.error('FAIL'); process.exit(1) }
51
+ slicer.dispose()
package/engine/index.d.ts CHANGED
@@ -6,22 +6,94 @@ export interface SliceCallbacks {
6
6
  onProgress?: (done: number, total: number) => void
7
7
  onLayer?: (layer: SliceLayer) => void
8
8
  }
9
+ /** The estimator's machine limits, as they were actually applied (`stats.machine_limits`). */
10
+ export interface SliceMachineLimits {
11
+ max_speed_xy: number; max_speed_z: number; max_speed_e: number
12
+ max_accel_xy: number; max_accel_z: number; max_accel_e: number
13
+ jerk_xy: number; jerk_z: number; jerk_e: number
14
+ accel_print: number; accel_travel: number; accel_retract: number
15
+ }
16
+
9
17
  export interface SliceStats {
10
- layers: number; model_layers: number; raft_layers: number;
11
- path_segments: number; filament_mm: number; time_estimate: number;
12
- streamed?: boolean; economy?: boolean; [k: string]: unknown
18
+ /** Emitted layers, raft included — the length of `result.layers` */
19
+ layers: number
20
+ /** Layers belonging to the model itself */
21
+ model_layers: number
22
+ raft_layers: number
23
+ path_segments: number
24
+ /** Filament consumed, mm of input filament */
25
+ filament_mm: number
26
+ /** Wall-crossing travels — the cross-check for `reduce_crossing_wall` */
27
+ wall_crossings: number
28
+
29
+ /** Seconds. `0` in economy mode, which skips the estimate */
30
+ time_estimate: number
31
+ first_layer_time: number
32
+ time_extrude: number
33
+ time_travel: number
34
+ /** Filament total the estimator parsed back out of the G-code — compare against `filament_mm` */
35
+ time_filament_mm: number
36
+ time_moves: number
37
+ /** Seconds per layer index */
38
+ layer_times: Record<string, number>
39
+ /** Seconds per extrusion role, keyed by the role number (the low nibble of `paths[k+3]`) */
40
+ role_times: Record<string, number>
41
+ /** Which estimator ran: the ported GCodeProcessor, the transcription, or neither */
42
+ time_engine: 'full' | 'transcribed' | 'fallback' | string
43
+ machine_limits: SliceMachineLimits
44
+
45
+ /** True when anything printed leaves the bed — support, skirt, brim and raft included */
46
+ over_bed: boolean
47
+ /** True when the MODEL itself is off the bed, not just what was printed around it */
48
+ over_bed_model: boolean
49
+ /** How far past the bed the toolpaths reach, mm. `0` = inside */
50
+ over_bed_x: number; over_bed_y: number
51
+ /** Emitted top layer past `printable_height`, mm */
52
+ over_bed_z: number
53
+
54
+ /** True when G-code and layers were delivered through `onLayer` and are NOT on the result */
55
+ streamed: boolean
56
+ /** True when it finished in economy mode: no preview toolpaths and no time estimate */
57
+ economy: boolean
58
+
59
+ // ---- multi-material only (absent from a single-material slice) ----
60
+ /** Indexed by tool number; sums to `filament_mm` */
61
+ filament_mm_by_tool?: number[]
62
+ /** The prime/wipe tower's share — already counted inside the per-tool figures above */
63
+ filament_mm_purge?: number
64
+ filament_mm_purge_by_tool?: number[]
65
+
66
+ // ---- phase timings, present when the kernel was built with them ----
67
+ t_pass1_ms?: number; t_surface_ms?: number; t_support_ms?: number; t_emit_ms?: number
68
+ /** The JS boundary's share during emit */
69
+ t_flush_ms?: number
70
+
71
+ [k: string]: unknown
72
+ }
73
+
74
+ export interface SliceResult {
75
+ /** Absent when `onLayer` was set — assemble it from the callback instead */
76
+ gcode?: string
77
+ stats: SliceStats
78
+ /** Absent when `onLayer` was set */
79
+ layers?: unknown[]
80
+ /** Set instead of a result when the slice failed or was cancelled (`'canceled'`) */
81
+ error?: string
13
82
  }
14
- export interface SliceResult { gcode?: string; stats: SliceStats; layers?: unknown[]; error?: string }
15
83
  export interface PaintArgs {
16
84
  facet: number; hx: number; hy: number; hz: number;
17
85
  cx: number; cy: number; cz: number; radius: number; enforcer: boolean
18
86
  }
19
87
  export interface Slicer {
20
88
  slice(stl: ArrayBuffer | Uint8Array, params: object | string, cb?: SliceCallbacks): SliceResult
89
+ /** SLA (resin) slice — no G-code: mask segment streams per layer, support/pad meshes, resin stats.
90
+ * Params from deriveSlaParams(); a refused capability (e.g. hollowing) sets `error` to its typed code. */
91
+ sliceSla(stl: ArrayBuffer | Uint8Array, params: object | string, onProgress?: (done: number, total: number) => void): SliceResult
21
92
  paintPrepare(stl: ArrayBuffer | Uint8Array): number
22
93
  paint(args: PaintArgs): { enf: number; blk: number }
23
94
  paintClear(): void
24
- overlay(enforcer: boolean): number[]
95
+ /** The painted overlay triangles for one state, as the kernel's own detached copy. */
96
+ overlay(enforcer: boolean): Float32Array
25
97
  heapSize(): number
26
98
  module: unknown
27
99
  dispose(): void
package/engine/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // three-slicer — browser/WASM 3D-slicing SDK (reverse-engineered from OrcaSlicer).
2
- // Public API. Node-safe (imports only the pure-JS WASM factory; no JSON so it loads in plain Node ESM).
2
+ // Public API. Node-safe (loads only the pure-JS WASM factory; no JSON so it loads in plain Node ESM).
3
3
  // Schema-driven helpers (deriveKernelParams etc.) live in the "three-slicer/settings" subpath because
4
4
  // they import JSON — use them in a bundler (vite) or with Node JSON import attributes.
5
- import createModule from './src/slicer_core.js'
6
5
 
7
6
  const u8 = (b) => (b instanceof Uint8Array ? b : new Uint8Array(b))
8
7
 
@@ -21,7 +20,10 @@ export const engineWorkerURL = () => new URL('./src/slicer.worker.js', import.me
21
20
  // paintPrepare/paint/paintClear/overlay: manual support enforcer/blocker painting (TriangleSelector).
22
21
  // heapSize(): current WASM heap bytes (peak, monotonic). module: escape hatch. dispose(): drop the module.
23
22
  export async function createSlicer() {
24
- const M = await createModule()
23
+ // Loaded here rather than at module scope so the 3.5MB emscripten glue is a separate chunk: a host that imports
24
+ // this module only for engineWorkerURL() — the recommended browser path, where the kernel runs in the worker —
25
+ // used to pull the whole thing into its bundle for a function that returns a URL.
26
+ const M = await (await import('./src/slicer_core.js')).default()
25
27
  return {
26
28
  slice(stl, params, { onProgress, onLayer } = {}) {
27
29
  const p = typeof params === 'string' ? params : JSON.stringify(params || {})
@@ -29,13 +31,22 @@ export async function createSlicer() {
29
31
  try { return M.slice(u8(stl), p, onProgress || (() => {})) }
30
32
  finally { if (onLayer) M.clear_layer_sink() }
31
33
  },
34
+ // SLA (resin) slice: no G-code — layers carry the mask segment stream, support_mesh/pad_mesh the preview
35
+ // soups, stats the resin figures and lift_layers. Same params-object-or-string contract as slice().
36
+ sliceSla(stl, params, onProgress) {
37
+ const p = typeof params === 'string' ? params : JSON.stringify(params || {})
38
+ return M.slice_sla(u8(stl), p, onProgress || (() => {}))
39
+ },
32
40
  paintPrepare(stl) { M.selector_prepare(u8(stl)); return M.selector_facet_count() },
33
41
  paint(a) {
34
42
  M.selector_paint(a.facet, a.hx, a.hy, a.hz, a.cx, a.cy, a.cz, a.radius, a.enforcer)
35
43
  return { enf: M.selector_painted_count(true), blk: M.selector_painted_count(false) }
36
44
  },
37
45
  paintClear() { M.selector_clear() },
38
- overlay(enforcer) { return Array.from(M.selector_overlay(!!enforcer)) },
46
+ // Returned as the kernel's own Float32Array. It is already a detached copy (bindings.cpp to_f32 slices the
47
+ // memory view), and the Array.from() this used to do cost 1.1ms of a measured 3.6ms stroke on a 40k-triangle
48
+ // mesh — the same conversion the worker dropped for the same reason (slicer.worker.js, 'overlay').
49
+ overlay(enforcer) { return M.selector_overlay(!!enforcer) },
39
50
  heapSize() { return M.heap_size() },
40
51
  module: M,
41
52
  dispose() { /* emscripten module is GC'd when dropped; no explicit teardown required */ },
@@ -0,0 +1,165 @@
1
+ // three-slicer/client — a promise wrapper over the slice worker's message protocol.
2
+ //
3
+ // Why it exists: the worker is the recommended way to slice in a browser (the kernel blocks its thread for
4
+ // seconds), but driving it meant hand-writing a `cmd` string protocol, assembling the streamed layers, knowing
5
+ // that a slice request is the one message with NO cmd and that its params must already be a JSON string, and
6
+ // reaching into a SharedArrayBuffer at an offset the worker posts once to cancel anything. All of that lived in
7
+ // the viewer and was not published.
8
+ //
9
+ // What it deliberately does NOT do: the viewer's progress weighting (its 15/5/40/40 phase budget is measured
10
+ // against ITS models) and its out-of-memory retry ladder (re-slicing simplified, then in economy mode). Those are
11
+ // UI policy — a host wanting them should own them, and lifting them here would make this a copy of the viewer.
12
+ //
13
+ // Requests are answered in order. The worker's onmessage handler processes one message at a time and each command
14
+ // replies exactly once, so a FIFO of pending requests is enough to match a reply to its caller — the protocol
15
+ // carries no request ids and does not need them.
16
+ import { engineWorkerURL } from '../index.js'
17
+ import { parseSlaJob, SlaRequestError } from './sla_request.js'
18
+
19
+ export { SLA_CAPABILITIES, SLA_JOB_VERSION, SlaRequestError } from './sla_request.js'
20
+
21
+ // Which reply type ends which command. A slice has no cmd (that is what selects slicing) and ends on 'done'.
22
+ const REPLY_OF = {
23
+ warmup: 'warm', prepare: 'prepared', paint: 'painted', erase: 'painted', clear: 'painted',
24
+ importPaint: 'painted', exportPaint: 'paintExport', overlay: 'overlay', slice: 'done', sla: 'done',
25
+ }
26
+
27
+ const asBuffer = (data) => (data instanceof ArrayBuffer ? data : data?.buffer ?? data)
28
+ const slaTransferables = (job) => job.objects.flatMap(object => [object, ...(object.modifierVolumes ?? [])])
29
+ .map(record => asBuffer(record.stl)).filter(buffer => buffer instanceof ArrayBuffer)
30
+
31
+ /**
32
+ * @param {Worker} [worker] an existing worker to drive. Omit to create one — which only works in a browser,
33
+ * since Node has no global Worker; a Node caller should use createSlicer() from the root entry instead.
34
+ */
35
+ export function createSlicerClient(worker = new Worker(engineWorkerURL(), { type: 'module' })) {
36
+ const pending = [] // FIFO of {expect, resolve, reject, onProgress, onLayer, chunks, layers}
37
+ let cancelFlag = null // Uint32Array over the worker's SharedArrayBuffer — mt kernel only
38
+ let closed = false
39
+
40
+ worker.addEventListener('message', ({ data }) => {
41
+ // Unprompted and not a reply to anything: the addresses of the progress counter and the cancel flag.
42
+ if (data.type === 'supsab') {
43
+ try { if (data.cancelPtr) cancelFlag = new Uint32Array(data.buf, data.cancelPtr, 1) } catch { /* not shared */ }
44
+ return
45
+ }
46
+ const head = pending[0]
47
+ if (!head) return
48
+ if (data.type === 'progress') { head.onProgress?.(data.done, data.total); return }
49
+ if (data.type === 'layer') {
50
+ head.onLayer?.({ z: data.z, idx: data.idx, gcode: data.gcode, paths: data.paths, widths: data.widths })
51
+ // Accumulated only when the caller did not take the layers itself — otherwise a streamed slice would hold
52
+ // the whole model in memory here, which is the one thing streaming exists to avoid.
53
+ if (!head.onLayer) { if (data.gcode) head.chunks.push(data.gcode); head.layers.push(data) }
54
+ return
55
+ }
56
+ pending.shift()
57
+ if (data.type === 'error') {
58
+ head.reject(data.code ? new SlaRequestError(data.code, data.error) : new Error(data.error)); return
59
+ }
60
+ if (data.type !== head.expect) { head.reject(new Error(`expected '${head.expect}', got '${data.type}'`)); return }
61
+ // What the layer messages accumulated, if this caller did not take them itself.
62
+ if (data.type === 'done' && head.layers.length) data.assembled = { gcode: head.chunks.join(''), layers: head.layers }
63
+ head.resolve(data)
64
+ })
65
+ worker.addEventListener('error', (event) => {
66
+ const head = pending.shift()
67
+ head?.reject(new Error(event.message || 'worker failed'))
68
+ })
69
+
70
+ const send = (message, { transfer = [], onProgress, onLayer } = {}) => new Promise((resolve, reject) => {
71
+ if (closed) { reject(new Error('slicer client terminated')); return }
72
+ pending.push({ expect: REPLY_OF[message.cmd ?? 'slice'], resolve, reject, onProgress, onLayer, chunks: [], layers: [] })
73
+ worker.postMessage(message, transfer)
74
+ })
75
+
76
+ const brush = (cmd, args) => send({ ...args, cmd }).then(reply => ({
77
+ enforcer: reply.enf, blocker: reply.blk, counts: reply.counts, applied: reply.applied,
78
+ }))
79
+
80
+ return {
81
+ worker,
82
+
83
+ /** Load the kernel ahead of the first slice, so the first one is not also a 4MB download. */
84
+ warmup: () => send({ cmd: 'warmup' }).then(() => undefined),
85
+
86
+ /**
87
+ * Slice. `params` may be an object — it is stringified here, which the raw protocol does not do for you.
88
+ * With `onLayer` the layers are yours and the result carries stats only; without it they are assembled and
89
+ * the result looks like the direct handle's.
90
+ */
91
+ async slice(stl, params, { onProgress, onLayer } = {}) {
92
+ const buffer = asBuffer(stl)
93
+ const entry = { stl: buffer, params: typeof params === 'string' ? params : JSON.stringify(params ?? {}) }
94
+ const reply = await send(entry, { transfer: [buffer], onProgress, onLayer })
95
+ const result = reply.result ?? {}
96
+ // A streamed slice returns stats only, with the content delivered as `layer` messages. Unless the caller
97
+ // took those itself, hand back the same shape a batch slice produces.
98
+ if (!reply.assembled) return result
99
+ return { ...result, gcode: reply.assembled.gcode, layers: reply.assembled.layers }
100
+ },
101
+
102
+ /**
103
+ * Legacy single-object SLA slice: per-layer closed contours instead of toolpaths. It uses WASM when available;
104
+ * its contour-only JS fallback rejects requested supports and pads. Same streaming shape as slice(): `onLayer` hands you the layers and the result
105
+ * carries stats only; without it they are assembled onto the result.
106
+ */
107
+ async sliceSla(stl, params, { onProgress, onLayer } = {}) {
108
+ const buffer = asBuffer(stl)
109
+ const entry = { cmd: 'sla', stl: buffer, params: typeof params === 'string' ? params : JSON.stringify(params ?? {}) }
110
+ const reply = await send(entry, { transfer: [buffer], onProgress, onLayer })
111
+ const result = reply.result ?? {}
112
+ if (!reply.assembled) return result
113
+ return { ...result, layers: reply.assembled.layers }
114
+ },
115
+
116
+ async sliceSlaJob(job, { onProgress, onLayer } = {}) {
117
+ const parsed = parseSlaJob(job)
118
+ const reply = await send({ cmd: 'slaJob', job: parsed }, { transfer: slaTransferables(parsed), onProgress, onLayer })
119
+ const result = reply.result ?? {}
120
+ if (!reply.assembled) return result
121
+ return { ...result, layers: reply.assembled.layers }
122
+ },
123
+
124
+ /** Register a mesh for painting. `keepPaint` carries existing marks across a move. */
125
+ prepare: (stl, { keepPaint = false } = {}) => {
126
+ const buffer = asBuffer(stl)
127
+ return send({ cmd: 'prepare', stl: buffer, keepPaint }, { transfer: [buffer] })
128
+ .then(reply => ({ facets: reply.facets, kept: reply.kept }))
129
+ },
130
+
131
+ /** Paint. `{state: 1..16}` addresses any extruder; `{enforcer: false}` is the legacy blocker brush. */
132
+ paint: (args) => brush('paint', args),
133
+ /** Return the brushed facets to the default extruder. Not `paint` with state 0 — see types/worker.d.ts. */
134
+ erase: (args) => brush('erase', args),
135
+ clear: (states) => brush('clear', states ? { states } : {}),
136
+
137
+ overlay: (states) => send({ cmd: 'overlay', ...(states ? { states } : {}) })
138
+ .then(reply => ({ enforcer: reply.enf, blocker: reply.blk, overlays: reply.overlays })),
139
+
140
+ /** Load a 3mf's painting. Replaces every mark, so run it directly after `prepare`. */
141
+ importPaint: (facets, hex, states) => brush('importPaint', { facets, hex, ...(states ? { states } : {}) }),
142
+ exportPaint: () => send({ cmd: 'exportPaint' })
143
+ .then(reply => ({ supported: reply.supported, facets: reply.facets, hex: reply.hex })),
144
+
145
+ /**
146
+ * Stop the running slice. The kernel polls this flag from inside its C++ loop, so it works while the worker
147
+ * is blocked in WASM — but the flag lives in a SharedArrayBuffer, so it exists only on the multithreaded
148
+ * kernel, i.e. on a cross-origin-isolated page. Returns false when there is nothing to write to; terminate
149
+ * the worker instead.
150
+ */
151
+ cancel() {
152
+ if (!cancelFlag) return false
153
+ try { Atomics.store(cancelFlag, 0, 1) } catch { cancelFlag[0] = 1 }
154
+ return true
155
+ },
156
+
157
+ terminate() {
158
+ closed = true
159
+ while (pending.length) pending.shift().reject(new Error('slicer client terminated'))
160
+ worker.terminate()
161
+ },
162
+ }
163
+ }
164
+
165
+ export default createSlicerClient
@@ -11,6 +11,9 @@ export { default as uiTree } from 'three-slicer/data/ui-tree.json' with { type:
11
11
  export { default as toggleRules } from 'three-slicer/data/toggle-rules.json' with { type: 'json' }
12
12
  export { default as invalidationMap } from 'three-slicer/data/invalidation-map.json' with { type: 'json' }
13
13
  export { default as printers } from 'three-slicer/data/printers.json' with { type: 'json' }
14
+ // Which option keys belong to which preset type, straight from upstream's Preset.cpp. Small (17KB) and needed by
15
+ // anything that writes or reads a preset FILE, so it is static rather than lazy like the two big catalogs.
16
+ export { default as presetKeys } from 'three-slicer/data/preset-keys.json' with { type: 'json' }
14
17
  // processes is the largest artifact (~800 KB) and is only needed once a printer is picked, so it stays a dynamic
15
18
  // import. It is generated as a JS module rather than JSON precisely because of the attribute problem above, in its
16
19
  // other direction: a dynamic JSON import needs the attribute in Node, and that same attribute makes browsers
@@ -0,0 +1,99 @@
1
+ // OrcaSlicer preset files — the `.json` its "Config files (*.json;…)" import accepts, and that its
2
+ // "Export preset" writes. One file holds one preset of one type: machine, process or filament.
3
+ //
4
+ // The value encoding is the same one `project_settings.config` uses, and for the same reason: upstream writes
5
+ // every option through ConfigBase::save_to_json, which serializes each one to a string (a bool is "1"/"0", a point
6
+ // is "XxY", a vector becomes an array of strings). So normalize/serializeProjectSettings are reused verbatim
7
+ // rather than reimplemented — a second copy of that coercion table is exactly how the two would drift.
8
+ //
9
+ // Zero dependencies, so this lives in the engine and works headless. The zip bundle formats (.orca_printer) need
10
+ // a deflate and therefore live in the viewer, which already has one.
11
+ import { schema, presetKeys } from './data.js'
12
+ import { normalizeProjectSettings, serializeProjectSettings } from './settings.js'
13
+
14
+ /** `machine` for printers, `process` for print settings, `filament` for materials — upstream's own `type` field. */
15
+ const KEY_LIST = { machine: 'printer', process: 'process', filament: 'filament' }
16
+
17
+ // Preset bookkeeping, not settings. Upstream writes six of these; `inherits` is read (it names the parent) and the
18
+ // rest are carried only so a round trip does not lose them.
19
+ const ENVELOPE = new Set(['type', 'name', 'from', 'version', 'setting_id', 'instantiation', 'inherits',
20
+ 'filament_id', 'is_custom_defined', 'filament_settings_id', 'print_settings_id', 'printer_settings_id'])
21
+
22
+ /** Every option key that belongs in a preset of this type. */
23
+ export function presetOptionKeys(type) {
24
+ const list = presetKeys[KEY_LIST[type]]
25
+ if (!list) throw new Error(`unknown preset type '${type}' (expected machine, process or filament)`)
26
+ return list
27
+ }
28
+
29
+ /**
30
+ * A settings map -> the object an OrcaSlicer preset `.json` holds.
31
+ *
32
+ * Written **flattened**: no `inherits`, every key present. That is the whole difference between a file another
33
+ * program can read on its own and one that only means something next to the vendor database it was derived from —
34
+ * upstream's own exports are diffs against a parent (`Preset::save`: "only save difference if it has parent"),
35
+ * which is fine for OrcaSlicer reading its own files back and useless for anyone else.
36
+ *
37
+ * `complete: false` writes only the keys the settings map actually holds, for a diff-shaped file.
38
+ */
39
+ export function writePresetFile(settings, { type = 'machine', name = 'Custom', complete = true, version = '1.0.0.0' } = {}) {
40
+ const keys = presetOptionKeys(type)
41
+ const picked = {}
42
+ for (const key of keys) {
43
+ // `inherits` is in upstream's printer_options list — it is an option to OrcaSlicer's config machinery, but it
44
+ // is bookkeeping here, and writing it would put an empty parent name in a file meant to stand alone. The same
45
+ // ENVELOPE set the reader strips is what the writer refuses to emit, so the two cannot disagree about it.
46
+ if (ENVELOPE.has(key)) continue
47
+ if (settings && key in settings && settings[key] != null) picked[key] = settings[key]
48
+ // A key with no schema default cannot be filled — there is nothing to fill it with. `complete` means "every
49
+ // option that HAS a value", not "every option name", and inventing one would be worse than leaving it out.
50
+ else if (complete && schema[key]?.default !== undefined) picked[key] = schema[key].default
51
+ }
52
+ return {
53
+ type, name, from: 'User', version, instantiation: 'true',
54
+ ...serializeProjectSettings(picked),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The inverse: a parsed preset `.json` -> a settings map this package accepts.
60
+ *
61
+ * `inherits` is the trap. Upstream stores a derived preset as the DIFF against its parent, so a vendor file on its
62
+ * own carries a fraction of the preset — the Bambu X1C machine json inherits `fdm_bbl_3dp_001_common` and lists
63
+ * about twenty keys. Resolution therefore needs a parent lookup, and this package has no full preset database:
64
+ * `resolveParent(name)` is the hook, and `printerSettings` covers the machine case for the keys the kernel reads.
65
+ *
66
+ * When the parent cannot be resolved the file's own values are still applied and `missingParent` names it —
67
+ * dropping a whole file because one name is unknown loses more than it protects, and the caller can decide.
68
+ */
69
+ export function readPresetFile(raw, { resolveParent } = {}) {
70
+ if (!raw || typeof raw !== 'object') throw new Error('not a preset object')
71
+ const type = raw.type
72
+ if (!KEY_LIST[type]) throw new Error(`not an OrcaSlicer preset: type is ${JSON.stringify(type)}`)
73
+
74
+ const own = {}
75
+ for (const [key, value] of Object.entries(raw)) if (!ENVELOPE.has(key)) own[key] = value
76
+ const { settings, applied, skipped } = normalizeProjectSettings(own)
77
+
78
+ // The parent goes UNDER the file's own values — the file is the diff, so it wins.
79
+ let missingParent = null
80
+ let merged = settings
81
+ if (raw.inherits) {
82
+ const parent = resolveParent?.(raw.inherits, type) ?? null
83
+ if (parent) merged = { ...parent, ...settings }
84
+ else missingParent = raw.inherits
85
+ }
86
+
87
+ return {
88
+ settings: merged,
89
+ type,
90
+ name: typeof raw.name === 'string' ? raw.name : '',
91
+ inherits: raw.inherits ?? null,
92
+ missingParent,
93
+ applied,
94
+ skipped,
95
+ }
96
+ }
97
+
98
+ /** Convenience: the JSON text an export writes. */
99
+ export const presetFileText = (settings, options) => JSON.stringify(writePresetFile(settings, options), null, 1)