partforge 0.142.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 may depend on `view`: the viewer re-poses each
107
- sub-part when the tab changes, so one sub-part can sit differently in two views.
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,17 +3753,20 @@ 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 — `place-not-rigid` (display vs.
3725
- export placement may differ only by a rigid motion — translate/rotate — never a
3726
- reshape; an error). A `place()` the probe cannot read (it queries the solid or
3727
- passes a function) stays silent for the place rule and earns the
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
3728
3770
  `animation-track-rebuilds` note.
3729
3771
 
3730
3772
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
@@ -4007,7 +4049,7 @@ counted: the footprint itself, and faces whose centroid sits within 1 mm of the
4007
4049
  flat underside spanning two supports) and the ceiling of a horizontal bore are
4008
4050
  reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
4009
4051
  is why this is a warning and never a gate. Two more limits, stated: it is judged in
4010
- 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
4011
4053
  flat beside its base) is measured as displayed, and `exportable: false` sub-parts
4012
4054
  are skipped. Switch it off under an FDM profile with an inline
4013
4055
  `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
@@ -4387,6 +4429,9 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
4387
4429
  - **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
4388
4430
  cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
4389
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)).
4390
4435
  - **Never sample an arc into points by hand.** A `Math.cos` loop hides the sweep direction
4391
4436
  in a sign, and a wrong sign produces a self-crossing outline that builds with inverted
4392
4437
  fill and no error — only a `profile-self-intersects` warning
@@ -141,9 +141,33 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
141
141
  ## place-not-rigid
142
142
 
143
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.
144
- - **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.
145
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".
146
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
+
147
171
  ## wrong-node-version
148
172
 
149
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.142.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 };
@@ -1,3 +1,6 @@
1
+ // Lint ids here: views-invalid (a sub-part whose views is neither form), place-not-rigid (legacy views-array + place() form), and for the views-map
2
+ // form view-entry-invalid (an entry that is not true or a pose), views-and-place (an author
3
+ // place beside a map) and view-pose-not-rigid (an entry that reshapes instead of moving).
1
4
  // Group 6 — the place() invariant (docs/AUTHORING-PARTS.md "Display vs export
2
5
  // placement"), promoted from a doc-only convention to lint because the animation
3
6
  // system leans on place() for every pose-only track: display vs export may differ
@@ -15,7 +18,9 @@
15
18
  // and stays silent. Readability, not `trusted`, gates the rules: the same reshape
16
19
  // on both purposes is allowed, so a reshaping place() must still be compared.
17
20
  import { err } from "./finding.js";
18
- import { probeSubPartPose } from "../pose-probe-core.js";
21
+ import { probeSubPartPose, CANONICAL } from "../pose-probe-core.js";
22
+ import { isViewsMap, inView, formOf } from "../sub-part-views.js";
23
+ import { isSheetPart } from "../sheet/constants.js";
19
24
 
20
25
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
21
26
 
@@ -24,7 +29,7 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
24
29
  // imported from jobs.js, which would break the lint purity closure.
25
30
  function viewNames(part, view, p) {
26
31
  return Object.entries(isPlainObject(part?.parts) ? part.parts : {})
27
- .filter(([, sp]) => Array.isArray(sp?.views) && sp.views.includes(view))
32
+ .filter(([, sp]) => inView(sp, view))
28
33
  .filter(([, sp]) => { try { return sp.enabled ? !!sp.enabled(p) : true; } catch { return false; } })
29
34
  .map(([name]) => name);
30
35
  }
@@ -38,7 +43,7 @@ export const PLACE_RULES = [
38
43
  run: ({ part, p, d }) => {
39
44
  const out = [];
40
45
  for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
41
- if (!sp?.place) continue;
46
+ if (!sp?.place || formOf(sp) === "map") continue;
42
47
  for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
43
48
  if (!viewNames(part, view, p).includes(name)) continue;
44
49
  const display = probePlace(sp, { view, purpose: "display", p, d });
@@ -57,4 +62,77 @@ export const PLACE_RULES = [
57
62
  return out;
58
63
  },
59
64
  },
65
+ {
66
+ id: "views-invalid",
67
+ run: ({ part }) => {
68
+ const out = [];
69
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
70
+ const v = sp?.views;
71
+ if (Array.isArray(v) || isPlainObject(v)) continue;
72
+ out.push(err("views-invalid",
73
+ `sub-part "${name}" has no usable \`views\` (it is ${v === undefined ? "missing" : JSON.stringify(v) ?? String(v)})`,
74
+ "Give the sub-part a `views` map — e.g. `views: { assembly: true }` — naming each view it appears in.",
75
+ `parts.${name}.views`,
76
+ "views-invalid"));
77
+ }
78
+ return out;
79
+ },
80
+ },
81
+ {
82
+ id: "view-entry-invalid",
83
+ run: ({ part }) => {
84
+ const out = [];
85
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
86
+ if (!isViewsMap(sp?.views)) continue;
87
+ for (const [view, entry] of Object.entries(sp.views)) {
88
+ if (entry === true || typeof entry === "function") continue;
89
+ out.push(err("view-entry-invalid",
90
+ `sub-part "${name}" has ${JSON.stringify(entry) ?? String(entry)} for view "${view}"`,
91
+ `Each entry in a sub-part's \`views\` map is \`true\` (shown as built) or a pose \`(s, p, d) => s.translate(…)\`. To leave the piece out of "${view}", delete the key.`,
92
+ `parts.${name}.views.${view}`,
93
+ "view-entry-invalid"));
94
+ }
95
+ }
96
+ return out;
97
+ },
98
+ },
99
+ {
100
+ id: "views-and-place",
101
+ run: ({ part }) => {
102
+ const out = [];
103
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
104
+ if (!isViewsMap(sp?.views) || typeof sp.place !== "function") continue;
105
+ const generated = isSheetPart(sp) && sp.place === sp.sheet.generatedPlace && sp.sheet.place == null;
106
+ if (generated) continue;
107
+ out.push(err("views-and-place",
108
+ `sub-part "${name}" has both a \`views\` map and \`place\``,
109
+ "Put each view's pose in its `views` entry and delete `place`. `build()` makes the piece as it prints — that is what exports.",
110
+ `parts.${name}.place`,
111
+ "views-and-place"));
112
+ }
113
+ return out;
114
+ },
115
+ },
116
+ {
117
+ id: "view-pose-not-rigid",
118
+ run: ({ part, p, d }) => {
119
+ const out = [];
120
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
121
+ if (!isViewsMap(sp?.views)) continue;
122
+ for (const [view, entry] of Object.entries(sp.views)) {
123
+ if (typeof entry !== "function") continue;
124
+ // Display only: map poses never apply on export, so there is no second purpose to probe.
125
+ const probe = probePlace(sp, { view, purpose: "display", p, d });
126
+ if (!readable(probe) || probe.baseHash === CANONICAL) continue;
127
+ out.push(err("view-pose-not-rigid",
128
+ `sub-part "${name}"'s pose for view "${view}" reshapes the piece`,
129
+ "A view's pose may only translate or rotate its argument — no `scale`, `mirror` or boolean. Bake a resized or reflected form into `build()` so every view shows the same physical piece.",
130
+ `parts.${name}.views.${view}`,
131
+ "view-pose-not-rigid"));
132
+ break; // one finding per sub-part
133
+ }
134
+ }
135
+ return out;
136
+ },
137
+ },
60
138
  ];
