partforge 0.141.0 → 0.143.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/README.md CHANGED
@@ -193,7 +193,7 @@ runtime.setParams({ openAngle: 45 }); // merges into the live params, syncs th
193
193
 
194
194
  The partial is merged into the current params and the control panel updates to
195
195
  match. Keys the part doesn't define are silently ignored. When every changed
196
- parameter only moves geometry (a rotation or translation in `place()`), the
196
+ parameter only moves geometry (a rotation or translation in a `views` pose), the
197
197
  viewer re-poses the meshes it already has — instantly, with no worker rebuild;
198
198
  `onBuild` does not fire for those pose-only edits. Anything that changes the
199
199
  geometry itself rebuilds as usual.
@@ -80,8 +80,7 @@ export default {
80
80
  <name>: {
81
81
  label?, // display name (tabs/progress); defaults to the key
82
82
  build: (k, p, d, onProgress?) => Solid, // REQUIRED — see kernel API
83
- place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
84
- views, // string[] — which views show this sub-part
83
+ views: { <view>: true | (s, p, d) => s }, // each view the piece appears in: true = as built, or a rigid pose — see Rules
85
84
  enabled?: (p) => boolean, // optional — gate a conditional sub-part
86
85
  display?: { color?, opacity?, material?, …overrides }, // viewer-only appearance — see "Materials and appearance"
87
86
  export?: { name }, // filename/object name on export; defaults to the key
@@ -96,31 +95,44 @@ export default {
96
95
 
97
96
  **Rules:**
98
97
 
99
- - `build(k, p, d, onProgress?)` returns the **canonical** solid (e.g. at the origin).
98
+ - `build(k, p, d, onProgress?)` makes the piece **as it prints**: at the origin, flat
99
+ face on the bed (z = 0). It is the exported solid and the direction layer lines run.
100
100
  It is the only required function per sub-part. `p` is `{ ...defaults, ...userParams }`;
101
101
  `d` is `derive(p)` (or `{}`). `onProgress?.("phase")` is optional per-feature progress
102
102
  shown during export — call it before expensive steps.
103
- - `place(solid, ctx)` is an optional escape hatch for parts whose **display pose differs
104
- from their export pose** (e.g. positioning a sub-part in an assembly). `ctx.purpose` is
105
- `"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
106
- parts omit it. **Display placement must not depend on `view`** — display meshes are built
107
- once per sub-part and cached across views (the viewer re-centres per view).
108
- The viewer applies the display pose as a matrix over the canonical mesh, so `build()` may
109
- query geometry freely; only `place()` has to stay a rigid motion of its argument, reading
110
- `p` and `d`, for a pose-only param to play at frame rate and for layer lines to stay on
111
- the part.
112
- **Any difference between the display and export pose must be a rigid motion** —
113
- `translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
114
- non-identity `scale` on one purpose but not the other: the exported (printed) part is the
115
- same physical object you show in the assembly, and a reflection or resize there makes the
116
- two silently disagree — you print the mirror image of what the viewer showed
117
- ([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). If a part genuinely needs a
118
- reflected or resized form (e.g. a block that seats flipped), bake that into `build` so
119
- both purposes share one canonical solid, then pose it rigidly.
120
- Branch on `purpose` so the export pose is the print orientation: an export pose that
121
- follows an animated param pins the layer lines to the pose at the last rebuild.
103
+ - `views` maps each view the piece appears in to `true` (shown as built) or a pose
104
+ `(s, p, d) => s` that only translates or rotates its argument
105
+ (`translate`/`rotate`/`rotateAbout`/`along`/`at`) — no geometry queries, no
106
+ `scale`/`mirror`, no new solid. A view missing from the map does not show the piece.
107
+ - One sub-part per physical piece — never separate "print" and "assembled" copies. A
108
+ piece's own tab is `true`; `assembly` entries move pieces into place; `print` entries
109
+ spread them on the bed (`p.w + 10`, not a measurement).
110
+ - A param that moves a piece (hinge angle, slide, explode distance) is read only in
111
+ `views` entries; it then animates at frame rate with no rebuild.
112
+ - List `assembly` first (or flag it `default: true`) among the top-level `views` (the default tab, and what
113
+ headless `verify` checks), piece tabs next, `print` last.
114
+ - A piece that is not printed (a filament pin, a bearing) is `exportable: false` and
115
+ appears in `assembly` only.
116
+ - Sheet pieces (`sheetPart`) keep their `pose`; their `views` entries are `true`, and they
117
+ stay out of `print` — they are cut, not printed, and export assembled.
118
+ - Exports write the checked pieces as built. STL is one file per piece; a 3MF or STEP of
119
+ several printed (non-sheet) pieces lays them out on the bed automatically, 10 mm apart.
120
+
121
+ The Lattice Box's `lid` shows all of it (helpers elided — see `src/parts/lattice-box.js` for the whole part, whose `swing` is `(s, p, d) => s.rotate(-p.lidAngle, d.hinge, [1, 0, 0])`):
122
+
123
+ ```js
124
+ lid: {
125
+ build: (k, p, d) => /* flat plate with the lattice window, as printed */ k.box({ min: [0, 0, 0], max: [p.w, p.d, p.t] }),
126
+ views: {
127
+ assembly: (s, p, d) => swing(s.translate([0, 0, p.h]), p, d),
128
+ lid: true,
129
+ print: (s, p, d) => s.translate([p.w + d.gap, 0, 0]),
130
+ },
131
+ },
132
+ ```
133
+
122
134
  - `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
123
- - A view's sub-parts are derived, never hard-coded: those whose `views` include the view
135
+ - A view's sub-parts are derived, never hard-coded: those whose `views` map has the view
124
136
  and whose `enabled(p)` is true.
125
137
  - **Which view the viewer opens on** is resolved in this order: the first view flagged
126
138
  `default: true`; else the view placing the most sub-parts at `defaults` (counting
@@ -150,6 +162,36 @@ export default {
150
162
  lives beside the part and is meant to stay hand-editable.
151
163
  See "Vector geometry" below for the full contract.
152
164
 
165
+ ### Legacy: views arrays and place()
166
+
167
+ Parts written before 0.143 list views as an array and pose pieces with `place()`. They keep working unchanged, including their export poses; new parts use the `views` map above. A sub-part may not use both (`views-and-place`).
168
+
169
+ ```js
170
+ <name>: {
171
+ build: (k, p, d, onProgress?) => Solid,
172
+ place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
173
+ views: ["assembly", "print"], // string[] — which views show this sub-part
174
+ },
175
+ ```
176
+
177
+ - `place(solid, ctx)` is an optional escape hatch for parts whose **display pose differs
178
+ from their export pose**. `ctx.purpose` is `"display"` or `"export"`; `ctx.view` is the
179
+ active view. Default is identity. Display placement may depend on `view`: the viewer
180
+ re-poses each sub-part when the tab changes. The viewer applies the display pose as a
181
+ matrix over the canonical mesh, so `build()` may query geometry freely; only `place()`
182
+ has to stay a rigid motion of its argument, reading `p` and `d`, for a pose-only param
183
+ to play at frame rate and for layer lines to stay on the part.
184
+ - **Any difference between the display and export pose must be a rigid motion** —
185
+ `translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
186
+ non-identity `scale` on one purpose but not the other: you would print the mirror image
187
+ of what the viewer showed ([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). Bake a
188
+ reflected or resized form into `build` so both purposes share one canonical solid, then
189
+ pose it rigidly.
190
+ - Branch on `purpose` so the export pose is the print orientation: an export pose that
191
+ follows an animated param pins the layer lines to the pose at the last rebuild.
192
+ - A param read only inside `place()` plays at frame rate; `lint` notes a track whose
193
+ `place()` it cannot read (a query on the solid, a function argument).
194
+
153
195
  ---
154
196
 
155
197
  ## Animations
@@ -292,10 +334,10 @@ Rules (all lint-enforced):
292
334
  ]
293
335
  ```
294
336
  - Playback drives params through the real param pipeline. A param read only
295
- inside `place()` plays at frame rate whatever `build()` does. A param
337
+ inside a `views` pose plays at frame rate whatever `build()` does. A param
296
338
  `build()` reads rebuilds at worker cadence — except through a trailing
297
339
  translate/rotate, which the viewer still re-poses by delta. `lint` notes a track whose
298
- `place()` it cannot read (a query on the solid, a function argument).
340
+ pose it cannot read (a query on the solid, a function argument).
299
341
  - Playback pauses when the user edits any control; Reset restores the values
300
342
  the animation found. Because animated values are real params, exporting
301
343
  while paused exports the posed state — by design.
@@ -385,8 +427,8 @@ that changes the part's footprint never slides the floor around under it.
385
427
 
386
428
  **3D-print layer lines** (`pla-print`, `petg-print`) run perpendicular to the
387
429
  **export** pose's +Z — the way the part will be printed, not the way it is
388
- displayed. If the lines run the wrong way, fix the export pose with `place()`
389
- (`purpose: "export"`), not the material. Wood, carbon fibre and SLS grain are
430
+ displayed. If the lines run the wrong way, fix `build()` so the piece is
431
+ modelled in its print orientation, not the material. Wood, carbon fibre and SLS grain are
390
432
  fixed to the sub-part, so they never slide when the camera or an animation
391
433
  moves.
392
434
 
@@ -396,7 +438,7 @@ moves.
396
438
  `plywood` the plies show through), and its engraving and score lines are scorched, while its
397
439
  faces stay wood. There is nothing to set, and it is still one material: the char follows
398
440
  from the sheet and the wood it is drawn in. It needs the sheet's own frame, so a sheet part
399
- with a custom `build` or its own `place` shows plain wood. The CAD view and every export are
441
+ with a custom `build` or a `views` pose of its own shows plain wood. The CAD view and every export are
400
442
  unchanged.
401
443
 
402
444
  **`textureScale` is millimetres, and what it measures depends on the preset's
@@ -792,11 +834,11 @@ This holds on **both backends** — and on OCCT, `translate`/`rotate` are additi
792
834
  *pose-lazy*: the backend re-poses the cached solid's cached tessellation instead of
793
835
  re-running any B-rep work. A parameter that only feeds a final placement rotation (a
794
836
  lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on the
795
- slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
837
+ slow exact kernel — keep such transforms as the last ops in `build` (or in a `views` pose)
796
838
  rather than baking them into the geometry earlier. In the app, such pose-only edits
797
839
  skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
798
- even at animation rates (see `runtime.setParams`). `place()` needs no geometry: the viewer
799
- reads its pose off a geometry-free probe of `place()` alone, so `build()` may query freely.
840
+ even at animation rates (see `runtime.setParams`). A `views` pose needs no geometry: the viewer
841
+ reads it off a geometry-free probe of the pose alone, so `build()` may query freely.
800
842
 
801
843
  ---
802
844
 
@@ -1801,7 +1843,7 @@ cleanly tells a facet artifact (close to 180°, barely bent) from a real corner
1801
1843
  away from 180°) — filter on that, or pass a coarser `segs` to the tessellated shape
1802
1844
  before unioning, rather than fighting the selector after the fact.
1803
1845
 
1804
- **Which controls a sub-part depends on** is recorded from its real build. The worker notes every `p` and `d` key that `build()` and `place()` read:
1846
+ **Which controls a sub-part depends on** is recorded from its real build. The worker notes every `p` and `d` key that `build()` and the `views` poses read:
1805
1847
  - a derived key expands to the params its `derive` group read. `derive` must return its values, not write them onto `p`: a key `derive` writes onto `p` isn't attributed to anything;
1806
1848
  - a param read by a `fonts`, `images` or `vectors` declaration counts for every sub-part.
1807
1849
 
@@ -1838,7 +1880,7 @@ with pockets, steps or fillets. Mixing is normal — plywood panels, printed hin
1838
1880
  import { sheetPart, sheetHole } from "partforge/geometry";
1839
1881
 
1840
1882
  plate: sheetPart({
1841
- label: "Plate", views: ["main"], display: { material: "clear-acrylic" },
1883
+ label: "Plate", views: { main: true }, display: { material: "clear-acrylic" },
1842
1884
  material: "clear acrylic", // stock label: groups pieces in the kit
1843
1885
  thickness: (p) => p.t, // the MEASURED thickness, from a control
1844
1886
  profile: (k, p) => k.shape2d([[0, 0], [p.w, 0], [p.w, p.h], [0, p.h]])
@@ -1847,12 +1889,11 @@ plate: sheetPart({
1847
1889
  }),
1848
1890
  ```
1849
1891
 
1850
- It returns an ordinary sub-part: `build`, `place` and a plain-data `sheet`. A field
1892
+ It returns an ordinary sub-part: `build`, the pose's placement and a plain-data `sheet`. A field
1851
1893
  needing the kernel is `(k, p, d) => …`; a plain value is a literal or `(p, d) => …`.
1852
1894
  `material`, `thickness` and `profile` are required; `score`, `engrave`, `pose`,
1853
1895
  `process` (default `"laser"`) are optional; `label`, `views`, `display`, `export`,
1854
- `enabled`, `exportable`, `reference` pass through; your own `place` runs after the
1855
- pose. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1896
+ `enabled`, `exportable`, `reference` pass through; your `views` poses apply after the `pose`, in the viewer only. `build` is supplied — passing one throws, as do `kerf`, `outline`/`cut` and
1856
1897
  `quantity`; each error names the fix.
1857
1898
 
1858
1899
  ### Cut, score and engrave
@@ -1922,8 +1963,7 @@ front: sheetPart({ ...PLY, profile: (k, p, d) => d.box.front.outline, pose: (p,
1922
1963
 
1923
1964
  `printedTab({ size: [w, h], thickness, clearance = 0.3 })` gives a slot and its printed
1924
1965
  tongue from one spec: `slot` is `(w + c) × (h + c)`, centred (all the play); `tongue`
1925
- is `[w, h, thickness]` for `k.box({ size: tongue })`. Build the printed part in its
1926
- PRINT pose, `place` it for display, and cut the slot where the tongue lands:
1966
+ is `[w, h, thickness]` for `k.box({ size: tongue })`. Build the printed part as it prints; its view entry moves it into place; the slot:
1927
1967
  `k.shape2d(tab.slot).translate(worldToSheet(panelPose, tongueCentre))`.
1928
1968
 
1929
1969
  ### What lint and verify check
@@ -1973,7 +2013,7 @@ A host downloads sheet parts as one ZIP, the **cut & print kit** (`format: "bund
1973
2013
  - One thickness per joint: fingers, tabs and T-slots join panels cut from the
1974
2014
  same sheet.
1975
2015
  - Poses are rigid and axis-aligned: `face` and `up` are the six axis words. An
1976
- angled panel is an author `place` after the pose (realistic mode then shows
2016
+ angled panel is a `views` entry after the pose (realistic mode then shows
1977
2017
  no laser burns), or a printed part.
1978
2018
  - No bends, folds, living hinges or grain direction: `folds`, `bends` and
1979
2019
  `grain` are reserved keys and throw.
@@ -2021,17 +2061,16 @@ function hinge(k, p, d) {
2021
2061
  return k.union([fixed, moving]);
2022
2062
  }
2023
2063
 
2024
- // Display: flat hinge against the back panel's outside face, knuckles above the wall.
2025
- // Export: the print pose, untouched — both hinges export the same solid.
2026
- const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
2027
- : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
2064
+ // Each hinge is built in its PRINT pose (above); the box view stands it up on the back
2065
+ // panel. Identical builds, so the kit still prints "hinge" ×2.
2066
+ const hingePose = (i) => (s, p, d) => s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]);
2028
2067
 
2029
2068
  // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
2030
2069
  const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
2031
2070
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
2032
2071
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
2033
2072
 
2034
- const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
2073
+ const PLY = { views: { box: true }, display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
2035
2074
  const panel = (name, label, extra = {}) => sheetPart({
2036
2075
  ...PLY, label,
2037
2076
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -2086,10 +2125,10 @@ export default {
2086
2125
  .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
2087
2126
  pose: (p, d) => d.lidPose,
2088
2127
  }),
2089
- hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
2090
- export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
2091
- hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
2092
- build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
2128
+ hingeL: { label: "Hinge (left)", views: { box: hingePose(0) }, display: { material: "pla-print" },
2129
+ export: { name: "hinge" }, build: hinge },
2130
+ hingeR: { label: "Hinge (right)", views: { box: hingePose(1) }, display: { material: "pla-print" },
2131
+ build: hinge }, // identical solid: the kit prints "hinge" ×2
2093
2132
  },
2094
2133
  views: { box: { label: "Box" } },
2095
2134
  // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.
@@ -2456,7 +2495,7 @@ parts: {
2456
2495
  // Ghost overlay: only in "reference" — never coincides with body in "assembly".
2457
2496
  ref: {
2458
2497
  label: "Reference (ghost)",
2459
- views: ["reference"],
2498
+ views: { reference: true },
2460
2499
  exportable: false,
2461
2500
  display: { opacity: 0.3 },
2462
2501
  build: (k) => k.import("scan"),
@@ -2465,7 +2504,7 @@ parts: {
2465
2504
  // in "reference" for visual alignment checking.
2466
2505
  body: {
2467
2506
  label: "Rebuild",
2468
- views: ["assembly", "reference"],
2507
+ views: { assembly: true, reference: true },
2469
2508
  reference: "scan",
2470
2509
  build: (k, p) => k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }),
2471
2510
  },
@@ -3714,19 +3753,21 @@ key, which the runtime ignores), `animations-not-object`,
3714
3753
  `animation-autoplay-invalid` (all errors). One
3715
3754
  more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
3716
3755
  each track's endpoint values and emits a **note** when the animated param feeds
3717
- real geometry, or when its `place()` can't be read (it queries the solid or
3756
+ real geometry, or when its pose can't be read (it queries the solid or
3718
3757
  passes a function), because such a track plays best-effort rather than at frame
3719
3758
  rate. Notes are informational — they never
3720
3759
  affect `ok`, `measure`, or `--strict`.
3721
3760
 
3722
- **Place invariants**, found by running the geometry-free place probe (the same
3723
- one animation's `animation-track-rebuilds` uses) against each sub-part's
3724
- `place()`, even when `build()` queries the solid — `view-dependent-display-place` (display placement must not depend
3725
- on the active view, since display meshes are cached across views) and
3726
- `place-not-rigid` (display vs. export placement may differ only by a rigid
3727
- motion — translate/rotate — never a reshape) (both errors). A `place()` the probe
3728
- cannot read (it queries the solid or passes a function) stays silent for the two
3729
- place rules and earns the `animation-track-rebuilds` note.
3761
+ **View-pose invariants**, found by running the geometry-free pose probe (the same
3762
+ one animation's `animation-track-rebuilds` uses) against each `views` entry, even when
3763
+ `build()` queries the solid — `view-entry-invalid` (an entry that is neither `true` nor a
3764
+ pose function), `views-and-place` (an author `place` beside a `views` map) and
3765
+ `view-pose-not-rigid` (an entry that reshapes instead of moving), `views-invalid` (a sub-part
3766
+ whose `views` is missing or neither a map nor an array; all errors), and
3767
+ `place-not-rigid` (legacy `place()` form: display vs. export placement may differ only by a
3768
+ rigid motion — translate/rotate — never a reshape; an error). A pose the probe cannot read
3769
+ (it queries the solid or passes a function) stays silent for these rules and earns the
3770
+ `animation-track-rebuilds` note.
3730
3771
 
3731
3772
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3732
3773
  library does not know; the viewer draws it as if it named none — a PLA print
@@ -3994,7 +4035,9 @@ a **min-wall** warning, and — only for a part that also declares
3994
4035
  `orientation: "print"` — an **overhang** warning: `overhangArea` is the mm² of
3995
4036
  downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
3996
4037
  on the FDM profiles; resin carries none, since it prints on supports), measured per
3997
- sub-part with the bed at that sub-part's own lowest Z, and warned past 1 mm². The
4038
+ sub-part in its print (export) pose — not as the view shows it — with the bed at that
4039
+ pose's own lowest Z, and warned past 1 mm²; the reported location is where the face sits
4040
+ in the view. The
3998
4041
  opt-in is deliberate: a profile says what a process can print, the orientation key
3999
4042
  says this part is laid out for it, and a part still being shaped, or one bound for a
4000
4043
  different process, should not be nagged about its underside. Writing your own
@@ -4006,7 +4049,7 @@ counted: the footprint itself, and faces whose centroid sits within 1 mm of the
4006
4049
  flat underside spanning two supports) and the ceiling of a horizontal bore are
4007
4050
  reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
4008
4051
  is why this is a warning and never a gate. Two more limits, stated: it is judged in
4009
- the DISPLAY pose, so a sub-part whose `place` differs for export (a lid that prints
4052
+ the DISPLAY pose, so a sub-part whose `assembly` pose stands it differently from how it prints (a lid that prints
4010
4053
  flat beside its base) is measured as displayed, and `exportable: false` sub-parts
4011
4054
  are skipped. Switch it off under an FDM profile with an inline
4012
4055
  `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
@@ -4386,6 +4429,9 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
4386
4429
  - **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
4387
4430
  cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
4388
4431
  - **Units are millimetres** throughout.
4432
+ - **One sub-part per physical piece, built as it prints; `views` poses it.** A `views`
4433
+ entry is `true` or a rigid pose `(s, p, d) => s` — never a second "print" copy, never a
4434
+ reshape ([view-pose-not-rigid](ERROR-PATTERNS.md#view-pose-not-rigid)).
4389
4435
  - **Never sample an arc into points by hand.** A `Math.cos` loop hides the sweep direction
4390
4436
  in a sign, and a wrong sign produces a self-crossing outline that builds with inverted
4391
4437
  fill and no error — only a `profile-self-intersects` warning
@@ -4429,8 +4475,6 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
4429
4475
  232 and a box is 12, and every one is a boolean operand — so prefer instancing one
4430
4476
  union of a row over a chain of per-feature booleans, and drop counts the print
4431
4477
  cannot show.
4432
- - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
4433
- depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
4434
4478
  - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
4435
4479
  ([probe-routed-to-occt](ERROR-PATTERNS.md#probe-routed-to-occt),
4436
4480
  [occt-holes-watertight-na](ERROR-PATTERNS.md#occt-holes-watertight-na)).
@@ -138,18 +138,36 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
138
138
  - **Cause:** OCCT and Manifold WASM must not boot in the same process.
139
139
  - **Fix:** Keep OCCT-booting tests in their own files (vitest isolates per file) and boot via `bootOcctKernel()` in a `beforeAll`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Testing a part".
140
140
 
141
- ## view-dependent-display-place
142
-
143
- - **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
144
- - **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
145
- - **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
146
-
147
141
  ## place-not-rigid
148
142
 
149
143
  - **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
150
- - **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
144
+ - **Cause:** A legacy-form (`views` array + `place()`; the views-map form is checked by [view-pose-not-rigid](#view-pose-not-rigid)) `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
151
145
  - **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
152
146
 
147
+ ## view-entry-invalid
148
+
149
+ - **Symptom:** A sub-part is missing from a view it names, and lint reports the entry.
150
+ - **Cause:** A `views` map entry that is not `true` or a pose function — `false`, `null`, a string or an object.
151
+ - **Fix:** Use `true` (shown as built) or `(s, p, d) => s.translate(…)`. To leave the piece out of a view, delete the key.
152
+
153
+ ## views-and-place
154
+
155
+ - **Symptom:** Lint refuses a sub-part that declares a `views` map and `place`.
156
+ - **Cause:** Two places a pose can live. With a map, each view's entry is its pose and `build()` is the export.
157
+ - **Fix:** Move each view's pose into its entry and delete `place`. If the piece prints in a different orientation from how it is modelled, build it as it prints and move it into place in the `assembly` entry.
158
+
159
+ ## view-pose-not-rigid
160
+
161
+ - **Symptom:** The piece in one view is a different size or a mirror image of the one exported.
162
+ - **Cause:** A view entry that scales, mirrors or adds geometry instead of only moving the piece.
163
+ - **Fix:** Entries only `translate`/`rotate`/`rotateAbout`/`at` their argument. Bake a resized or reflected form into `build()`.
164
+
165
+ ## views-invalid
166
+
167
+ - **Symptom:** A sub-part is missing from every view, or lint refuses a sub-part whose `views` is absent, a string or a number.
168
+ - **Cause:** `views` must be a map of view name to `true` or a pose (or, in the legacy form, an array of view names). Anything else shows the piece nowhere.
169
+ - **Fix:** Give the sub-part a `views` map — e.g. `views: { assembly: true }` — naming each view it appears in.
170
+
153
171
  ## wrong-node-version
154
172
 
155
173
  - **Symptom:** Confusing failures during `npm install`, tests, or CLI runs — WASM load errors, syntax errors in dependencies, or kernels that never boot — on a machine that built fine before.
@@ -367,7 +367,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
367
367
  | Op | Contract |
368
368
  |---|---|
369
369
  | `cut(tool)` / `cutAll(tools[])` / `intersect(other)` / `union(other)` | Boolean subtract (single / batched), intersection, and union. |
370
- | `translate(v)` · `rotate(deg, center, axis)` · `mirror("XY"\|"XZ"\|"YZ")` · `scale(factor, center?)` | Transforms — but only two are **rigid** (pose): `translate`/`rotate` move a solid without altering it (position + orientation, shape and handedness preserved). `mirror` **reflects** — it returns the opposite-handed (chiral) solid, which no rotation can reproduce; `scale` **resizes**. So `mirror`/`scale` change the solid *itself*, not just where it sits — think of them as build operations, and never as the difference between a display pose and an export pose (see AUTHORING-PARTS.md `place`). `translate`/`rotate` are the primitives; the placement sugar below is composed *purely from them* (`solid-sugar.js`), so it is geometry-identical on every backend and a host gets it for free via `addSugar()`. |
370
+ | `translate(v)` · `rotate(deg, center, axis)` · `mirror("XY"\|"XZ"\|"YZ")` · `scale(factor, center?)` | Transforms — but only two are **rigid** (pose): `translate`/`rotate` move a solid without altering it (position + orientation, shape and handedness preserved). `mirror` **reflects** — it returns the opposite-handed (chiral) solid, which no rotation can reproduce; `scale` **resizes**. So `mirror`/`scale` change the solid *itself*, not just where it sits — think of them as build operations, and never as the difference between a display pose and an export pose (see AUTHORING-PARTS.md, the `views` map). `translate`/`rotate` are the primitives; the placement sugar below is composed *purely from them* (`solid-sugar.js`), so it is geometry-identical on every backend and a host gets it for free via `addSugar()`. |
371
371
  | `rotateX(deg)` / `rotateY(deg)` / `rotateZ(deg)` · `rotateAbout({axis, deg, through?})` · `along(dir)` · `at(v)` | The readable placement vocabulary parts actually use. `along` maps the canonical +Z build axis to `"±X"\|"±Y"\|"±Z"`. |
372
372
  | `clone()` | Independent handle (see value semantics). |
373
373
  | `label(name)` | Name this solid's surface for feature attribution; must survive transforms and booleans; equal names merge into one feature. Affects mesh metadata only, never geometry. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.141.0",
3
+ "version": "0.143.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@
9
9
  // verify / renderViews) deliberately do NOT use this — they stay on
10
10
  // Object.keys(part.views)[0], where a mechanically obvious rule matters more than a
11
11
  // convenient one.
12
+ import { inView } from "./sub-part-views.js";
12
13
 
13
14
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
14
15
 
@@ -20,7 +21,7 @@ function placedCount(part, view, defaults) {
20
21
  const parts = isPlainObject(part?.parts) ? part.parts : {};
21
22
  let n = 0;
22
23
  for (const sp of Object.values(parts)) {
23
- if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
24
+ if (!inView(sp, view)) continue;
24
25
  if (typeof sp.enabled !== "function") { n++; continue; }
25
26
  try { if (sp.enabled(defaults)) n++; } catch { n++; }
26
27
  }
@@ -0,0 +1,62 @@
1
+ // Where a multi-piece 3MF or STEP puts each views-map piece. Those formats write every
2
+ // selected piece into one file, and a views-map piece exports AS BUILT — at its own
3
+ // origin — so without a layout every piece would sit on top of the others
4
+ // (spec 2026-10-04 "Export layout"). Translation only: orientation, layer lines and
5
+ // the per-piece STL are untouched. Deterministic first-fit-decreasing-height shelves
6
+ // over each piece's XY bounding box — the cut kit's packer (layout.js) without
7
+ // rotation, sheets or margins — `spacing` apart, every piece resting at z = 0.
8
+ // Legacy (array views + place) pieces never come here: their author placed them.
9
+ import { resolveProfile } from "../oracle/dfm-profiles.js";
10
+ import { formOf } from "../sub-part-views.js";
11
+ import { isSheetPart } from "../sheet/constants.js";
12
+
13
+ const EPS = 1e-9;
14
+ export const BED_SPACING = 10;
15
+ export const DEFAULT_BED_WIDTH = 220;
16
+ const byKey = (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
17
+
18
+ export function bedLayout(boxes, { width, spacing = BED_SPACING } = {}) {
19
+ const items = boxes.map((b) => ({ ...b, w: b.max[0] - b.min[0], h: b.max[1] - b.min[1] }));
20
+ const W = Math.max(width ?? DEFAULT_BED_WIDTH, ...items.map((it) => it.w));
21
+ const order = [...items].sort((a, b) => b.h - a.h || b.w - a.w || byKey(a, b));
22
+ const shelves = []; // { y, h, x: used width, n: pieces }
23
+ const out = new Map();
24
+ for (const it of order) {
25
+ let shelf = shelves.find((s) => (s.n ? s.x + spacing : 0) + it.w <= W + EPS);
26
+ if (!shelf) {
27
+ const last = shelves.at(-1);
28
+ shelf = { y: last ? last.y + last.h + spacing : 0, h: it.h, x: 0, n: 0 };
29
+ shelves.push(shelf);
30
+ }
31
+ const x = shelf.n ? shelf.x + spacing : 0;
32
+ out.set(it.key, [x - it.min[0], shelf.y - it.min[1], -it.min[2]]);
33
+ shelf.x = x + it.w;
34
+ shelf.n++;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function bedWidthFor(part) {
40
+ const process = part?.verify?.process;
41
+ if (!process) return null;
42
+ try {
43
+ const bed = resolveProfile(process)?.bed;
44
+ return Array.isArray(bed) && Number.isFinite(bed[0]) ? bed[0] : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ // `pieces`: [{ name, solid, ...rest }] in export order. Map-form pieces are laid out
51
+ // together; legacy ones keep their export pose, and so do sheet pieces — a sheet's pose IS
52
+ // its placement, so sheet exports come out assembled. Returns a new array, same order.
53
+ export function layoutExportPieces(part, pieces) {
54
+ const laidOut = (sp) => formOf(sp) === "map" && !isSheetPart(sp);
55
+ const mapPieces = pieces.filter(({ name }) => laidOut(part.parts[name]));
56
+ if (mapPieces.length === 0) return pieces;
57
+ const offsets = bedLayout(
58
+ mapPieces.map(({ name, solid }) => ({ key: name, ...solid.boundingBox() })),
59
+ { width: bedWidthFor(part) },
60
+ );
61
+ return pieces.map((pc) => (offsets.has(pc.name) ? { ...pc, solid: pc.solid.translate(offsets.get(pc.name)) } : pc));
62
+ }
@@ -40,6 +40,9 @@ const loadInspect = () => Promise.all([
40
40
  // a session that never downloads a kit. test/worker-layering.test.js keeps every one of
41
41
  // them out of worker boot, and test/kit-layering.test.js keeps the kit off ./oracle/*.
42
42
  const loadBundle = () => import("./export/bundle.js");
43
+ // The 3MF/STEP bed layout (export/bed-layout.js, which reads the DFM profile table) loads
44
+ // the same way, on the first 3MF/STEP job.
45
+ const loadBedLayout = () => import("./export/bed-layout.js");
43
46
 
44
47
  // HOST JOBS — the extension seam. A host registers its own job types with
45
48
  // `runWorker(part, { jobs: { <type>: handler } })`, threaded here as `opts.jobs`, and
@@ -496,21 +499,28 @@ export async function handle(kernel, part, msg, post, opts = {}) {
496
499
  post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
497
500
  out.map((pp) => bufferOf(pp.data)));
498
501
  } else if (msg.type === "export-step") {
502
+ // 3MF/STEP hold every selected piece in one file; printed views-map pieces export as
503
+ // built, so they are laid out apart (export/bed-layout.js). Sheet pieces keep their
504
+ // assembled pose. STL is one file per piece and is never laid out.
499
505
  const names = selected();
500
506
  if (names.length === 0) throw new Error("no exportable parts selected");
501
- const solids = names.map((name) => {
507
+ const { layoutExportPieces } = await loadBedLayout();
508
+ const solids = layoutExportPieces(part, names.map((name) => {
502
509
  onProgress(`building ${label(name)}`);
503
- return { name: exportName(name), solid: posed(name, "export", onProgress), color: printColor(part.parts[name]?.display) };
504
- });
510
+ return { name, solid: posed(name, "export", onProgress) };
511
+ })).map(({ name, solid }) => ({ name: exportName(name), solid, color: printColor(part.parts[name]?.display) }));
505
512
  onProgress("writing STEP file");
506
513
  const data = await kernel.toSTEP(solids);
507
514
  post({ type: "download", data, filename: `${fileBase}.step`, mime: "application/step", jobId: msg.jobId }, [bufferOf(data)]);
508
515
  } else if (msg.type === "export-3mf") {
509
516
  const names = selected();
510
517
  if (names.length === 0) throw new Error("no exportable parts selected");
511
- const meshes = names.map((name) => {
518
+ const { layoutExportPieces } = await loadBedLayout();
519
+ const meshes = layoutExportPieces(part, names.map((name) => {
512
520
  onProgress(`building ${label(name)}`);
513
- const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh({ quality: msg.quality ?? "print" });
521
+ return { name, solid: posed(name, "export", onProgress) };
522
+ })).map(({ name, solid }) => {
523
+ const { positions, indices } = solid.toIndexedMesh({ quality: msg.quality ?? "print" });
514
524
  return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
515
525
  });
516
526
  onProgress("writing 3MF file");
@@ -13,6 +13,7 @@
13
13
  import { err, note } from "./finding.js";
14
14
  import { EASINGS } from "../animation.js";
15
15
  import { CANONICAL_VIEWS, CAMERA_CUE_VIEWS } from "../view-angles.js";
16
+ import { inView } from "../sub-part-views.js";
16
17
  import { probeSubPartPose } from "../pose-probe-core.js";
17
18
  import { resolveDerivedAttributed } from "../derive.js";
18
19
  import { recorder, expandDerivedReads } from "../read-recorder.js";
@@ -246,8 +247,8 @@ export const ANIMATION_RULES = [
246
247
  rawSteps(a).forEach((s, i) => {
247
248
  for (const key of Object.keys(isPlainObject(s.opacity) ? s.opacity : {})) {
248
249
  const sub = isPlainObject(part?.parts) ? part.parts[key] : undefined;
249
- const inView = isPlainObject(sub) && Array.isArray(sub.views) && sub.views.includes(view);
250
- if (!inView) {
250
+ const listed = isPlainObject(sub) && inView(sub, view);
251
+ if (!listed) {
251
252
  out.push(err("animation-opacity-unknown-part",
252
253
  `animation "${name}" fades "${key}", which is not a sub-part of view "${view}"`,
253
254
  `Opacity tracks name sub-parts of the owning view — add "${view}" to \`parts.${key}.views\`, or correct the key.`,
@@ -524,7 +525,7 @@ export const ANIMATION_RULES = [
524
525
  function classifyTrack(part, p, key, v0, v1, view) {
525
526
  let result = "pose";
526
527
  for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
527
- if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
528
+ if (!inView(sp, view)) continue;
528
529
  const ends = [];
529
530
  for (const v of [v0, v1]) {
530
531
  const pv = { ...p, [key]: v };