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 +1 -1
- package/docs/AUTHORING-PARTS.md +103 -58
- package/docs/ERROR-PATTERNS.md +25 -1
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/framework/default-view.js +2 -1
- package/src/framework/export/bed-layout.js +62 -0
- package/src/framework/jobs.js +15 -5
- package/src/framework/lint/rules-animations.js +4 -3
- package/src/framework/lint/rules-place.js +81 -3
- package/src/framework/lint/rules-shape.js +10 -7
- package/src/framework/materials/print-frame.js +3 -2
- package/src/framework/materials/sheet-look.js +2 -0
- package/src/framework/mount.js +2 -0
- package/src/framework/oracle/verify.js +1 -1
- package/src/framework/param-deps.js +5 -3
- package/src/framework/part-model.js +5 -4
- package/src/framework/pose-probe-core.js +5 -3
- package/src/framework/sheet/part.js +5 -1
- package/src/framework/sub-part-views.js +56 -0
- package/src/parts/hinged-box.js +12 -13
- package/src/parts/laser-box.js +8 -9
- package/src/parts/lattice-box.js +110 -0
- package/src/parts/mixed-smoke.js +2 -3
- package/types/geometry.d.ts +1 -1
- package/types/part.d.ts +10 -3
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 `
|
|
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.
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
-
|
|
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?)`
|
|
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
|
-
- `
|
|
104
|
-
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
`
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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`
|
|
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 `
|
|
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
|
-
|
|
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
|
|
389
|
-
|
|
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
|
|
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 `
|
|
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`). `
|
|
799
|
-
reads
|
|
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 `
|
|
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:
|
|
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`,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2025
|
-
//
|
|
2026
|
-
const
|
|
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:
|
|
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:
|
|
2090
|
-
export: { name: "hinge" }, build: hinge
|
|
2091
|
-
hingeR: { label: "Hinge (right)", views:
|
|
2092
|
-
build: hinge
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
**
|
|
3723
|
-
one animation's `animation-track-rebuilds` uses) against each
|
|
3724
|
-
`
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
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 `
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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 `
|
|
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
|
@@ -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 (!
|
|
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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
|
507
|
+
const { layoutExportPieces } = await loadBedLayout();
|
|
508
|
+
const solids = layoutExportPieces(part, names.map((name) => {
|
|
502
509
|
onProgress(`building ${label(name)}`);
|
|
503
|
-
return { name
|
|
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
|
|
518
|
+
const { layoutExportPieces } = await loadBedLayout();
|
|
519
|
+
const meshes = layoutExportPieces(part, names.map((name) => {
|
|
512
520
|
onProgress(`building ${label(name)}`);
|
|
513
|
-
|
|
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
|
|
250
|
-
if (!
|
|
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 (!
|
|
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]) =>
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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\`
|
|
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
|
|
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
|
|
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
|
|
package/src/framework/mount.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
133
|
-
s =
|
|
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" &&
|
|
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
|
+
}
|
package/src/parts/hinged-box.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
55
|
-
//
|
|
56
|
-
// [0, depth, height]; negative angle opens upward)
|
|
57
|
-
// lift.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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: {
|
package/src/parts/laser-box.js
CHANGED
|
@@ -32,17 +32,16 @@ function hinge(k, p, d) {
|
|
|
32
32
|
return k.union([fixed, moving]);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const
|
|
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:
|
|
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:
|
|
101
|
-
export: { name: "hinge" }, build: hinge
|
|
102
|
-
hingeR: { label: "Hinge (right)", views:
|
|
103
|
-
build: hinge
|
|
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
|
+
};
|
package/src/parts/mixed-smoke.js
CHANGED
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
parts: {
|
|
22
22
|
body: {
|
|
23
23
|
label: "Body",
|
|
24
|
-
views:
|
|
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: [
|
|
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" } },
|
package/types/geometry.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
375
|
-
|
|
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. */
|