@@ -3,6 +3,7 @@
3
3
  // defaults and build but not `views`, and had already drifted from the eval runner's
4
4
  // separate views check. One source of truth ends that split.
5
5
  import { err, warn } from "./finding.js";
6
+ import { isViewsMap, viewsOf } from "../sub-part-views.js";
6
7
  import { SHEET_DOC_ID } from "../sheet/constants.js";
7
8
 
8
9
  // A sub-part that carries a `sheet` declaration but no build was meant to be a sheet
@@ -70,7 +71,7 @@ export const SHAPE_RULES = [
70
71
  id: "missing-views",
71
72
  run: ({ part }) => (isPlainObject(part?.views) && Object.keys(part.views).length > 0 ? [] : [
72
73
  err("missing-views", "the part has no `views` map",
73
- "Add a top-level `views` object — e.g. `views: { main: { label: \"Main\" } }` — and list each view name in the owning sub-part's `views` array.",
74
+ "Add a top-level `views` object — e.g. `views: { main: { label: \"Main\" } }` — and give each sub-part a `views` map naming the views it appears in.",
74
75
  "views"),
75
76
  ]),
76
77
  },
@@ -81,15 +82,17 @@ export const SHAPE_RULES = [
81
82
  if (known.size === 0) return []; // missing-views already reported it; don't pile on
82
83
  const out = [];
83
84
  for (const [name, sp] of partEntries(part)) {
84
- if (!Array.isArray(sp?.views)) continue;
85
- sp.views.forEach((v, i) => {
85
+ const listed = Array.isArray(sp?.views) ? sp.views.map((v, i) => [v, `parts.${name}.views[${i}]`])
86
+ : isViewsMap(sp?.views) ? Object.keys(sp.views).map((v) => [v, `parts.${name}.views.${v}`])
87
+ : [];
88
+ for (const [v, path] of listed) {
86
89
  if (!known.has(v)) {
87
90
  out.push(err("part-view-unknown",
88
91
  `sub-part "${name}" lists view "${v}", which is not in the \`views\` map`,
89
92
  `Add "${v}" to the top-level \`views\` map, or correct the name to one of: ${[...known].join(", ")}.`,
90
- `parts.${name}.views[${i}]`));
93
+ path));
91
94
  }
92
- });
95
+ }
93
96
  }
