partforge 0.142.0 → 0.143.1
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/geometry/probe.js +24 -3
- package/src/framework/imports.js +11 -1
- 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
|
+
}
|
|
@@ -51,6 +51,29 @@ function makeProbe(onCall) {
|
|
|
51
51
|
toSTL: () => new ArrayBuffer(0),
|
|
52
52
|
toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
|
|
53
53
|
};
|
|
54
|
+
// Solid's optional mesh-topology queries return numbers/booleans, not a handle.
|
|
55
|
+
Object.assign(solidQueries, { genus: () => 0, isEmpty: () => false });
|
|
56
|
+
// Shape2D ops that return DATA rather than another Shape2D. Without an entry here an
|
|
57
|
+
// op falls through to the chaining branch and hands back the Shape2D handle itself,
|
|
58
|
+
// so ordinary array/number use of the result (`.corners().map(…)`, `.area() * 2`)
|
|
59
|
+
// was recorded as an unknown Shape2D op and failed lint as `unknown-solid-op`.
|
|
60
|
+
// `regions()` returns live Shape2Ds, so its dummy is an array holding the handle.
|
|
61
|
+
// The dummy region is a unit square in the contour IR (closed: last point = first).
|
|
62
|
+
const square = [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]];
|
|
63
|
+
const shape2dQueries = {
|
|
64
|
+
...solidQueries,
|
|
65
|
+
isEmpty: () => false,
|
|
66
|
+
area: () => 1,
|
|
67
|
+
boundingBox: () => ({ min: [0, 0], max: [1, 1], center: [0.5, 0.5], size: [1, 1] }),
|
|
68
|
+
toRegions: () => [{ outer: square.map((p) => [...p]), holes: [] }],
|
|
69
|
+
simple: () => ({ outer: square.map((p) => [...p]), holes: [] }),
|
|
70
|
+
toContours: () => [{ outer: { start: [0, 0], segments: square.slice(1).map((p) => ({ to: [...p] })) }, holes: [] }],
|
|
71
|
+
corners: () => [0, 1, 2, 3].map((i) => ({
|
|
72
|
+
index: i, position: i, point: [...square[i]], interiorAngleDeg: 90, convex: true, segTypes: ["line", "line"],
|
|
73
|
+
})),
|
|
74
|
+
contains: () => false,
|
|
75
|
+
regions: () => [shape2d],
|
|
76
|
+
};
|
|
54
77
|
const kernelQueries = {
|
|
55
78
|
toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
|
|
56
79
|
cleanup: () => {},
|
|
@@ -82,10 +105,8 @@ function makeProbe(onCall) {
|
|
|
82
105
|
},
|
|
83
106
|
});
|
|
84
107
|
|
|
85
|
-
// Shape2D handles reuse solidQueries: boundingBox/volume/… dummies chain the same
|
|
86
|
-
// either way, and a 3-component bbox is as good a dummy as a 2-component one.
|
|
87
108
|
const proxy = opProxy(solidQueries, "solid", () => proxy); // a solid handle: every op chains back to itself
|
|
88
|
-
const shape2d = opProxy(
|
|
109
|
+
const shape2d = opProxy(shape2dQueries, "shape2d",
|
|
89
110
|
(key) => (SOLID_YIELDING_SHAPE2D_OPS.has(key) ? proxy : shape2d));
|
|
90
111
|
const kernel = opProxy(kernelQueries, "kernel", // factory ops (cylinder/box/prism/…) return a solid
|
|
91
112
|
(key) => (SHAPE2D_YIELDING_KERNEL_OPS.has(key) ? shape2d : proxy));
|
package/src/framework/imports.js
CHANGED
|
@@ -10,11 +10,21 @@ import { parse3MF } from "./geometry/threemf-parse.js";
|
|
|
10
10
|
|
|
11
11
|
const EXT = { step: "step", stp: "step", stl: "stl", "3mf": "3mf" };
|
|
12
12
|
|
|
13
|
+
// Font files declared as an import. Without this a TTF fell through to the STL
|
|
14
|
+
// fallback below and failed as "binary STL truncated: header says 3848966777
|
|
15
|
+
// triangles" — an error about a mesh the author never meant (cloud feedback #148).
|
|
16
|
+
const FONT_EXT = new Set(["ttf", "otf", "woff", "woff2"]);
|
|
17
|
+
const FONT_MAGIC = ["\x00\x01\x00\x00", "OTTO", "true", "ttcf", "wOFF", "wOF2"];
|
|
18
|
+
|
|
13
19
|
export function detectFormat(source, bytes) {
|
|
14
20
|
const path = source instanceof URL ? source.pathname : typeof source === "string" ? source.split("?")[0] : null;
|
|
15
21
|
const ext = path?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase();
|
|
16
|
-
if (ext && EXT[ext]) return EXT[ext];
|
|
17
22
|
const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
|
|
23
|
+
const magic = u8 && u8.length >= 4 ? String.fromCharCode(...u8.slice(0, 4)) : "";
|
|
24
|
+
if ((ext && FONT_EXT.has(ext)) || FONT_MAGIC.includes(magic)) {
|
|
25
|
+
throw new Error(`a font file${path ? ` ("${path}")` : ""} is declared under \`imports\` — declare it under \`fonts\` and use it with text2d`);
|
|
26
|
+
}
|
|
27
|
+
if (ext && EXT[ext]) return EXT[ext];
|
|
18
28
|
if (u8 && u8.length > 0) {
|
|
19
29
|
const head = String.fromCharCode(...u8.slice(0, 64));
|
|
20
30
|
if (head.startsWith("ISO-10303-21")) return "step";
|
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 };
|