partforge 0.133.3 → 0.135.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 (37) hide show
  1. package/docs/AUTHORING-PARTS.md +80 -19
  2. package/docs/KERNEL-CONTRACT.md +1 -1
  3. package/package.json +1 -1
  4. package/src/app-materials.js +3 -1
  5. package/src/framework/export/bundle.js +413 -0
  6. package/src/framework/export/drawing.js +356 -0
  7. package/src/framework/export/dxf.js +150 -0
  8. package/src/framework/export/formats.js +142 -0
  9. package/src/framework/export/layout.js +92 -0
  10. package/src/framework/export/placement.js +14 -0
  11. package/src/framework/export/readme.js +103 -0
  12. package/src/framework/export/svg.js +97 -0
  13. package/src/framework/export-controller.js +7 -2
  14. package/src/framework/export-rows.js +34 -0
  15. package/src/framework/jobs.js +16 -0
  16. package/src/framework/lint/rules-materials.js +9 -2
  17. package/src/framework/materials/assets/SOURCES.md +10 -4
  18. package/src/framework/materials/assets/pattern-plywood-color.jpg +0 -0
  19. package/src/framework/materials/assets/pattern-plywood-normal.jpg +0 -0
  20. package/src/framework/materials/assets/pattern-plywood-rough.jpg +0 -0
  21. package/src/framework/materials/assets.js +3 -0
  22. package/src/framework/materials/patterns.js +103 -0
  23. package/src/framework/materials/physical.js +5 -2
  24. package/src/framework/materials/presets.js +11 -5
  25. package/src/framework/materials/resolve.js +4 -2
  26. package/src/framework/materials/sheet-look.js +204 -0
  27. package/src/framework/mount.js +74 -37
  28. package/src/framework/process/exporters.js +18 -0
  29. package/src/framework/process/laser/export.js +53 -0
  30. package/src/framework/sheet/part.js +32 -17
  31. package/src/framework/viewer.js +53 -29
  32. package/src/index.js +4 -0
  33. package/src/parts/laser-box.js +1 -1
  34. package/src/parts/material-swatches.js +58 -2
  35. package/types/index.d.ts +72 -5
  36. package/types/part.d.ts +4 -0
  37. package/types/testing.d.ts +7 -3
@@ -9,6 +9,11 @@
9
9
  // `sp.sheet.generatedBuild` is the build made here; an author who replaces `build`
10
10
  // afterwards is detectable as `sp.build !== sp.sheet.generatedBuild`.
11
11
  //
12
+ // Likewise `sp.sheet.generatedPlace` is the place made here (the pose, then the author's
13
+ // own) and `sp.sheet.place` the author's own place, or null: the realistic look
14
+ // (materials/sheet-look.js) trusts a sheet's canonical frame only with no author place
15
+ // and `sp.place === sp.sheet.generatedPlace`.
16
+ //
12
17
  // Placement (decision 10): the pose applies for display AND export, then the author's
13
18
  // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
14
19
  import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
@@ -67,32 +72,42 @@ export function sheetPart(spec) {
67
72
  const s = resolveSheet(k, sub, p, d);
68
73
  return (processById(s.process).preview ?? sheetPreview)(k, s);
69
74
  };
75
+
76
+ // Placement (decision 10): the pose applies for display AND export, then the author's own
77
+ // place. Made BEFORE the record is frozen, so the record can name it. Everything the
78
+ // place reads is taken NOW, into the same values the record freezes — never read off
79
+ // `spec` later, which an author may reuse and change for the next panel: the build and
80
+ // the burn's frame both read the frozen record, and a place that read the spec live would
81
+ // move the panel away from them.
82
+ const pose = spec.pose ?? null;
83
+ const thickness = spec.thickness;
84
+ const authorPlace = spec.place ?? null;
85
+ const generatedPlace = pose !== null || authorPlace ? (solid, ctx) => {
86
+ const resolved = typeof pose === "function" ? pose(ctx.p, ctx.d) : pose;
87
+ let posed = solid;
88
+ if (resolved !== null) {
89
+ const reason = validatePose(resolved);
90
+ if (reason) throw new Error(`sheet pose: ${reason}`);
91
+ const t = typeof thickness === "function" ? thickness(ctx.p, ctx.d) : thickness;
92
+ posed = applyPose(solid, resolved, t);
93
+ }
94
+ return authorPlace ? authorPlace(posed, ctx) : posed;
95
+ } : null;
96
+
70
97
  const sheet = Object.freeze({
71
98
  process,
72
99
  material: spec.material,
73
- thickness: spec.thickness,
100
+ thickness,
74
101
  profile: spec.profile,
75
102
  score: spec.score ?? null,
76
103
  engrave: spec.engrave ?? null,
77
- pose: spec.pose ?? null,
104
+ pose,
105
+ place: authorPlace,
78
106
  generatedBuild,
107
+ generatedPlace,
79
108
  });