94
97
  return out;
95
98
  },
@@ -100,12 +103,12 @@ export const SHAPE_RULES = [
100
103
  if (!isPlainObject(part?.views)) return [];
101
104
  const used = new Set();
102
105
  for (const [, sp] of partEntries(part)) {
103
- if (Array.isArray(sp?.views)) for (const v of sp.views) used.add(v);
106
+ for (const v of viewsOf(sp)) used.add(v);
104
107
  }
105
108
  return Object.keys(part.views)
106
109
  .filter((v) => !used.has(v))
107
110
  .map((v) => warn("view-unused", `view "${v}" is not listed by any sub-part`,
108
- `Either add "${v}" to a sub-part's \`views\` array or remove it from the \`views\` map — as it stands the view renders empty.`,
111
+ `Either add "${v}" to a sub-part's \`views\` or remove it from the \`views\` map — as it stands the view renders empty.`,
109
112
  `views.${v}`));
110
113
  },
111
114
  },
@@ -4,6 +4,7 @@
4
4
  // (lint place-not-rigid), and the geometry-free pose probe reports each as a
5
5
  // list of rigid steps — so the display→export map is E · D⁻¹, with no geometry.
6
6
  // Three-free and DOM-free: the pose probe and pose.js are pure.
7
+ import { placeOf } from "../sub-part-views.js";
7
8
  import { probeSubPartPose } from "../pose-probe-core.js";
8
9
  import { poseDelta, composePose } from "../geometry/pose.js";
9
10
 
@@ -14,7 +15,7 @@ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
14
15
  // build that queries its solid cannot make it untrusted); a "posed" mesh already carries the
15
16
  // display pose, so the map is E · D⁻¹.
16
17
  export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
17
- if (!subPart?.place) return [...IDENTITY];
18
+ if (!placeOf(subPart)) return [...IDENTITY];
18
19
  if (frame === "canonical") {
19
20
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
20
21
  return exp.trusted ? composePose(exp.pose) : [...IDENTITY];
@@ -34,7 +35,7 @@ const isIdentity = (m) => m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-12);
34
35
  // readable, and a build that queries its solid cannot make it untrusted. A place()
35
36
  // that only the full-scope probe reads falls back to printFrameMatrix's posed branch.
36
37
  export function displayToPrintMatrix(subPart, { view, p, d }) {
37
- if (!subPart?.place) return null;
38
+ if (!placeOf(subPart)) return null;
38
39
  const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d }, { scope: "place" });
39
40
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
40
41
  const m = disp.trusted && exp.trusted
@@ -17,6 +17,7 @@
17
17
  // (resolve.js's default for every other sub-part) but the sheet its stock label names —
18
18
  // realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
19
19
  // default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
20
+ import { isViewsMap } from "../sub-part-views.js";
20
21
  import { isSheetPart, sheetMeta, MARK_DEPTH } from "../sheet/constants.js";
21
22
  import { poseSteps, validatePose } from "../sheet/pose.js";
22
23
  import { composePose, invertRigid } from "../geometry/pose.js";
@@ -145,6 +146,7 @@ export function burnsFor(sp) {
145
146
  if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
146
147
  if (sp.sheet.place != null) return false; // an author place moves it off its pose
147
148
  if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
149
+ if (isViewsMap(sp.views) && Object.values(sp.views).some((v) => typeof v === "function")) return false; // a pose entry moves it off its pose
148
150
  try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
149
151
  }
150
152
 
@@ -1027,6 +1027,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1027
1027
  // normal is enough to char a whole face. A print frame keeps its last value (the
1028
1028
  // worst it can do is run layer lines the wrong way).
1029
1029
  if (!resolved) { delete sheetFrames[n]; continue; }
1030
+ // For a views-map sub-part the export pose is the build itself (plus a sheet pose), so a canonical
1031
+ // delivery's print frame is identity — no special case, the probe reads the compiled placement.
1030
1032
  if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1031
