partforge 0.146.4 → 0.149.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 +25 -0
- package/docs/AUTHORING-PARTS.md +49 -0
- package/package.json +12 -1
- package/src/framework/app.css +66 -1
- package/src/framework/appearance-card.js +331 -0
- package/src/framework/default-view.js +6 -3
- package/src/framework/gltf/decode-preview.js +89 -0
- package/src/framework/gltf/encode-preview.js +142 -0
- package/src/framework/materials/overrides.js +120 -0
- package/src/framework/materials/thumb-cache.js +33 -0
- package/src/framework/mount-preview.js +157 -0
- package/src/framework/mount.js +247 -52
- package/src/framework/parts-list.js +116 -0
- package/src/framework/preview-gate.js +37 -0
- package/src/framework/sub-part-list.js +44 -0
- package/src/framework/view-style-controls.js +110 -5
- package/src/framework/viewbar-clear.js +23 -0
- package/src/framework/viewer.js +377 -49
- package/src/gltf.js +6 -0
- package/src/index.js +5 -0
- package/src/testing.js +1 -0
- package/types/gltf.d.ts +40 -0
- package/types/index.d.ts +80 -0
- package/types/testing.d.ts +3 -0
package/README.md
CHANGED
|
@@ -169,6 +169,31 @@ Parking releases both large GPU allocations — the drawing buffer and the cache
|
|
|
169
169
|
unchanged, so a host can still take a build screenshot of a hidden viewer; the
|
|
170
170
|
first capture after parking just re-allocates its target.
|
|
171
171
|
|
|
172
|
+
**Show a cached preview while the kernel boots (0.147.0+).** `partforge/gltf`'s
|
|
173
|
+
`encodePreviewGlb({ meshes, part, view })` writes one view's posed display
|
|
174
|
+
meshes as a small GLB — welded, quantized and meshopt-compressed, one node per
|
|
175
|
+
sub-part, Y-up, millimetres — in Node, a page or a worker, and
|
|
176
|
+
`runtime.exportPreview()` encodes the view on screen with it. Hand the file back
|
|
177
|
+
as `mount()`'s `preview` and the viewer draws it before the worker is ready, then
|
|
178
|
+
swaps each sub-part for the live build in place without re-framing;
|
|
179
|
+
`runtime.previewActive` is true in between, while picking, measure, sketch and
|
|
180
|
+
cutaway wait for the live build. A file for another view, or one that will not
|
|
181
|
+
decode, is logged and ignored (a file written without a view is drawn on
|
|
182
|
+
sub-part name match alone). `mountPreview(container, { glb, elements,
|
|
183
|
+
viewerState })` is the kernel-free form — the same viewer, view cube, view style
|
|
184
|
+
and theme, with no part, no worker and no rail — for a page that only shows a
|
|
185
|
+
model (CAD look exact; realistic mode uses each sub-part's display only — no sheet
|
|
186
|
+
stock, no layer-line frames). Previews carry no edge lines, textures or patterns.
|
|
187
|
+
`resolveDefaultView(part)` (main entry and `partforge/testing`) names the view
|
|
188
|
+
`mount()` opens on, so a preview built headlessly matches it.
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
|
|
192
|
+
mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
|
|
193
|
+
const shown = mountPreview(stage, { glb }); // no kernel at all
|
|
194
|
+
await shown.ready;
|
|
195
|
+
```
|
|
196
|
+
|
|
172
197
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
173
198
|
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
174
199
|
`#download`/`#download-step`/`#download-3mf`,
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -493,6 +493,13 @@ the viewer's blue-grey.
|
|
|
493
493
|
maple table), `print-bed` (a dim, hard-lit enclosure over a standard-size PEI build plate (180, 220, 256 or 350 mm) marked with its size), `outdoor` (overcast sky,
|
|
494
494
|
concrete).
|
|
495
495
|
|
|
496
|
+
**Viewers can change a sub-part's look.** The View style popover's Parts
|
|
497
|
+
section lets a user pick a material and colour per sub-part, fade it, or hide
|
|
498
|
+
it, live and without a rebuild (`runtime.setSubPartDisplay` below). Material
|
|
499
|
+
and colour draw exactly as if authored, and a host may write them back into
|
|
500
|
+
`display`; hiding and see-through are only ever how the screen looks — exports,
|
|
501
|
+
`captureViews` and `renderViews` never see them.
|
|
502
|
+
|
|
496
503
|
---
|
|
497
504
|
|
|
498
505
|
## Geometry: the kernel / `Solid` API
|
|
@@ -3299,6 +3306,48 @@ back into the next `mount()` call to resume both where the previous mount
|
|
|
3299
3306
|
left them; omit it on a first mount and the viewer restores its own persisted
|
|
3300
3307
|
choice instead, the same way it does for the camera.
|
|
3301
3308
|
|
|
3309
|
+
### `runtime.setSubPartDisplay`, `runtime.listSubParts`, `runtime.openAppearance`
|
|
3310
|
+
|
|
3311
|
+
A viewer user can change how one sub-part looks, or hide it, without a rebuild.
|
|
3312
|
+
The View style popover (the eye) has a **Parts** section — the sub-parts in the
|
|
3313
|
+
active view, an eye and a swatch each — and the swatch opens an appearance card
|
|
3314
|
+
(material presets, colour for tintable presets, finish, See-through). A host can
|
|
3315
|
+
drive the same state:
|
|
3316
|
+
|
|
3317
|
+
- `runtime.setSubPartDisplay(name, patch)` — merges `patch` over the sub-part's
|
|
3318
|
+
override and returns the override now held (`null` when nothing is left, or
|
|
3319
|
+
for an unknown `name`). Appearance keys (`material`, `color`, `roughness`,
|
|
3320
|
+
`metalness`, `clearcoat`, `clearcoatRoughness`, `anisotropy`, `textureScale`)
|
|
3321
|
+
merge over the authored `display` for every render — CAD, realistic and the
|
|
3322
|
+
`captureView` thumbnail — so a tile picked here is drawn exactly as if it had
|
|
3323
|
+
been authored. Two **viewing** keys are different: `hidden` (boolean) and
|
|
3324
|
+
`opacity` (0.05–1, a multiplier on an authored `display.opacity`). They are
|
|
3325
|
+
how the user is looking: never part of a material read, an export, lint or
|
|
3326
|
+
`declaresMaterials`, and `captureViews` / `renderViews` (the agent's renders)
|
|
3327
|
+
draw every sub-part as if they were unset. `captureCurrent` follows them,
|
|
3328
|
+
like the screen, unless called with `{ includeHidden: true }`. A `null` value
|
|
3329
|
+
clears a key; `hidden: false` and `opacity: 1` clear theirs.
|
|
3330
|
+
- `runtime.getSubPartDisplay(name)` → `{ authored, override, effective, hidden,
|
|
3331
|
+
opacity }`, or `null`.
|
|
3332
|
+
- `runtime.clearSubPartDisplay({ viewing?, appearance?, name? })` — Show all is
|
|
3333
|
+
`{ viewing: true }`; the card's Reset is `{ appearance: true, name }`.
|
|
3334
|
+
- `runtime.onSubPartDisplayChange(cb)` — `cb({ name, override })` after a
|
|
3335
|
+
change, `cb(null)` after a clear; returns the unsubscribe. A host that
|
|
3336
|
+
persists overrides into source (partforge-cloud writes the sub-part's
|
|
3337
|
+
`display` literal) listens here.
|
|
3338
|
+
- `runtime.listSubParts({ view? })` — every sub-part as `{ name, label, inView,
|
|
3339
|
+
hidden, opacity, effective, color, materialLabel, tintable, overridden }`, in
|
|
3340
|
+
definition order, for the active view unless `view` names another.
|
|
3341
|
+
- `runtime.openAppearance(name)` — opens the popover on the card for `name`;
|
|
3342
|
+
`runtime.setDisplaySaveState("saved" | "screen-only" | "blocked")` sets the
|
|
3343
|
+
card's footer line ("screen-only" is the default; a host that saves says so).
|
|
3344
|
+
|
|
3345
|
+
The override map rides `viewerState.display` and comes back through
|
|
3346
|
+
`mount({ viewerState })`, pruned for the new part: an entry for a sub-part the
|
|
3347
|
+
part no longer declares is dropped, and an appearance key that now equals the
|
|
3348
|
+
authored value is dropped (a host that saved the change into source sees its
|
|
3349
|
+
override vanish on the next mount, which is the intended self-clean).
|
|
3350
|
+
|
|
3302
3351
|
### The annotation payload's camera block
|
|
3303
3352
|
|
|
3304
3353
|
`onAnnotationSend(payload)` receives a `camera` block in two frames — `world`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.149.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,6 +62,10 @@
|
|
|
62
62
|
"types": "./types/panel-values.d.ts",
|
|
63
63
|
"default": "./src/panel-values.js"
|
|
64
64
|
},
|
|
65
|
+
"./gltf": {
|
|
66
|
+
"types": "./types/gltf.d.ts",
|
|
67
|
+
"default": "./src/gltf.js"
|
|
68
|
+
},
|
|
65
69
|
"./tokens.css": "./src/framework/tokens.css",
|
|
66
70
|
"./chrome.css": "./src/framework/chrome.css"
|
|
67
71
|
},
|
|
@@ -90,6 +94,9 @@
|
|
|
90
94
|
],
|
|
91
95
|
"panel-values": [
|
|
92
96
|
"./types/panel-values.d.ts"
|
|
97
|
+
],
|
|
98
|
+
"gltf": [
|
|
99
|
+
"./types/gltf.d.ts"
|
|
93
100
|
]
|
|
94
101
|
}
|
|
95
102
|
},
|
|
@@ -120,10 +127,14 @@
|
|
|
120
127
|
}
|
|
121
128
|
},
|
|
122
129
|
"dependencies": {
|
|
130
|
+
"@gltf-transform/core": "^4.5.1",
|
|
131
|
+
"@gltf-transform/extensions": "^4.5.1",
|
|
132
|
+
"@gltf-transform/functions": "^4.5.1",
|
|
123
133
|
"dompurify": "^3.4.11",
|
|
124
134
|
"fflate": "^0.8.3",
|
|
125
135
|
"manifold-3d": "^3.5.1",
|
|
126
136
|
"marked": "^18.0.5",
|
|
137
|
+
"meshoptimizer": "^1.3.0",
|
|
127
138
|
"opentype.js": "^2.0.0",
|
|
128
139
|
"paper": "^0.12.18",
|
|
129
140
|
"pngjs": "^7.0.0",
|
package/src/framework/app.css
CHANGED
|
@@ -774,10 +774,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
774
774
|
}
|
|
775
775
|
.pf-view-style-popover[hidden] { display: none; }
|
|
776
776
|
.pf-view-style-heading { margin: 0; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--pf-muted-2); }
|
|
777
|
-
.pf-view-style-grid { display: grid; grid-template-columns: repeat(
|
|
777
|
+
.pf-view-style-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
|
|
778
778
|
.pf-view-style-tile {
|
|
779
779
|
display: flex; flex-direction: column; gap: 4px; padding: 0; border: 0;
|
|
780
780
|
background: none; color: var(--pf-muted-2); cursor: pointer; font: inherit; text-align: center;
|
|
781
|
+
font-size: 10.5px;
|
|
781
782
|
}
|
|
782
783
|
.pf-view-style-thumb {
|
|
783
784
|
display: block; aspect-ratio: 1; border-radius: 8px; overflow: hidden;
|
|
@@ -796,6 +797,70 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
796
797
|
.pf-view-style-button:focus-visible, .pf-view-style-tile:focus-visible {
|
|
797
798
|
outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
798
799
|
}
|
|
800
|
+
/* The Parts section (parts-list.js): under the style grid, behind a rule. */
|
|
801
|
+
.pf-popover-rule { border: 0; border-top: 1px solid var(--pf-border); margin: 0 -12px; }
|
|
802
|
+
.pf-parts { display: flex; flex-direction: column; gap: 6px; }
|
|
803
|
+
.pf-parts-head { display: flex; align-items: baseline; justify-content: space-between; }
|
|
804
|
+
.pf-parts-showall { font: inherit; font-weight: 500; color: var(--pf-accent); background: none; border: 0; padding: 0; cursor: pointer; }
|
|
805
|
+
.pf-parts-showall[hidden] { display: none; }
|
|
806
|
+
.pf-parts-list { display: flex; flex-direction: column; gap: 2px; }
|
|
807
|
+
.pf-parts-row { display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 4px; padding: 0; border-radius: var(--pf-radius-control); min-width: 0; }
|
|
808
|
+
.pf-parts-open { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-width: 0; padding: 3px 6px 3px 0; border: 0; border-radius: var(--pf-radius-control); background: none; font: inherit; text-align: left; cursor: pointer; }
|
|
809
|
+
.pf-parts-open:hover { background: var(--pf-surface-2); }
|
|
810
|
+
.pf-parts-eye { width: 28px; height: 28px; border: 0; background: none; color: var(--pf-muted-2); border-radius: 6px; display: grid; place-items: center; cursor: pointer; }
|
|
811
|
+
.pf-parts-eye:hover { background: var(--pf-accent-soft); color: var(--pf-accent); }
|
|
812
|
+
.pf-parts-row.hidden .pf-parts-eye { color: var(--pf-hint); }
|
|
813
|
+
.pf-parts-row.faded .pf-parts-eye { opacity: .55; }
|
|
814
|
+
.pf-parts-row.hidden .pf-parts-name { color: var(--pf-hint); }
|
|
815
|
+
.pf-parts-row.hidden .pf-parts-swatch { opacity: .35; }
|
|
816
|
+
.pf-parts-swatch { width: 22px; height: 22px; border-radius: 50%; background: var(--pf-swatch, var(--pf-surface-2)); box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18); }
|
|
817
|
+
.pf-parts-open:hover .pf-parts-swatch { box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18), 0 0 0 2px var(--pf-surface), 0 0 0 4px var(--pf-accent); }
|
|
818
|
+
.pf-parts-name { font-size: 13px; color: var(--pf-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
819
|
+
.pf-parts-mat { font-size: 11.5px; color: var(--pf-muted); white-space: nowrap; }
|
|
820
|
+
.pf-parts-eye:focus-visible, .pf-parts-open:focus-visible, .pf-parts-showall:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
821
|
+
/* The appearance card (appearance-card.js): the popover's second page. The
|
|
822
|
+
popover shows one page at a time by hiding the other's nodes; the grid and
|
|
823
|
+
the Parts section set display, which beats the UA's [hidden] rule, so the
|
|
824
|
+
page switch needs this guard (the #viewbar button[hidden] trap). */
|
|
825
|
+
.pf-view-style-popover > [hidden] { display: none; }
|
|
826
|
+
.pf-card { display: flex; flex-direction: column; gap: 10px; }
|
|
827
|
+
.pf-card[hidden] { display: none; }
|
|
828
|
+
.pf-card-head { display: flex; align-items: center; gap: 4px; }
|
|
829
|
+
.pf-card-title { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; color: var(--pf-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
830
|
+
.pf-card-back, .pf-card-eye, .pf-card-close { width: 28px; height: 28px; border: 0; background: none; color: var(--pf-muted-2); border-radius: 6px; display: grid; place-items: center; cursor: pointer; }
|
|
831
|
+
.pf-card-back:hover, .pf-card-eye:hover, .pf-card-close:hover { background: var(--pf-surface-2); color: var(--pf-text); }
|
|
832
|
+
.pf-card-eye[aria-pressed="true"] { color: var(--pf-hint); }
|
|
833
|
+
.pf-card-row { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: baseline; }
|
|
834
|
+
.pf-card-row input[type="range"] { grid-column: 1 / -1; width: 100%; margin: 0; accent-color: var(--pf-accent); }
|
|
835
|
+
.pf-card-lab-row { display: flex; justify-content: space-between; align-items: baseline; }
|
|
836
|
+
.pf-card-lab { font-size: 12px; color: var(--pf-muted); }
|
|
837
|
+
.pf-card-val { font-size: 12px; color: var(--pf-text-2); font-variant-numeric: tabular-nums; }
|
|
838
|
+
.pf-card-tiles { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
|
|
839
|
+
.pf-card-tile { display: grid; justify-items: center; gap: 3px; border: 0; background: none; padding: 3px 0; border-radius: 8px; cursor: pointer; font: inherit; color: var(--pf-muted); }
|
|
840
|
+
.pf-card-tile:hover { background: var(--pf-surface-2); }
|
|
841
|
+
.pf-card-tile[aria-pressed="true"] { background: var(--pf-accent-soft); color: var(--pf-accent); }
|
|
842
|
+
.pf-card-ball { position: relative; width: 30px; height: 30px; border-radius: 50%; overflow: hidden; background: radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--pf-swatch) 72%, white) 0, var(--pf-swatch) 48%, color-mix(in oklab, var(--pf-swatch) 68%, black) 100%); box-shadow: inset 0 -1px 2px rgb(0 0 0 / .25); }
|
|
843
|
+
.pf-card-ball img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
|
844
|
+
.pf-card-ball img[hidden] { display: none; }
|
|
845
|
+
.pf-card-tile-label { font-size: 9.5px; line-height: 1.1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
846
|
+
.pf-card-unknown { margin: 0; font-size: 11px; color: var(--pf-err); font-family: var(--pf-mono); }
|
|
847
|
+
.pf-card-unknown[hidden] { display: none; }
|
|
848
|
+
.pf-card-colors { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 6px; }
|
|
849
|
+
.pf-card-colors.disabled { opacity: .35; pointer-events: none; }
|
|
850
|
+
.pf-card-color { width: 100%; aspect-ratio: 1; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: var(--pf-swatch); box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18); }
|
|
851
|
+
.pf-card-color.on, .pf-card-color:hover { box-shadow: inset 0 0 0 1px rgb(0 0 0 / .18), 0 0 0 2px var(--pf-surface), 0 0 0 4px var(--pf-accent); }
|
|
852
|
+
.pf-card-native { width: 100%; min-width: 0; aspect-ratio: 1; border: 1px solid var(--pf-border); border-radius: 50%; padding: 0; background: none; cursor: pointer; }
|
|
853
|
+
.pf-card-hex { width: 100%; font: 12px var(--pf-mono); padding: 5px 8px; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); background: var(--pf-input-bg); color: var(--pf-text); }
|
|
854
|
+
.pf-card-hex:disabled { opacity: .5; }
|
|
855
|
+
.pf-card-tint-note { margin: 0; font-size: 12px; color: var(--pf-muted); }
|
|
856
|
+
.pf-card-tint-note[hidden] { display: none; }
|
|
857
|
+
.pf-card-finish-group summary { cursor: pointer; font-size: 12px; color: var(--pf-muted-2); list-style: none; display: flex; align-items: center; gap: 6px; }
|
|
858
|
+
.pf-card-finish-group summary::before { content: ""; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
|
|
859
|
+
.pf-card-finish-group[open] summary::before { transform: rotate(45deg); }
|
|
860
|
+
.pf-card-finish-group .pf-card-row { margin-top: 8px; }
|
|
861
|
+
.pf-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; color: var(--pf-muted); border-top: 1px solid var(--pf-border); padding-top: 10px; }
|
|
862
|
+
.pf-card-reset { font: inherit; font-weight: 500; color: var(--pf-accent); background: none; border: 0; padding: 0; cursor: pointer; white-space: nowrap; }
|
|
863
|
+
.pf-card button:focus-visible, .pf-card input:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
799
864
|
|
|
800
865
|
/* At very narrow widths the full-size viewbar (5 icon buttons + cutaway's
|
|
801
866
|
Flip/Reset, ~314px) is wider than a small phone's viewport minus the
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// src/framework/appearance-card.js
|
|
2
|
+
// The appearance card: the popover's second page, for one sub-part. Header
|
|
3
|
+
// (Back, label, eye, close), See-through, the material tiles, the colour row,
|
|
4
|
+
// the finish sliders, and a footer that says where a change goes. Every write
|
|
5
|
+
// is one parts.setDisplay(name, patch); the card re-renders from parts.getState
|
|
6
|
+
// on the change event, so what it shows is always the viewer's answer.
|
|
7
|
+
import { PRESETS } from "./materials/presets.js";
|
|
8
|
+
import { OVERRIDE_RANGES } from "./materials/resolve.js";
|
|
9
|
+
import { MIN_USER_OPACITY } from "./materials/overrides.js";
|
|
10
|
+
import { hexColor } from "./parts-list.js";
|
|
11
|
+
|
|
12
|
+
const COLORS = ["#b3261e", "#e8711a", "#e3b31c", "#2e8b57", "#1f5bd6", "#6f42c1", "#f2f2f0", "#8c8c90", "#1f1f22"];
|
|
13
|
+
const FINISH = [
|
|
14
|
+
["roughness", "Roughness"], ["metalness", "Metalness"], ["clearcoat", "Clearcoat"],
|
|
15
|
+
["clearcoatRoughness", "Clearcoat roughness"], ["anisotropy", "Anisotropy"], ["textureScale", "Texture scale"],
|
|
16
|
+
];
|
|
17
|
+
const FOOT = {
|
|
18
|
+
"saved": { note: "Material and colour are saved to this forge. See-through and hiding are only on this screen.", reset: "Reset to authored" },
|
|
19
|
+
"screen-only": { note: "", reset: "Reset" },
|
|
20
|
+
"blocked": { note: "This part's look is set by code. Ask the assistant to change it.", reset: "Reset" },
|
|
21
|
+
};
|
|
22
|
+
const ICONS = {
|
|
23
|
+
back: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`,
|
|
24
|
+
close: `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
|
|
25
|
+
eye: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`,
|
|
26
|
+
eyeOff: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`,
|
|
27
|
+
};
|
|
28
|
+
// Texture scale spans 0.01–1000 mm, five decades: a linear slider over that puts
|
|
29
|
+
// ~4 mm under each pixel and cannot land on the print presets' 0.2 mm. The
|
|
30
|
+
// slider moves a POSITION in [0, 1] instead, mapped logarithmically onto
|
|
31
|
+
// OVERRIDE_RANGES.textureScale; values are clamped to the range both ways.
|
|
32
|
+
const [TS_LO, TS_HI] = OVERRIDE_RANGES.textureScale;
|
|
33
|
+
const clampTs = (v) => Math.min(TS_HI, Math.max(TS_LO, v));
|
|
34
|
+
export function textureScaleToPos(value) {
|
|
35
|
+
const v = Number.isFinite(value) ? clampTs(value) : TS_LO;
|
|
36
|
+
return Math.log(v / TS_LO) / Math.log(TS_HI / TS_LO);
|
|
37
|
+
}
|
|
38
|
+
export function posToTextureScale(pos) {
|
|
39
|
+
const p = Number.isFinite(pos) ? Math.min(1, Math.max(0, pos)) : 0;
|
|
40
|
+
return clampTs(TS_LO * (TS_HI / TS_LO) ** p);
|
|
41
|
+
}
|
|
42
|
+
// Sensible precision for a millimetre scale: 0.2 mm, 4.8 mm, 48 mm, 250 mm.
|
|
43
|
+
function formatTextureScale(v) {
|
|
44
|
+
const digits = v < 1 ? 2 : v < 10 ? 1 : 0;
|
|
45
|
+
return `${Number(v.toFixed(digits))} mm`;
|
|
46
|
+
}
|
|
47
|
+
// The tiles' repaint is a pass over ~25 spheres, one per frame; a dragged colour
|
|
48
|
+
// well re-renders the card every frame, so repaints are held to one per this
|
|
49
|
+
// many milliseconds (leading edge, plus one trailing pass for the last state).
|
|
50
|
+
const REPAINT_MS = 150;
|
|
51
|
+
|
|
52
|
+
const parseHex = (s) => (/^#?[0-9a-f]{6}$/i.test(String(s).trim()) ? parseInt(String(s).trim().replace("#", ""), 16) : null);
|
|
53
|
+
const known = (m) => typeof m === "string" && Object.hasOwn(PRESETS, m);
|
|
54
|
+
|
|
55
|
+
// Leading-edge, once-per-frame write. The viewer rebuilds the sub-part's
|
|
56
|
+
// material on every appearance write, so a dragged slider or colour well must
|
|
57
|
+
// not write on every input event: the first event in a frame writes at once,
|
|
58
|
+
// later ones in the same frame keep only their latest value, which is written
|
|
59
|
+
// when the frame ends. requestAnimationFrame where there is one; a 16ms timer
|
|
60
|
+
// otherwise (a test DOM, a worker-less host).
|
|
61
|
+
const hasRaf = () => typeof requestAnimationFrame === "function";
|
|
62
|
+
const nextFrame = () => new Promise((res) => (hasRaf() ? requestAnimationFrame(() => res()) : setTimeout(res, 16)));
|
|
63
|
+
function frameThrottle(fn) {
|
|
64
|
+
const raf = hasRaf() ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
|
|
65
|
+
const caf = typeof cancelAnimationFrame === "function" && hasRaf() ? cancelAnimationFrame : clearTimeout;
|
|
66
|
+
let handle = null;
|
|
67
|
+
let pending = null; // [args] waiting for the frame's end, or null
|
|
68
|
+
const tick = () => {
|
|
69
|
+
handle = null;
|
|
70
|
+
if (!pending) return;
|
|
71
|
+
const args = pending; pending = null;
|
|
72
|
+
fn(...args);
|
|
73
|
+
handle = raf(tick); // this write opened a new frame of its own
|
|
74
|
+
};
|
|
75
|
+
const call = (...args) => {
|
|
76
|
+
if (handle === null) { fn(...args); handle = raf(tick); } else pending = args;
|
|
77
|
+
};
|
|
78
|
+
call.cancel = () => { if (handle !== null) caf(handle); handle = null; pending = null; };
|
|
79
|
+
return call;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function el(tag, className, attrs = {}) {
|
|
83
|
+
const e = document.createElement(tag);
|
|
84
|
+
if (className) e.className = className;
|
|
85
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
86
|
+
return e;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `renderThumb(materialId, display) -> Promise<dataUrl|null>` (Task 7) paints a
|
|
90
|
+
// sphere into a tile; absent, tiles stay flat discs in the preset colour.
|
|
91
|
+
// `onBack(name)` gets the name the card was showing, so the host can put focus
|
|
92
|
+
// back on that row; `onGone(name)` fires when a re-render finds the part gone
|
|
93
|
+
// (dropped by a rebuild), so the host can leave the card for the list.
|
|
94
|
+
export function attachAppearanceCard(parts, { onBack, onClose, onGone, getSaveState, renderThumb = null } = {}) {
|
|
95
|
+
const root = el("div", "pf-card");
|
|
96
|
+
let name = null;
|
|
97
|
+
let row = null;
|
|
98
|
+
|
|
99
|
+
// --- static skeleton (rebuilt values only on render) ---
|
|
100
|
+
const head = el("div", "pf-card-head");
|
|
101
|
+
const back = el("button", "pf-card-back", { type: "button", "aria-label": "Back to parts" }); back.innerHTML = ICONS.back;
|
|
102
|
+
const title = el("span", "pf-card-title");
|
|
103
|
+
const eye = el("button", "pf-card-eye", { type: "button" });
|
|
104
|
+
const close = el("button", "pf-card-close", { type: "button", "aria-label": "Close" }); close.innerHTML = ICONS.close;
|
|
105
|
+
head.append(back, title, eye, close);
|
|
106
|
+
|
|
107
|
+
const seeRow = el("label", "pf-card-row");
|
|
108
|
+
const seeLab = el("span", "pf-card-lab"); seeLab.textContent = "See-through";
|
|
109
|
+
const seeVal = el("span", "pf-card-val");
|
|
110
|
+
const see = el("input", "pf-card-seethrough", { type: "range", min: String(Math.round(MIN_USER_OPACITY * 100)), max: "100", step: "1", "aria-label": "See-through" });
|
|
111
|
+
seeRow.append(seeLab, seeVal, see);
|
|
112
|
+
|
|
113
|
+
const matHead = el("div", "pf-card-lab-row");
|
|
114
|
+
const matLab = el("span", "pf-card-lab"); matLab.textContent = "Material";
|
|
115
|
+
const matVal = el("span", "pf-card-val");
|
|
116
|
+
matHead.append(matLab, matVal);
|
|
117
|
+
const tiles = el("div", "pf-card-tiles", { role: "group", "aria-label": "Material" });
|
|
118
|
+
const tileFor = (id, label, color) => {
|
|
119
|
+
const t = el("button", "pf-card-tile", { type: "button", "data-material": id, "aria-pressed": "false", title: label });
|
|
120
|
+
const ball = el("span", "pf-card-ball"); ball.style.setProperty("--pf-swatch", hexColor(color));
|
|
121
|
+
const img = el("img", "", { alt: "" }); img.hidden = true; ball.append(img);
|
|
122
|
+
const lab = el("span", "pf-card-tile-label"); lab.textContent = label;
|
|
123
|
+
t.append(ball, lab);
|
|
124
|
+
return t;
|
|
125
|
+
};
|
|
126
|
+
// One tile per preset. There is no "no material" tile: in the realistic
|
|
127
|
+
// view a part with no material looks like plain matte in its colour, which
|
|
128
|
+
// nothing on screen can tell from a neighbouring preset, so the tile read as
|
|
129
|
+
// dead. A part with no (or an unknown) material presses nothing; Reset is
|
|
130
|
+
// the way back to the authored look.
|
|
131
|
+
tiles.append(...Object.values(PRESETS).map((p) => tileFor(p.id, p.label, p.color)));
|
|
132
|
+
const unknown = el("p", "pf-card-unknown"); unknown.hidden = true;
|
|
133
|
+
|
|
134
|
+
const colHead = el("div", "pf-card-lab-row");
|
|
135
|
+
const colLab = el("span", "pf-card-lab"); colLab.textContent = "Colour";
|
|
136
|
+
const colVal = el("span", "pf-card-val");
|
|
137
|
+
colHead.append(colLab, colVal);
|
|
138
|
+
const colors = el("div", "pf-card-colors");
|
|
139
|
+
for (const c of COLORS) {
|
|
140
|
+
const b = el("button", "pf-card-color", { type: "button", "data-color": c, title: c, "aria-label": c });
|
|
141
|
+
b.style.setProperty("--pf-swatch", c);
|
|
142
|
+
colors.append(b);
|
|
143
|
+
}
|
|
144
|
+
const native = el("input", "pf-card-native", { type: "color", "aria-label": "Custom colour" });
|
|
145
|
+
colors.append(native);
|
|
146
|
+
const hexRow = el("div", "pf-card-hexrow");
|
|
147
|
+
const hex = el("input", "pf-card-hex", { type: "text", "aria-label": "Hex colour", spellcheck: "false", maxlength: "7" });
|
|
148
|
+
hexRow.append(hex);
|
|
149
|
+
const tintNote = el("p", "pf-card-tint-note"); tintNote.hidden = true;
|
|
150
|
+
|
|
151
|
+
const finish = el("details", "pf-card-finish-group");
|
|
152
|
+
const summary = el("summary"); summary.textContent = "Finish";
|
|
153
|
+
finish.append(summary);
|
|
154
|
+
const finishInputs = new Map();
|
|
155
|
+
// Each throttle is bound to the part it was started for, so a write still
|
|
156
|
+
// pending when the card switches part lands on the part it was meant for.
|
|
157
|
+
const writeFinish = frameThrottle((who, patch) => parts.setDisplay(who, patch));
|
|
158
|
+
for (const [key, label] of FINISH) {
|
|
159
|
+
const [lo, hi] = OVERRIDE_RANGES[key];
|
|
160
|
+
const r = el("label", "pf-card-row");
|
|
161
|
+
const lab = el("span", "pf-card-lab"); lab.textContent = label;
|
|
162
|
+
const val = el("span", "pf-card-val");
|
|
163
|
+
// Texture scale's range input holds a log POSITION (textureScaleToPos), not mm.
|
|
164
|
+
const logScale = key === "textureScale";
|
|
165
|
+
const input = el("input", "pf-card-finish", logScale
|
|
166
|
+
? { type: "range", "data-key": key, min: "0", max: "1", step: "0.001", "aria-label": label }
|
|
167
|
+
: { type: "range", "data-key": key, min: String(lo), max: String(hi), step: "0.01", "aria-label": label });
|
|
168
|
+
r.append(lab, val, input);
|
|
169
|
+
finish.append(r);
|
|
170
|
+
finishInputs.set(key, { input, val });
|
|
171
|
+
input.addEventListener("input", () => {
|
|
172
|
+
const raw = Number(input.value);
|
|
173
|
+
if (!name || !Number.isFinite(raw)) return;
|
|
174
|
+
const v = logScale ? posToTextureScale(raw) : raw;
|
|
175
|
+
writeFinish(name, { [key]: Math.min(hi, Math.max(lo, v)) });
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const foot = el("div", "pf-card-foot");
|
|
180
|
+
const note = el("span", "pf-card-foot-note");
|
|
181
|
+
const reset = el("button", "pf-card-reset", { type: "button" });
|
|
182
|
+
foot.append(note, reset);
|
|
183
|
+
|
|
184
|
+
root.append(head, seeRow, matHead, tiles, unknown, colHead, colors, hexRow, tintNote, finish, foot);
|
|
185
|
+
|
|
186
|
+
// --- wiring ---
|
|
187
|
+
back.addEventListener("click", () => onBack?.(name));
|
|
188
|
+
close.addEventListener("click", () => onClose?.());
|
|
189
|
+
eye.addEventListener("click", () => { if (name && row) parts.setDisplay(name, { hidden: !row.hidden }); });
|
|
190
|
+
// See-through rebuilds the fade clone on every write, like the finish sliders:
|
|
191
|
+
// once per frame, the first event at once.
|
|
192
|
+
const writeSee = frameThrottle((who, patch) => parts.setDisplay(who, patch));
|
|
193
|
+
see.addEventListener("input", () => {
|
|
194
|
+
if (!name) return;
|
|
195
|
+
const v = Number(see.value) / 100;
|
|
196
|
+
writeSee(name, { opacity: v >= 1 ? null : v });
|
|
197
|
+
});
|
|
198
|
+
tiles.addEventListener("click", (e) => {
|
|
199
|
+
const t = e.target.closest(".pf-card-tile");
|
|
200
|
+
if (!t || !name) return;
|
|
201
|
+
const id = t.dataset.material;
|
|
202
|
+
// An untintable preset draws its own colour, and the card disables the
|
|
203
|
+
// colour row under it — so a colour the USER set would be stranded there
|
|
204
|
+
// (drawn as red brass, with nothing but Reset to clear it). Picking one
|
|
205
|
+
// clears the override colour. An authored colour is source and persists.
|
|
206
|
+
const ownColor = known(id) && !PRESETS[id].tintable && typeof parts.getState(name)?.override?.color === "number";
|
|
207
|
+
parts.setDisplay(name, ownColor ? { material: id, color: null } : { material: id });
|
|
208
|
+
});
|
|
209
|
+
colors.addEventListener("click", (e) => {
|
|
210
|
+
const b = e.target.closest(".pf-card-color");
|
|
211
|
+
if (!b || !name) return;
|
|
212
|
+
parts.setDisplay(name, { color: parseHex(b.dataset.color) });
|
|
213
|
+
});
|
|
214
|
+
const writeNative = frameThrottle((who, patch) => parts.setDisplay(who, patch));
|
|
215
|
+
native.addEventListener("input", () => { const c = parseHex(native.value); if (name && c !== null) writeNative(name, { color: c }); });
|
|
216
|
+
hex.addEventListener("change", () => { const c = parseHex(hex.value); if (name && c !== null) parts.setDisplay(name, { color: c }); else render(); });
|
|
217
|
+
reset.addEventListener("click", () => { if (name) parts.clear({ appearance: true, name }); });
|
|
218
|
+
|
|
219
|
+
function render() {
|
|
220
|
+
if (!name) return;
|
|
221
|
+
row = parts.list().find((r) => r.name === name) ?? null;
|
|
222
|
+
const state = parts.getState(name);
|
|
223
|
+
if (!row || !state) {
|
|
224
|
+
// The part went away under the card (a rebuild dropped it): there is
|
|
225
|
+
// nothing left to show, so hand the page back rather than leave a card
|
|
226
|
+
// for a part that no longer exists.
|
|
227
|
+
const gone = name;
|
|
228
|
+
name = null; row = null; thumbToken++; cancelRepaint();
|
|
229
|
+
onGone?.(gone);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
title.textContent = row.label;
|
|
233
|
+
eye.innerHTML = row.hidden ? ICONS.eyeOff : ICONS.eye;
|
|
234
|
+
eye.setAttribute("aria-pressed", String(row.hidden));
|
|
235
|
+
eye.setAttribute("aria-label", `${row.hidden ? "Show" : "Hide"} ${row.label}`);
|
|
236
|
+
eye.title = eye.getAttribute("aria-label");
|
|
237
|
+
const pct = Math.round(row.opacity * 100);
|
|
238
|
+
see.value = String(pct); seeVal.textContent = `${pct}%`;
|
|
239
|
+
const m = state.effective.material;
|
|
240
|
+
for (const t of tiles.children) t.setAttribute("aria-pressed", String(known(m) && t.dataset.material === m));
|
|
241
|
+
matVal.textContent = row.materialLabel ?? "None";
|
|
242
|
+
unknown.hidden = !(m != null && !known(m));
|
|
243
|
+
unknown.textContent = unknown.hidden ? "" : String(m);
|
|
244
|
+
const color = row.color;
|
|
245
|
+
// An explicit colour under an untintable preset still exists (an override
|
|
246
|
+
// cannot clear an authored colour, only replace it), so it is shown by
|
|
247
|
+
// value rather than as "from the material" — the row stays disabled.
|
|
248
|
+
const ownColor = typeof state.effective.color === "number";
|
|
249
|
+
colVal.textContent = row.tintable || ownColor ? hexColor(color) : "from the material";
|
|
250
|
+
colors.classList.toggle("disabled", !row.tintable);
|
|
251
|
+
// Disabled for real, not only dimmed: pointer-events alone left the
|
|
252
|
+
// swatches and the colour well reachable by keyboard and screen reader.
|
|
253
|
+
for (const b of colors.querySelectorAll(".pf-card-color")) b.disabled = !row.tintable;
|
|
254
|
+
native.disabled = !row.tintable;
|
|
255
|
+
hex.disabled = !row.tintable;
|
|
256
|
+
for (const b of colors.querySelectorAll(".pf-card-color")) b.classList.toggle("on", parseHex(b.dataset.color) === color);
|
|
257
|
+
native.value = hexColor(color);
|
|
258
|
+
if (document.activeElement !== hex) hex.value = hexColor(color);
|
|
259
|
+
tintNote.hidden = row.tintable;
|
|
260
|
+
tintNote.textContent = row.tintable ? "" : `${row.materialLabel} has its own colour. Pick a tintable preset to colour it.`;
|
|
261
|
+
for (const [key, { input, val }] of finishInputs) {
|
|
262
|
+
const v = state.effective[key];
|
|
263
|
+
const shown = typeof v === "number" ? v : (known(m) ? (PRESETS[m][key] ?? 0) : 0);
|
|
264
|
+
if (key === "textureScale") {
|
|
265
|
+
const mm = shown > 0 ? clampTs(shown) : null;
|
|
266
|
+
input.value = String(textureScaleToPos(mm ?? TS_LO));
|
|
267
|
+
val.textContent = mm === null ? "—" : formatTextureScale(mm);
|
|
268
|
+
} else {
|
|
269
|
+
input.value = String(shown); val.textContent = shown.toFixed(2);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const f = FOOT[getSaveState?.() ?? "screen-only"] ?? FOOT["screen-only"];
|
|
273
|
+
note.textContent = f.note; note.hidden = !f.note; reset.textContent = f.reset;
|
|
274
|
+
if (renderThumb) requestPaint(state.effective);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Repaints, held to one per REPAINT_MS: the first at once, then at most one
|
|
278
|
+
// trailing pass carrying the latest state when the window closes.
|
|
279
|
+
let repaintTimer = null;
|
|
280
|
+
let repaintPending = null;
|
|
281
|
+
function requestPaint(effective) {
|
|
282
|
+
if (repaintTimer !== null) { repaintPending = effective; return; }
|
|
283
|
+
paintThumbs(effective);
|
|
284
|
+
const windowEnd = () => {
|
|
285
|
+
repaintTimer = null;
|
|
286
|
+
if (!repaintPending) return;
|
|
287
|
+
const next = repaintPending; repaintPending = null;
|
|
288
|
+
paintThumbs(next);
|
|
289
|
+
repaintTimer = setTimeout(windowEnd, REPAINT_MS);
|
|
290
|
+
};
|
|
291
|
+
repaintTimer = setTimeout(windowEnd, REPAINT_MS);
|
|
292
|
+
}
|
|
293
|
+
function cancelRepaint() {
|
|
294
|
+
if (repaintTimer !== null) clearTimeout(repaintTimer);
|
|
295
|
+
repaintTimer = null; repaintPending = null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Thumbnails: one per tile, lazily, in order, cached by the renderer (Task 7),
|
|
299
|
+
// with a frame between tiles so a pass of cold renders never blocks a task
|
|
300
|
+
// for all ~25 spheres at once.
|
|
301
|
+
let thumbToken = 0;
|
|
302
|
+
async function paintThumbs(effective) {
|
|
303
|
+
const token = ++thumbToken;
|
|
304
|
+
let first = true;
|
|
305
|
+
for (const t of tiles.children) {
|
|
306
|
+
if (token !== thumbToken || !root.isConnected) return;
|
|
307
|
+
const id = t.dataset.material;
|
|
308
|
+
if (!first) { await nextFrame(); if (token !== thumbToken || !root.isConnected) return; }
|
|
309
|
+
first = false;
|
|
310
|
+
let url = null;
|
|
311
|
+
try { url = await renderThumb(id, { ...effective, material: id }); } catch { url = null; }
|
|
312
|
+
if (token !== thumbToken) return;
|
|
313
|
+
const img = t.querySelector("img");
|
|
314
|
+
if (url) { img.src = url; img.hidden = false; } else img.hidden = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const off = parts.onChange(() => render());
|
|
319
|
+
return {
|
|
320
|
+
element: root,
|
|
321
|
+
show(next) {
|
|
322
|
+
if (!parts.list().some((r) => r.name === next)) return false;
|
|
323
|
+
name = next;
|
|
324
|
+
render();
|
|
325
|
+
return true;
|
|
326
|
+
},
|
|
327
|
+
hide() { name = null; thumbToken++; cancelRepaint(); },
|
|
328
|
+
render,
|
|
329
|
+
detach() { writeFinish.cancel(); writeNative.cancel(); writeSee.cancel(); cancelRepaint(); thumbToken++; try { off(); } catch { /* gone */ } root.remove(); },
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// declaration order, so every part written before this existed resolves to the view
|
|
6
6
|
// it has always opened on.
|
|
7
7
|
//
|
|
8
|
-
// Pure: no DOM, no storage, no kernel, no build.
|
|
9
|
-
// verify / renderViews) deliberately do NOT use this — they stay on
|
|
8
|
+
// Pure: no DOM, no storage, no kernel, no build. partforge's own headless surfaces
|
|
9
|
+
// (measure / 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
|
-
// convenient one.
|
|
11
|
+
// convenient one. It is exported (main entry and partforge/testing, 0.147.0) for a
|
|
12
|
+
// host that must open the SAME view mount() does — a cached preview built off the
|
|
13
|
+
// viewer has to match the live build it is swapped for. The signature is (part): it
|
|
14
|
+
// reads part.defaults, never caller params.
|
|
12
15
|
import { inView } from "./sub-part-views.js";
|
|
13
16
|
|
|
14
17
|
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|