80
109
  sub.build = generatedBuild;
81
-
82
- const authorPlace = spec.place;
83
- if (sheet.pose !== null || authorPlace) {
84
- sub.place = (solid, ctx) => {
85
- const pose = typeof sheet.pose === "function" ? sheet.pose(ctx.p, ctx.d) : sheet.pose;
86
- let posed = solid;
87
- if (pose !== null) {
88
- const reason = validatePose(pose);
89
- if (reason) throw new Error(`sheet pose: ${reason}`);
90
- const t = typeof sheet.thickness === "function" ? sheet.thickness(ctx.p, ctx.d) : sheet.thickness;
91
- posed = applyPose(solid, pose, t);
92
- }
93
- return authorPlace ? authorPlace(posed, ctx) : posed;
94
- };
95
- }
110
+ if (generatedPlace) sub.place = generatedPlace;
96
111
  sub.sheet = sheet;
97
112
  return sub;
98
113
  }
@@ -12,6 +12,7 @@ import { ensureBoxUVs } from "./materials/uv.js";
12
12
  import { loadEnvironmentRig } from "./materials/environment.js";
13
13
  import { assetUrl } from "./materials/assets.js";
14
14
  import { resolveEnvironmentId } from "./materials/resolve.js";
15
+ import { burnsFor, realisticDisplay } from "./materials/sheet-look.js";
15
16
  import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
16
17
  import { createCutaway } from "./cutaway.js";
17
18
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
@@ -591,9 +592,11 @@ export function createViewer(container, part) {
591
592
  const textureCache = new Map(); // asset file name -> Texture
592
593
  const textureLoader = new THREE.TextureLoader();
593
594
  let pmrem = null;
594
- let printFrames = {};
595
- let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
596
- let printFramesStale = false; // the source has frames the materials have not seen
595
+ let printFrames = {}; // name -> mat4 array: layer lines' display → export map
596
+ let sheetFrames = {}; // name -> { frame, t, plies }: a burning sheet's canonical frame (sheet-look.js)
597
+ let frameSource = null; // () => { print, sheet }; mount's lazy provider (setFrameSource)
598
+ let framesStale = false; // the source has frames the materials have not seen
599
+ const IDENTITY_MAT4 = new THREE.Matrix4().toArray();
597
600
  const modeListeners = new Set();
598
601
  const envListeners = new Set();
599
602
  const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
@@ -728,9 +731,14 @@ export function createViewer(container, part) {
728
731
  function physicalFor(name) {
729
732
  let m = physicalMats.get(name);
730
733
  if (!m) {
731
- m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
734
+ const sp = part.parts[name];
735
+ // `burn` is decided once per sub-part (sheet-look.js burnsFor): only a laser sheet in
736
+ // a wood compiles the burn pass, so every other program is exactly what it was.
737
+ // realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
738
+ m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(sp), { printFrame: printFrames[name], burn: burnsFor(sp), loadTexture }));
732
739
  physicalMats.set(name, m);
733
740
  syncGrain(name);
741
+ applySheetFrame(m, sheetFrames[name]);
734
742
  }
735
743
  return m;
736
744
  }