1033
  if (burning.has(n)) {
1032
1034
  const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
@@ -290,7 +290,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
290
290
  checks.push({ scope: "subpart", subpart: s.name, metric: "bbox", kind: "gate", expr: `<=[${profile.bed.join(",")}]`,
291
291
  actual: null, status: "skip", pass: null, unevaluated: true, note: PRINT_POSE_NOTE,
292
292
  message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
293
- hint: "This printed part's place() failed for purpose \"export\", so its print size — and its STL/3MF export — is unavailable. Fix place() for the export pose; the bed check then runs." });
293
+ hint: "build() should make each piece as it prints — a printed piece shown in an assembly gets its assembled pose from its `views` entry, never from build(). Its export pose failed to build, so its print size — and its STL/3MF export — is unavailable. Fix the pose; the bed check then runs." });
294
294
  }
295
295
  const merged = {
296
296
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
@@ -3,6 +3,7 @@
3
3
  import { recorder, expandDerivedReads } from "./read-recorder.js";
4
4
  import { resolveDerivedAttributed } from "./derive.js";
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
+ import { inView, placeOf } from "./sub-part-views.js";
6
7
  import { byteAwareReplacer } from "./geometry/solid-hash.js";
7
8
  import { viewSubParts } from "./part-model.js";
8
9
 
@@ -21,7 +22,7 @@ export function relevantParamKeys(part, view, params) {
21
22
  // sub-part is currently hidden (on-screen ones are already in `reads`).
22
23
  for (const name of Object.keys(part.parts)) {
23
24
  const sp = part.parts[name];
24
- if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
25
+ if (inView(sp, view) && sp.enabled) sp.enabled(recorder(params, relevant));
25
26
  }
26
27
  return relevant;
27
28
  } catch {
@@ -48,7 +49,8 @@ export function subPartReadKeys(part, view, params) {
48
49
  // place() shapes what's on screen too (display pose is baked into the cached
49
50
  // mesh), so its reads count — without this, a param consumed only by place()
50
51
  // would let the mesh cache skip a rebuild and leave the sub-part misplaced.
51
- if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
52
+ const place = placeOf(sp);
53
+ if (place) place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
52
54
  map.set(name, expandDerivedReads(reads, dSeen, { depsOf, allInputs }));
53
55
  }
54
56
  return map;
@@ -102,7 +104,7 @@ export function recordedRelevantKeys(part, view, params, readsOf) {
102
104
  }
103
105
  for (const name of Object.keys(part.parts)) {
104
106
  const sp = part.parts[name];
105
- if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
107
+ if (inView(sp, view) && sp.enabled) sp.enabled(recorder(params, relevant));
106
108
  }
107
109
  return relevant;
108
110
  } catch {
@@ -11,15 +11,15 @@
11
11
  // oracle depend on the part model without an import cycle back through the job loop.
12
12
  import { resolveDerived, resolveDerivedAttributed } from "./derive.js";
13
13
  import { recorder } from "./read-recorder.js";
14
+ import { inView, placeOf } from "./sub-part-views.js";
14
15
 
15
16
  // Names of the sub-parts a view shows: declared in the view and enabled for these
16
17
  // params. Order follows Object.keys(part.parts) (definition order).
17
18
  export function viewSubParts(part, view, params) {
18
19
  return Object.keys(part.parts).filter((name) => {
19
20
  const sp = part.parts[name];
20
- const inView = sp.views.includes(view);
21
21
  const on = sp.enabled ? !!sp.enabled(params) : true;
22
- return inView && on;
22
+ return inView(sp, view) && on;
23
23
  });
24
24
  }
25
25
 
@@ -70,12 +70,13 @@ export function resolveParams(part, params, sanitize, reads) {
70
70
  return recordedParams(resolved, reads);
71
71
  }
72
72
 
73
- // Build one sub-part and apply its optional place() for the given purpose/view.
73
+ // Build one sub-part and apply its placement (sub-part-views.js placeOf — a legacy place() or the compiled views map) for the given purpose/view.
74
74
  // `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
75
75
  // sub-part solid" — the worker, the collision check, and the test harness all call
76
76
  // it, so display/export poses can never drift between the app and its tests.
77
77
  export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
78
78
  const sp = part.parts[name];
79
79
  const solid = sp.build(kernel, p, d, onProgress);
80
- return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
80
+ const place = placeOf(sp);
81
+ return place ? place(solid, { view, purpose, p, d }) : solid;
81
82
  }
@@ -16,6 +16,7 @@
16
16
  // same source text at every value of `p.z`, so hashing it would hold baseHash
17
17
  // stable while the real geometry changed — precisely the false-positive the fast
18
18
  // path must never make. Untrusted subparts simply take the normal regen path.
19
+ import { placeOf } from "./sub-part-views.js";
19
20
  import { h } from "./geometry/solid-hash.js";
20
21
  import { addSugar } from "./geometry/solid-sugar.js";
21
22
  import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
@@ -127,15 +128,16 @@ const stepsFinite = (steps) => steps.every((st) =>
127
128
  export function probeSubPartPose(sp, { view, purpose = "display", p, d }, { scope = "full" } = {}) {
128
129
  try {
129
130
  const { kernel, state, token } = makeProbeSession(); // fresh op budget + trust per subpart
131
+ const place = placeOf(sp);
130
132
  if (scope === "place") {
131
133
  let s = token(CANONICAL, []);
132
- if (!sp.place) return { trusted: true, baseHash: CANONICAL, pose: [] };
133
- s = sp.place(s, { view, purpose, p, d });
134
+ if (!place) return { trusted: true, baseHash: CANONICAL, pose: [] };
135
+ s = place(s, { view, purpose, p, d });
134
136
  if (!(s && s.__poseToken) || state.queried || state.unhashable || !stepsFinite(s._pose)) return { trusted: false };
135
137
  return { trusted: s._hash === CANONICAL, baseHash: s._hash, pose: s._pose };
136
138
  }
137
139
  let s = sp.build(kernel, p, d);
138
- if (scope === "full" && sp.place) s = sp.place(s, { view, purpose, p, d });
140
+ if (scope === "full" && place) s = place(s, { view, purpose, p, d });
139
141
  const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
140
142
  return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
141
143
  } catch {
@@ -15,7 +15,11 @@
15
15
  // and `sp.place === sp.sheet.generatedPlace`.
16
16
  //
17
17
  // Placement (decision 10): the pose applies for display AND export, then the author's
18
- // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
18
+ // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled. With a views
19
+ // map, the generated place is the BASE of the compiled placement (sub-part-views.js
20
+ // placeOf): the pose applies for display and export, then the view's entry for display
21
+ // only. Sheet entries are taught as `true`; a pose entry is allowed but drops the burn
22
+ // pass (sheet-look.js burnsFor).
19
23
  import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
20
24
  import { validatePose } from "./pose.js";
21
25
  import { applyPose, resolveSheet, sheetPreview } from "./resolve.js";
@@ -0,0 +1,56 @@
1
+ // One sub-part's views and placement, in either authoring form.
2
+ //
3
+ // map form: views: { assembly: (s, p, d) => s.translate(…), lid: true }
4
+ // legacy form: views: ["assembly", "lid"], place: (s, { view, purpose, p, d }) => s
5
+ //
6
+ // Every reader of a sub-part's views or placement asks here, so the map form compiles to
7
+ // the same place(s, { view, purpose, p, d }) the pose probe, the pose ladder, print frames
8
+ // and the oracle already understand (spec 2026-10-04 "Compilation"). A map entry is `true`
9
+ // (shown as built) or a rigid pose; anything else leaves the piece out of that view and is
10
+ // lint's view-entry-invalid. Export never applies an entry: a map-form piece exports as
11
+ // built. A base `place` on a map-form sub-part can only be sheetPart's generated pose
12
+ // (lint's views-and-place refuses an author's), and it runs first for both purposes, as
13
+ // sheet poses always have.
14
+ //
15
+ // Pure and import-free: lint rules import it (test/lint-purity.test.js).
16
+
17
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
18
+ const isEntry = (v) => v === true || typeof v === "function";
19
+
20
+ export const isViewsMap = (views) => isPlainObject(views);
21
+
22
+ export function viewsOf(sp) {
23
+ const v = sp?.views;
24
+ if (Array.isArray(v)) return v;
25
+ if (isPlainObject(v)) return Object.keys(v).filter((name) => isEntry(v[name]));
26
+ return [];
27
+ }
28
+
29
+ export const inView = (sp, view) => viewsOf(sp).includes(view);
30
+
31
+ export const formOf = (sp) => (isViewsMap(sp?.views) ? "map" : "legacy");
32
+
33
+ // Memo only: never identity-compared across module instances (the cloud worker loads two).
34
+ const compiled = new WeakMap();
35
+
36
+ export function placeOf(sp) {
37
+ if (!sp) return null;
38
+ if (compiled.has(sp)) return compiled.get(sp);
39
+ if (!isViewsMap(sp.views)) {
40
+ // A wrapper, so a method-style place keeps `this` when callers invoke it detached.
41
+ const legacy = typeof sp.place === "function" ? (s, ctx) => sp.place(s, ctx) : null;
42
+ compiled.set(sp, legacy);
43
+ return legacy;
44
+ }
45
+ const base = typeof sp.place === "function" ? sp.place : null;
46
+ const entries = sp.views;
47
+ const anyPose = Object.values(entries).some((v) => typeof v === "function");
48
+ const fn = !base && !anyPose ? null : (s, ctx) => {
49
+ const posed = base ? base(s, ctx) : s;
50
+ if (ctx.purpose === "export") return posed;
51
+ const entry = entries[ctx.view];
52
+ return typeof entry === "function" ? entry(posed, ctx.p, ctx.d) : posed;
53
+ };
54
+ compiled.set(sp, fn);
55
+ return fn;
56
+ }
@@ -1,7 +1,7 @@
1
1
  // Animation reference part — a box with a hinged lid. Worked example for
2
2
  // docs/AUTHORING-PARTS.md "Animations": animations are VIEW-OWNED (declared
3
3
  // under `views.box.animations`, so the transport bar belongs to that view),
4
- // they drive pose-only params (lidAngle, lidLift) through place(), and
4
+ // they drive pose-only params (lidAngle, lidLift) through the lid's `views` entry, and
5
5
  // `assemble` opens with an OPACITY fade that brings the lid in from nothing
6
6
  // before any motion. Also shown: an intro camera + markdown description on
7
7
  // `open`, a looping autoplay `cycle`, and per-step cameras on `assemble`.
@@ -39,7 +39,7 @@ export default {
39
39
  parts: {
40
40
  base: {
41
41
  label: "Base",
42
- views: ["box"],
42
+ views: { box: true },
43
43
  export: { name: "base" },
44
44
  display: { material: "walnut" },
45
45
  build: (k, p) =>
@@ -48,19 +48,18 @@ export default {
48
48
  },
49
49
  lid: {
50
50
  label: "Lid",
51
- views: ["box"],
52
51
  export: { name: "lid" },
53
52
  display: { material: "walnut" },
54
- build: (k, p) => k.box({ min: [0, 0, p.height], max: [p.width, p.depth, p.height + p.wall] }),
55
- // Display: swing about the hinge line (rear top edge, axis +X through
56
- // [0, depth, height]; negative angle opens upward), then the assembly
57
- // lift. Export: the lid prints flat beside the base. Both poses are
58
- // rigid motions of the same solid, and neither reads `view` — the two
59
- // invariants lint's place rules hold every part to.
60
- place: (s, { purpose, p }) =>
61
- purpose === "export"
62
- ? s.translate([p.width + 10, 0, -p.height])
63
- : s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
53
+ // Built as it prints: a flat plate at the origin. The `box` view sets it on the
54
+ // base, swings it about the hinge line (rear top edge, axis +X through
55
+ // [0, depth, height]; negative angle opens upward) and applies the assembly
56
+ // lift. lidAngle and lidLift are read only here, so both animate at frame rate.
57
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.width, p.depth, p.wall] }),
58
+ views: {
59
+ box: (s, p) => s.translate([0, 0, p.height])
60
+ .rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0])
61
+ .translate([0, 0, p.lidLift]),
62
+ },
64
63
  },
65
64
  },
66
65
  views: {
@@ -32,17 +32,16 @@ function hinge(k, p, d) {
32
32
  return k.union([fixed, moving]);
33
33
  }
34
34
 
35
- // Display: flat hinge against the back panel's outside face, knuckles above the wall.
36
- // Export: the print pose, untouched — both hinges export the same solid.
37
- const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
38
- : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
35
+ // Each hinge is built in its PRINT pose (above); the box view stands it up on the back
36
+ // panel. Identical builds, so the kit still prints "hinge" ×2.
37
+ const hingePose = (i) => (s, p, d) => s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]);
39
38
 
