partforge 0.141.0 → 0.143.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/AUTHORING-PARTS.md +107 -63
- package/docs/ERROR-PATTERNS.md +25 -7
- 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 +91 -35
- package/src/framework/lint/rules-shape.js +10 -7
- package/src/framework/materials/print-frame.js +20 -1
- package/src/framework/materials/sheet-look.js +2 -0
- package/src/framework/mount.js +2 -0
- package/src/framework/oracle/measure.js +28 -8
- 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,19 +3753,21 @@ key, which the runtime ignores), `animations-not-object`,
|
|
|
3714
3753
|
`animation-autoplay-invalid` (all errors). One
|
|
3715
3754
|
more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
|
|
3716
3755
|
each track's endpoint values and emits a **note** when the animated param feeds
|
|
3717
|
-
real geometry, or when its
|
|
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
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3761
|
+
**View-pose invariants**, found by running the geometry-free pose probe (the same
|
|
3762
|
+
one animation's `animation-track-rebuilds` uses) against each `views` entry, even when
|
|
3763
|
+
`build()` queries the solid — `view-entry-invalid` (an entry that is neither `true` nor a
|
|
3764
|
+
pose function), `views-and-place` (an author `place` beside a `views` map) and
|
|
3765
|
+
`view-pose-not-rigid` (an entry that reshapes instead of moving), `views-invalid` (a sub-part
|
|
3766
|
+
whose `views` is missing or neither a map nor an array; all errors), and
|
|
3767
|
+
`place-not-rigid` (legacy `place()` form: display vs. export placement may differ only by a
|
|
3768
|
+
rigid motion — translate/rotate — never a reshape; an error). A pose the probe cannot read
|
|
3769
|
+
(it queries the solid or passes a function) stays silent for these rules and earns the
|
|
3770
|
+
`animation-track-rebuilds` note.
|
|
3730
3771
|
|
|
3731
3772
|
**Appearance** (all warnings) — `unknown-material` (a `display.material` the
|
|
3732
3773
|
library does not know; the viewer draws it as if it named none — a PLA print
|
|
@@ -3994,7 +4035,9 @@ a **min-wall** warning, and — only for a part that also declares
|
|
|
3994
4035
|
`orientation: "print"` — an **overhang** warning: `overhangArea` is the mm² of
|
|
3995
4036
|
downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
|
|
3996
4037
|
on the FDM profiles; resin carries none, since it prints on supports), measured per
|
|
3997
|
-
sub-part
|
|
4038
|
+
sub-part in its print (export) pose — not as the view shows it — with the bed at that
|
|
4039
|
+
pose's own lowest Z, and warned past 1 mm²; the reported location is where the face sits
|
|
4040
|
+
in the view. The
|
|
3998
4041
|
opt-in is deliberate: a profile says what a process can print, the orientation key
|
|
3999
4042
|
says this part is laid out for it, and a part still being shaped, or one bound for a
|
|
4000
4043
|
different process, should not be nagged about its underside. Writing your own
|
|
@@ -4006,7 +4049,7 @@ counted: the footprint itself, and faces whose centroid sits within 1 mm of the
|
|
|
4006
4049
|
flat underside spanning two supports) and the ceiling of a horizontal bore are
|
|
4007
4050
|
reported as overhangs — the mesh alone cannot tell a bridge from a ceiling — which
|
|
4008
4051
|
is why this is a warning and never a gate. Two more limits, stated: it is judged in
|
|
4009
|
-
the DISPLAY pose, so a sub-part whose `
|
|
4052
|
+
the DISPLAY pose, so a sub-part whose `assembly` pose stands it differently from how it prints (a lid that prints
|
|
4010
4053
|
flat beside its base) is measured as displayed, and `exportable: false` sub-parts
|
|
4011
4054
|
are skipped. Switch it off under an FDM profile with an inline
|
|
4012
4055
|
`{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
|
|
@@ -4386,6 +4429,9 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
|
|
|
4386
4429
|
- **`build` is a pure function of `(k, p, d)`** — impurity silently defeats the geometry
|
|
4387
4430
|
cache ([impure-build-stale-preview](ERROR-PATTERNS.md#impure-build-stale-preview)).
|
|
4388
4431
|
- **Units are millimetres** throughout.
|
|
4432
|
+
- **One sub-part per physical piece, built as it prints; `views` poses it.** A `views`
|
|
4433
|
+
entry is `true` or a rigid pose `(s, p, d) => s` — never a second "print" copy, never a
|
|
4434
|
+
reshape ([view-pose-not-rigid](ERROR-PATTERNS.md#view-pose-not-rigid)).
|
|
4389
4435
|
- **Never sample an arc into points by hand.** A `Math.cos` loop hides the sweep direction
|
|
4390
4436
|
in a sign, and a wrong sign produces a self-crossing outline that builds with inverted
|
|
4391
4437
|
fill and no error — only a `profile-self-intersects` warning
|
|
@@ -4429,8 +4475,6 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
|
|
|
4429
4475
|
232 and a box is 12, and every one is a boolean operand — so prefer instancing one
|
|
4430
4476
|
union of a row over a chain of per-feature booleans, and drop counts the print
|
|
4431
4477
|
cannot show.
|
|
4432
|
-
- **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
|
|
4433
|
-
depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
|
|
4434
4478
|
- **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
|
|
4435
4479
|
([probe-routed-to-occt](ERROR-PATTERNS.md#probe-routed-to-occt),
|
|
4436
4480
|
[occt-holes-watertight-na](ERROR-PATTERNS.md#occt-holes-watertight-na)).
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -138,18 +138,36 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
|
|
|
138
138
|
- **Cause:** OCCT and Manifold WASM must not boot in the same process.
|
|
139
139
|
- **Fix:** Keep OCCT-booting tests in their own files (vitest isolates per file) and boot via `bootOcctKernel()` in a `beforeAll`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Testing a part".
|
|
140
140
|
|
|
141
|
-
## view-dependent-display-place
|
|
142
|
-
|
|
143
|
-
- **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
|
|
144
|
-
- **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
|
|
145
|
-
- **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
146
|
-
|
|
147
141
|
## place-not-rigid
|
|
148
142
|
|
|
149
143
|
- **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
|
|
150
|
-
- **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
|
|
144
|
+
- **Cause:** A legacy-form (`views` array + `place()`; the views-map form is checked by [view-pose-not-rigid](#view-pose-not-rigid)) `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
|
|
151
145
|
- **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
|
|
152
146
|
|
|
147
|
+
## view-entry-invalid
|
|
148
|
+
|
|
149
|
+
- **Symptom:** A sub-part is missing from a view it names, and lint reports the entry.
|
|
150
|
+
- **Cause:** A `views` map entry that is not `true` or a pose function — `false`, `null`, a string or an object.
|
|
151
|
+
- **Fix:** Use `true` (shown as built) or `(s, p, d) => s.translate(…)`. To leave the piece out of a view, delete the key.
|
|
152
|
+
|
|
153
|
+
## views-and-place
|
|
154
|
+
|
|
155
|
+
- **Symptom:** Lint refuses a sub-part that declares a `views` map and `place`.
|
|
156
|
+
- **Cause:** Two places a pose can live. With a map, each view's entry is its pose and `build()` is the export.
|
|
157
|
+
- **Fix:** Move each view's pose into its entry and delete `place`. If the piece prints in a different orientation from how it is modelled, build it as it prints and move it into place in the `assembly` entry.
|
|
158
|
+
|
|
159
|
+
## view-pose-not-rigid
|
|
160
|
+
|
|
161
|
+
- **Symptom:** The piece in one view is a different size or a mirror image of the one exported.
|
|
162
|
+
- **Cause:** A view entry that scales, mirrors or adds geometry instead of only moving the piece.
|
|
163
|
+
- **Fix:** Entries only `translate`/`rotate`/`rotateAbout`/`at` their argument. Bake a resized or reflected form into `build()`.
|
|
164
|
+
|
|
165
|
+
## views-invalid
|
|
166
|
+
|
|
167
|
+
- **Symptom:** A sub-part is missing from every view, or lint refuses a sub-part whose `views` is absent, a string or a number.
|
|
168
|
+
- **Cause:** `views` must be a map of view name to `true` or a pose (or, in the legacy form, an array of view names). Anything else shows the piece nowhere.
|
|
169
|
+
- **Fix:** Give the sub-part a `views` map — e.g. `views: { assembly: true }` — naming each view it appears in.
|
|
170
|
+
|
|
153
171
|
## wrong-node-version
|
|
154
172
|
|
|
155
173
|
- **Symptom:** Confusing failures during `npm install`, tests, or CLI runs — WASM load errors, syntax errors in dependencies, or kernels that never boot — on a machine that built fine before.
|
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 };
|