@@ -859,7 +867,7 @@ export function createViewer(container, part) {
859
867
  // ground only moves on showAssembly, never because a capture happened.
860
868
  function enterRealistic(rig, { live = true, reground = true } = {}) {
861
869
  try {
862
- syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
870
+ syncFrames(); // before the swap: the first realistic frame has the export-pose layers and the sheets' burns
863
871
  renderMode = "realistic";
864
872
  // A regen can land between the proxy compile and here, while the mode
865
873
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
@@ -929,7 +937,7 @@ export function createViewer(container, part) {
929
937
  // target bound — each only for the synchronous compile() inside
930
938
  // compileAsync, so the view on screen is untouched while it waits.
931
939
  function compileRealistic(rig, { forCapture = false } = {}) {
932
- syncPrintFrames();
940
+ syncFrames();
933
941
  const proxy = new THREE.Scene();
934
942
  proxy.environment = rig.envMap;
935
943
  for (const n of names) {
@@ -1030,30 +1038,45 @@ export function createViewer(container, part) {
1030
1038
  for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
1031
1039
  }
1032
1040
 
1033
- // The lazy form mount uses. Computing a frame runs the sub-part's pose probe
1034
- // (two geometry-free builds for a placed sub-part), and every sub-part with
1035
- // no material draws layer lines, so frames are PULLED only when something is
1036
- // about to draw the realistic look: the live view (or a switch loading),
1037
- // and any capture that borrows it — compileRealistic and enterRealistic are
1038
- // the two doors every such path goes through. A delivery just marks them
1039
- // stale (invalidatePrintFrames); in CAD nothing is computed.
1040
- function setPrintFrameSource(source) {
1041
- printFrameSource = typeof source === "function" ? source : null;
1042
- invalidatePrintFrames();
1043
- }
1044
- function invalidatePrintFrames() {
1045
- printFramesStale = true;
1046
- if (renderMode === "realistic" || realisticPending) syncPrintFrames();
1047
- }
1048
- function syncPrintFrames() {
1049
- if (!printFrameSource || !printFramesStale) return;
1050
- printFramesStale = false;
1041
+ // A burning sheet's canonical frame (sheet-look.js sheetFrameFor). No frame — not yet
1042
+ // delivered, or not computable at these params — is pfSheetT = 0: plain wood.
1043
+ function applySheetFrame(m, f) {
1044
+ const u = m.userData.patternUniforms;
1045
+ if (!u?.pfSheetFrame) return;
1046
+ u.pfSheetFrame.value.fromArray(f?.frame ?? IDENTITY_MAT4);
1047
+ u.pfSheetT.value = f?.t ?? 0;
1048
+ u.pfPlies.value = f?.plies ?? 0;
1049
+ }
1050
+ function setSheetFrames(frames) {
1051
+ sheetFrames = frames ?? {};
1052
+ for (const [n, m] of physicalMats) applySheetFrame(m, sheetFrames[n]);
1053
+ }
1054
+
1055
+ // The lazy form mount uses. Frames are PULLED only when something is about to draw the
1056
+ // realistic look: the live view (or a switch loading), and any capture that borrows it —
1057
+ // compileRealistic and enterRealistic are the two doors every such path goes through. A
1058
+ // delivery just marks them stale (invalidateFrames); in CAD nothing is computed. One
1059
+ // source carries both kinds: `print` (layer lines; a placed sub-part's costs two
1060
+ // geometry-free probe builds, and every sub-part with no material draws layer lines) and
1061
+ // `sheet` (a burning sheet's canonical frame, from data).
1062
+ function setFrameSource(source) {
1063
+ frameSource = typeof source === "function" ? source : null;
1064
+ invalidateFrames();
1065
+ }
1066
+ function invalidateFrames() {
1067
+ framesStale = true;
1068
+ if (renderMode === "realistic" || realisticPending) syncFrames();
1069
+ }
1070
+ function syncFrames() {
1071
+ if (!frameSource || !framesStale) return;
1072
+ framesStale = false;
1051
1073
  let frames;
1052
- try { frames = printFrameSource(); } catch (e) {
1053
- console.warn("partforge: computing print frames failed", e);
1074
+ try { frames = frameSource(); } catch (e) {
1075
+ console.warn("partforge: computing material frames failed", e);
1054
1076
  return;
1055
1077
  }
1056
- setPrintFrames(frames);
1078
+ setPrintFrames(frames?.print);
1079
+ setSheetFrames(frames?.sheet);
1057
1080
  }
1058
1081
 
1059
1082
  // Resolves once the current environment's rig is loaded (captures wait on it).
@@ -2735,8 +2758,9 @@ export function createViewer(container, part) {
2735
2758
  isRealisticPending: () => realisticPending,
2736
2759
  onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2737
2760
  setPrintFrames,
2738
- setPrintFrameSource,
2739
- invalidatePrintFrames,
2761
+ setSheetFrames,
2762
+ setFrameSource,
2763
+ invalidateFrames,
2740
2764
  whenRealisticReady,
2741
2765
  dispose,
2742
2766
  }, () => requestRender(API_GRACE_MS));
package/src/index.js CHANGED
@@ -4,3 +4,7 @@
4
4
  // Part build functions import geometry helpers from "partforge/geometry" instead.
5
5
  export { mount } from "./framework/index.js";
6
6
  export { viewSubParts } from "./framework/part-model.js";
7
+ // The cut & print kit's options surface, for a host that draws its own options screen
8
+ // (partforge-cloud's coupling test reads KIT_OPTIONS_ERROR from here). export/formats.js
9
+ // is import-free, so this adds nothing to the app bundle but a few constants.
10
+ export { EXPORT_FORMATS, KIT_OPTIONS_ERROR, KIT_DEFAULTS, validateKitOptions } from "./framework/export/formats.js";
@@ -42,7 +42,7 @@ const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
42
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
43
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
44
 
45
- const PLY = { views: ["box"], display: { material: "oak" }, material: "birch plywood", thickness: (p) => p.t };
45
+ const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
46
46
  const panel = (name, label, extra = {}) => sheetPart({
47
47
  ...PLY, label,
48
48
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -1,7 +1,10 @@
1
1
  // Dev-only contact sheet for the material library: one 30 mm sample per preset,
2
2
  // laid out in a grid, so a change to a preset or the pattern shader can be
3
- // judged by eye in every environment (materials.html). Not a reference part.
3
+ // judged by eye in every environment (materials.html). The "Laser-cut" views
4
+ // (below) do the same for the laser burn. Not a reference part.
4
5
  import { PRESETS } from "../framework/materials/presets.js";
6
+ import { sheetPart } from "../framework/sheet/part.js";
7
+ import { sheetHole } from "../framework/sheet/joinery.js";
5
8
 
6
9
  const ids = Object.keys(PRESETS);
7
10
  const COLS = 6, PITCH = 40;
@@ -19,10 +22,59 @@ const FILAMENT_COLOURS = [
19
22
  const withHole = (k) =>
20
23
  k.box({ size: [30, 30, 12] }).fillet({ r: 3 }).cut(k.cylinder({ r: 5, h: 20 }).translate([0, 0, -4]));
21
24
 
25
+ // The "Laser-cut" view: sheet swatches as the laser burn draws them — each 30 × 30 with a
26
+ // cut hole, an engraved label (its thickness) and a score line — in every wood at 3, 6 and
27
+ // 9 mm; one oak panel standing on edge (posed, so its frame is not the identity); and the
28
+ // looks that must NOT burn: a clear-acrylic sheet and an oak block that is not a sheet part.
29
+ // "Laser-cut, unburnt" is its twin: the same swatches with an identity author `place`,
30
+ // which shows plain wood (materials/sheet-look.js) — same geometry, same place, same
31
+ // material — so the two views differ ONLY by the burn and scripts/capture-contact-sheet.mjs
32
+ // can diff them pixel for pixel — and one row of sheets that name no material, drawn as
33
+ // their stock (sheet-look.js STOCK_LOOKS).
34
+ const SW = 30;
35
+ const WOODS = ["plywood", "oak", "walnut"];
36
+ const CONTROLS_Y = -WOODS.length * PITCH;
37
+ const LASER_SWATCHES = [
38
+ ...WOODS.flatMap((material, row) => [3, 6, 9].map((t, col) => ({
39
+ key: `${material}_${t}`, label: `${PRESETS[material].label}, ${t} mm`, at: [col * PITCH, -row * PITCH], t, display: { material },
40
+ }))),
41
+ { key: "acrylic_3", label: "Clear acrylic, 3 mm", at: [0, CONTROLS_Y], t: 3, display: { material: "clear-acrylic" }, stock: "clear acrylic" },
42
+ { key: "oak_standing", label: "Oak, 3 mm, standing", at: [PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, standing: true },
43
+ { key: "oak_block", label: "Oak block (not a sheet part)", at: [2 * PITCH, CONTROLS_Y], t: 3, display: { material: "oak" }, block: true },
44
+ { key: "stock_plywood", label: "No material, \"birch plywood\" stock", at: [0, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood" },
45
+ { key: "stock_acrylic", label: "No material, \"clear acrylic\" stock", at: [PITCH, CONTROLS_Y - PITCH], t: 3, stock: "clear acrylic" },
46
+ { key: "stock_stained", label: "A bare colour on \"birch plywood\" stock", at: [2 * PITCH, CONTROLS_Y - PITCH], t: 3, stock: "birch plywood", display: { color: 0x2e8b3d } },
47
+ ];
48
+
49
+ function laserSwatch(s, unburnt) {
50
+ const [x, y] = s.at;
51
+ const common = {
52
+ label: unburnt ? `${s.label} (unburnt)` : s.label,
53
+ views: [unburnt ? "unburnt" : "laser"],
54
+ ...(s.display ? { display: s.display } : {}),
55
+ };
56
+ if (s.block) {
57
+ return { ...common, build: (k) => k.box({ min: [x, y, 0], max: [x + SW, y + SW, s.t] })
58
+ .cut(k.cylinder({ d: 8, h: s.t + 2 }).translate([x + 21, y + 21, -1])) };
59
+ }
60
+ // A standing swatch is drawn at the drawing origin; its pose stands it on edge at `at`.
61
+ const [u, v] = s.standing ? [0, 0] : [x, y];
62
+ return sheetPart({
63
+ ...common,
64
+ material: s.stock ?? "birch plywood",
65
+ thickness: s.t,
66
+ profile: (k) => k.shape2d([[u, v], [u + SW, v], [u + SW, v + SW], [u, v + SW]]).cut(sheetHole({ d: 8, at: [u + 21, v + 21] })),
67
+ engrave: (k) => k.text2d(String(s.t), { size: 10, align: "center", valign: "middle" }).translate([u + 9, v + 20]),
68
+ score: () => [[[u + 3, v + 7], [u + SW - 3, v + 7]]],
69
+ ...(s.standing ? { pose: { face: "-Y", up: "+Z", at: [x, y, 0] } } : {}),
70
+ ...(unburnt ? { place: (solid) => solid } : {}),
71
+ });
72
+ }
73
+
22
74
  export default {
23
75
  meta: { title: "Material swatches", units: "mm", environment: "studio" },
24
76
  defaults: {},
25
- views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" } },
77
+ views: { all: { label: "All presets", default: true }, colours: { label: "PLA colours" }, petg: { label: "PETG colours" }, print: { label: "Layer lines" }, laser: { label: "Laser-cut" }, unburnt: { label: "Laser-cut, unburnt" } },
26
78
  parts: {
27
79
  ...Object.fromEntries(ids.map((id, i) => [id.replaceAll("-", "_"), {
28
80
  label: PRESETS[id].label,
@@ -65,5 +117,9 @@ export default {
65
117
  build: (k) => k.box({ size: [30, 12, 40] }).translate([50, 0, 0]),
66
118
  display: { material: "pla-print", color: 0xe0592a },
67
119
  },
120
+ ...Object.fromEntries(LASER_SWATCHES.flatMap((s) => [
121
+ [`laser_${s.key}`, laserSwatch(s, false)],
122
+ [`unburnt_${s.key}`, laserSwatch(s, true)],
123
+ ])),
68
124
  },
69
125
  };
package/types/index.d.ts CHANGED
@@ -25,8 +25,49 @@ export type HostRailLayout =
25
25
  | { mode: "overlay" }
26
26
  | null;
27
27
 
28
- /** An export file format. STEP is routed to OCCT automatically. */
29
- export type ExportFormat = "stl" | "step" | "3mf";
28
+ /** An export file format. STEP is routed to OCCT automatically; `"bundle"` is the cut & print kit (a ZIP). */
29
+ export type ExportFormat = "stl" | "step" | "3mf" | "bundle";
30
+
31
+ /** One entry of `runtime.listExportFormats()` / `EXPORT_FORMATS`. `needsSheet` marks a format that needs a sheet part checked. */
32
+ export interface ExportFormatInfo {
33
+ id: ExportFormat;
34
+ label: string;
35
+ ext: string;
36
+ mime: string;
37
+ needsSheet: boolean;
38
+ }
39
+
40
+ /** A sheet part's stock, as `listExportableParts()` reports it. `group` is the kit's stock-group key, e.g. `"birch plywood|3.00"`. */
41
+ export interface ExportableSheetInfo {
42
+ process: string;
43
+ material: string;
44
+ thickness: number;
45
+ group: string;
46
+ }
47
+
48
+ /**
49
+ * The cut & print kit's download options — `exportParts({ format: "bundle", options })`.
50
+ * Every key is optional; `validateKitOptions` fills the defaults and throws an error
51
+ * starting `"cut kit options:"` for anything it cannot honour.
52
+ */
53
+ export interface KitOptions {
54
+ /** `"own-laser"` (default): SVG, laid out on sheets. `"service"`: DXF, one cut-only file per piece. */
55
+ destination?: "own-laser" | "service";
56
+ /** Derived from `destination`; when given it must match. */
57
+ cutFormat?: "svg" | "dxf";
58
+ /** mm, 0–0.5: the cut lines move out by half of it. Default 0 (nominal geometry). */
59
+ kerf?: number;
60
+ colors?: "lightburn";
61
+ /** Sheet size in mm per stock group; `"*"` is an explicit default. `null` (default) is 300 × 300 mm for every group. */
62
+ stock?: Array<{ group: string; size: [number, number] }> | null;
63
+ /** mm, 0–50. Default 5. */
64
+ margin?: number;
65
+ /** mm, 1–50. Default 3. */
66
+ spacing?: number;
67
+ printFormat?: "stl" | "3mf";
68
+ /** Whole number, 1–20. Default 1. */
69
+ sets?: number;
70
+ }
30
71
 
31
72
  /**
32
73
  * A semantic click result: which sub-part was hit, where (in the sub-part's own
@@ -346,9 +387,11 @@ export interface ExportPartsOptions {
346
387
  /** Sub-part names, as `listExportableParts()` reports them. */
347
388
  parts: string[];
348
389
  format: ExportFormat;
349
- /** Mesh quality for STL/3MF. Defaults to `"print"`. */
390
+ /** Mesh quality for STL/3MF (and a kit's printed parts). Defaults to `"print"`. */
350
391
  quality?: "preview" | "print";
351
392
  onProgress?: (phase: string) => void;
393
+ /** The cut & print kit's download options (`format: "bundle"`; the other formats ignore them). */
394
+ options?: KitOptions;
352
395
  }
353
396
 
354
397
  export interface CaptureCurrentOptions {
@@ -581,9 +624,12 @@ export interface PartRuntime {
581
624
  onContextLost(listener: () => void): () => void;
582
625
  /**
583
626
  * Every exportable sub-part — excludes any `exportable: false` part, respects
584
- * each part's `enabled(params)` — INDEPENDENT of the active view.
627
+ * each part's `enabled(params)` — INDEPENDENT of the active view. A sheet
628
+ * part's row adds `sheet`, evaluated at the current params (omitted when that throws).
585
629
  */
586
- listExportableParts(): Array<{ name: string; label: string }>;
630
+ listExportableParts(): Array<{ name: string; label: string; sheet?: ExportableSheetInfo }>;
631
+ /** The formats `exportParts` writes in this version — fresh copies of `EXPORT_FORMATS`. */
632
+ listExportFormats(): ExportFormatInfo[];
587
633
  /**
588
634
  * Headless export of a chosen subset. Resolves once the file is written
589
635
  * (handed to your `onDownload` sink, or downloaded directly); rejects on
@@ -648,6 +694,27 @@ export interface PartRuntime {
648
694
  /** Mount a full parametric-part app from a `PartDefinition`. */
649
695
  export function mount(part: PartDefinition, options: MountOptions): PartRuntime;
650
696
 
697
+ /** Every format `exportParts` can write in this version (`runtime.listExportFormats()` returns copies). */
698
+ export const EXPORT_FORMATS: ReadonlyArray<ExportFormatInfo>;
699
+
700
+ /** The prefix every cut & print kit options error starts with. */
701
+ export const KIT_OPTIONS_ERROR: "cut kit options:";
702
+
703
+ /** The kit's option defaults; `stock: null` is 300 × 300 mm for every stock group. */
704
+ export const KIT_DEFAULTS: Readonly<{
705
+ destination: "own-laser";
706
+ kerf: number;
707
+ colors: "lightburn";
708
+ stock: null;
709
+ margin: number;
710
+ spacing: number;
711
+ printFormat: "stl";
712
+ sets: number;
713
+ }>;
714
+
715
+ /** Normalise kit options — defaults filled, `cutFormat` derived — or throw a `"cut kit options: …"` error. */
716
+ export function validateKitOptions(options: unknown): Required<KitOptions>;
717
+
651
718
  /**
652
719
  * The sub-parts a view shows: declared in the view and `enabled` for these
653
720
  * params, in `Object.keys(part.parts)` order. Handy for app-side view logic.
package/types/part.d.ts CHANGED
@@ -441,6 +441,10 @@ export interface SheetDeclaration<P = ResolvedParams, D = Derived> {
441
441
  pose: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
442
442
  /** The build `sheetPart` made; `sub.build !== sub.sheet.generatedBuild` is a custom build. */
443
443
  generatedBuild: (k: GeometryKernel, p: P, d: D) => Solid;
444
+ /** The author's own `place` (the spec's), run after the pose; `null` when there is none. */
445
+ place: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
446
+ /** The `place` `sheetPart` installed (the pose, then the author's own), or `null`; `sub.place !== sub.sheet.generatedPlace` is a replaced place. */
447
+ generatedPlace: ((solid: Solid, ctx: PlaceContext<P, D>) => Solid) | null;
444
448
  }
445
449
 
446
450
  export interface ViewDefinition {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { GeometryKernel, Mesh, Point3, Solid } from "./kernel.js";
10
10
  import type { Derived, FontSource, PartDefinition, ResolvedParams } from "./part.js";
11
+ import type { KitOptions } from "./index.js";
11
12
 
12
13
  export type { Derived, GeometryKernel, Mesh, PartDefinition, ResolvedParams, Solid };
13
14
 
@@ -46,7 +47,8 @@ export type MatchTarget =
46
47
 
47
48
  /** A job the worker loop accepts. */
48
49
  export interface WorkerJob {
49
- type: "generate" | "export-stl" | "export-step" | "export-3mf" | "inspect";
50
+ /** `export-bundle` builds the cut & print kit (a ZIP) from `parts`, as `exportParts({ format: "bundle" })` does. */
51
+ type: "generate" | "export-stl" | "export-step" | "export-3mf" | "export-bundle" | "inspect";
50
52
  view?: string;
51
53
  params?: ResolvedParams;
52
54
  /** `generate`: which sub-parts to build. */
@@ -55,10 +57,12 @@ export interface WorkerJob {
55
57
  parts?: string[];
56
58
  quality?: "preview" | "print";
57
59
  cache?: boolean;
58
- /** Correlation id echoed on every reply. */
59
- jobId?: number;
60
+ /** Correlation id echoed on every reply (the export controller mints strings). */
61
+ jobId?: number | string;
60
62
  /** Single-file export name base. */
61
63
  name?: string;
64
+ /** `export-bundle` only: the kit's download options; absent means every default. */
65
+ options?: KitOptions;
62
66
  /**
63
67
  * `inspect`: score the part's six canonical silhouettes against these. Absent or
64
68
  * empty leaves `match` off the report entirely.