40
39
  // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
41
40
  const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
41
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
42
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
43
 
45
- const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
44
+ const PLY = { views: { box: true }, display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
46
45
  const panel = (name, label, extra = {}) => sheetPart({
47
46
  ...PLY, label,
48
47
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -97,10 +96,10 @@ export default {
97
96
  .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
98
97
  pose: (p, d) => d.lidPose,
99
98
  }),
100
- hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
101
- export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
102
- hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
103
- build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
99
+ hingeL: { label: "Hinge (left)", views: { box: hingePose(0) }, display: { material: "pla-print" },
100
+ export: { name: "hinge" }, build: hinge },
101
+ hingeR: { label: "Hinge (right)", views: { box: hingePose(1) }, display: { material: "pla-print" },
102
+ build: hinge }, // identical solid: the kit prints "hinge" ×2
104
103
  },
105
104
  views: { box: { label: "Box" } },
106
105
  // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.
@@ -0,0 +1,110 @@
1
+ // Worked example for the views map (docs/AUTHORING-PARTS.md "The PartDefinition
2
+ // contract"): a storage box with a hinged lid, a lattice insert in the lid window, a
3
+ // nameplate on the front and a filament-offcut hinge pin. Every piece is ONE sub-part,
4
+ // built as it prints; each view entry only moves it — `assembly` puts it together and
5
+ // swings the lid, the piece tabs show it as built, `print` spreads the printed pieces
6
+ // on the bed. lidAngle is read only in view entries, so the Open lid animation never
7
+ // rebuilds geometry, and layer lines follow each piece's print orientation.
8
+
9
+ // The one hinge every piece that rides on the lid swings about.
10
+ const swing = (s, p, d) => s.rotate(-p.lidAngle, d.hinge, [1, 0, 0]);
11
+
12
+ export default {
13
+ meta: { title: "Lattice Box", units: "mm" },
14
+ parameters: [
15
+ {
16
+ id: "box", title: "Box",
17
+ advanced: [
18
+ { key: "w", label: "Width", unit: "mm", min: 50, max: 140, step: 1 },
19
+ { key: "d", label: "Depth", unit: "mm", min: 40, max: 120, step: 1 },
20
+ { key: "h", label: "Height", unit: "mm", min: 20, max: 80, step: 1 },
21
+ ],
22
+ },
23
+ {
24
+ id: "pose", title: "Pose",
25
+ advanced: [{ key: "lidAngle", label: "Lid angle", unit: "°", min: 0, max: 110, step: 1 }],
26
+ },
27
+ ],
28
+ defaults: { w: 80, d: 60, h: 40, wall: 2, t: 2.4, lidAngle: 0 },
29
+ derive: (p) => ({
30
+ hinge: [0, p.d + 1, p.h], // just behind the rear top edge, along X
31
+ win: { x: 12, y: 12, w: p.w - 24, d: p.d - 24 }, // the lid's window
32
+ fit: 0.2,
33
+ gap: 10, // spacing in the print view
34
+ }),
35
+ parts: {
36
+ body: {
37
+ label: "Body",
38
+ display: { color: 0x1e88e5 },
39
+ // Open-top box; prints open side up, as built.
40
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.d, p.h] })
41
+ .cut(k.box({ min: [p.wall, p.wall, p.wall], max: [p.w - p.wall, p.d - p.wall, p.h + 1] })),
42
+ views: { assembly: true, body: true, print: true },
43
+ },
44
+ lid: {
45
+ label: "Lid",
46
+ display: { color: 0xe53935 },
47
+ // A flat plate with the lattice window, printed flat.
48
+ build: (k, p, d) => k.box({ min: [0, 0, 0], max: [p.w, p.d, p.t] })
49
+ .cut(k.box({ min: [d.win.x, d.win.y, -1], max: [d.win.x + d.win.w, d.win.y + d.win.d, p.t + 1] })),
50
+ views: {
51
+ assembly: (s, p, d) => swing(s.translate([0, 0, p.h]), p, d),
52
+ lid: true,
53
+ print: (s, p, d) => s.translate([p.w + d.gap, 0, 0]),
54
+ },
55
+ },
56
+ mesh: {
57
+ label: "Lattice insert",
58
+ display: { color: 0x222222 },
59
+ // Fills the window less the fit, with a staggered grid of round holes; printed flat.
60
+ build: (k, p, d) => {
61
+ const W = d.win.w - 2 * d.fit, D = d.win.d - 2 * d.fit;
62
+ let plate = k.box({ min: [0, 0, 0], max: [W, D, p.t] });
63
+ let row = 0;
64
+ for (let y = 4; y <= D - 4; y += 6, row++) {
65
+ for (let x = 4 + (row % 2) * 3; x <= W - 4; x += 6) {
66
+ plate = plate.cut(k.cylinder({ r: 2.2, h: p.t + 2 }).translate([x, y, -1]));
67
+ }
68
+ }
69
+ return plate;
70
+ },
71
+ views: {
72
+ assembly: (s, p, d) => swing(s.translate([d.win.x + d.fit, d.win.y + d.fit, p.h]), p, d),
73
+ mesh: true,
74
+ print: (s, p, d) => s.translate([p.w + d.gap, p.d + d.gap, 0]),
75
+ },
76
+ },
77
+ plate: {
78
+ label: "Nameplate",
79
+ display: { color: 0xffd400 },
80
+ // A thin plate, printed flat; stands just off the front of the box (0.2 mm clear) in the assembly.
81
+ build: (k) => k.box({ min: [0, 0, 0], max: [40, 12, 1.6] }),
82
+ views: {
83
+ assembly: (s, p) => s.rotateX(90).translate([(p.w - 40) / 2, -0.2, p.h / 2 - 6]),
84
+ plate: true,
85
+ print: (s, p, d) => s.translate([0, p.d + d.gap, 0]),
86
+ },
87
+ },
88
+ pin: {
89
+ label: "Hinge pin (1.75 mm filament)",
90
+ exportable: false,
91
+ display: { color: 0x9e9e9e },
92
+ build: (k, p) => k.cylinder({ r: 0.875, h: p.w }).rotate(90, [0, 0, 0], [0, 1, 0]),
93
+ views: { assembly: (s, p, d) => s.translate(d.hinge) },
94
+ },
95
+ },
96
+ views: {
97
+ assembly: {
98
+ label: "Assembly",
99
+ animations: {
100
+ open: { label: "Open lid", duration: 1.2, tracks: { lidAngle: [[0, 0], [1, 110]] } },
101
+ },
102
+ },
103
+ body: { label: "Body" },
104
+ lid: { label: "Lid" },
105
+ mesh: { label: "Lattice insert" },
106
+ plate: { label: "Nameplate" },
107
+ print: { label: "Print bed" },
108
+ },
109
+ verify: { process: "fdm-pla", expect: { _view: { overlaps: 0 } } },
110
+ };
@@ -21,7 +21,7 @@ export default {
21
21
  parts: {
22
22
  body: {
23
23
  label: "Body",
24
- views: ["assembly"],
24
+ views: { assembly: true },
25
25
  export: { name: "body" },
26
26
  // The t > 0 branch is what drives the routing: the probe re-runs with live
27
27
  // params, so the shell call is only seen (and OCCT only engaged) when the
@@ -33,10 +33,9 @@ export default {
33
33
  },
34
34
  lid: {
35
35
  label: "Lid",
36
- views: ["assembly"],
36
+ views: { assembly: (s) => s.at([0, 0, 12]) },
37
37
  export: { name: "lid" },
38
38
  build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 2] }),
39
- place: (s) => s.at([0, 0, 12]),
40
39
  },
41
40
  },
42
41
  views: { assembly: { label: "Assembly" } },
@@ -317,7 +317,7 @@ export interface SheetPartSpec<P = ResolvedParams, D = Derived> {
317
317
  pose?: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
318
318
  process?: "laser";
319
319
  label?: string;
320
- views: string[];
320
+ views: string[] | Record<string, true | ((s: Solid, p: P, d: D) => Solid)>;
321
321
  display?: SubPartDefinition<P, D>["display"];
322
322
  export?: { name: string };
323
323
  enabled?: (p: P) => unknown;
package/types/part.d.ts CHANGED
@@ -350,6 +350,7 @@ export type DeriveSpec<P = ResolvedParams, D = Derived> =
350
350
 
351
351
  // --- sub-parts --------------------------------------------------------------
352
352
 
353
+ /** The argument to the LEGACY `place` (views-array form); the `views` map form needs none. */
353
354
  export interface PlaceContext<P = ResolvedParams, D = Derived> {
354
355
  /** The active view. Display placement must NOT depend on it. */
355
356
  view: string;
@@ -367,12 +368,18 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
367
368
  */
368
369
  build: (k: GeometryKernel, p: P, d: D, onProgress?: (phase: string) => void) => Solid;
369
370
  /**
370
- * Optional reposition (default identity), for a part whose display pose
371
+ * LEGACY form (with a `views` array); with a `views` map put each pose in its
372
+ * entry instead. Optional reposition (default identity), for a part whose display pose
371
373
  * differs from its export pose. Any such difference must be a RIGID motion.
372
374
  */
373
375
  place?: (solid: Solid, ctx: PlaceContext<P, D>) => Solid;
374
- /** Which views show this sub-part. Every name must exist in `views`. */
375
- views: string[];
376
+ /**
377
+ * Each view the sub-part appears in: a map of view name to `true` (shown as
378
+ * built) or a rigid pose `(s, p, d) => s.translate(…)`. Every name must exist in
379
+ * the part's `views`. The legacy form, an array of names (placed by `place`),
380
+ * still works.
381
+ */
382
+ views: string[] | Record<string, true | ((s: Solid, p: P, d: D) => Solid)>;
376
383
  /** Gate a conditional sub-part. Coerced with `!!`. */
377
384
  enabled?: (p: P) => unknown;
378
385
  /** `false` = reference/preview-only: shown in the viewer, never exported. */