partforge 0.93.0 → 0.95.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 +16 -0
- package/docs/AUTHORING-PARTS.md +28 -1
- package/docs/VECTOR-FORMAT.md +11 -2
- package/package.json +1 -1
- package/src/framework/asset-resolve.js +24 -3
- package/src/framework/cutaway-controls.js +7 -1
- package/src/framework/cutaway-math.js +9 -2
- package/src/framework/cutaway.js +59 -0
- package/src/framework/mount.js +38 -2
- package/src/framework/vectors.js +44 -9
- package/src/framework/viewer-controls.js +7 -0
- package/src/framework/viewer.js +13 -0
- package/src/parts/emblem.js +17 -5
- package/types/index.d.ts +48 -0
- package/types/part.d.ts +65 -0
package/README.md
CHANGED
|
@@ -120,9 +120,25 @@ runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join th
|
|
|
120
120
|
// aria-label, or a per-entry getLabel()); returns
|
|
121
121
|
// { sync, hide, detach }, auto-detached on dispose()
|
|
122
122
|
const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
|
|
123
|
+
const carried = runtime.getViewerState(); // camera + projection + cutaway, as plain JSON
|
|
123
124
|
runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
|
|
125
|
+
mount(nextPart, { createWorker, elements, viewerState: carried }); // resumes the view
|
|
124
126
|
```
|
|
125
127
|
|
|
128
|
+
**Carry the view across a remount.** A host that applies edits by mounting a
|
|
129
|
+
new part — rather than by `setParams` — starts each mount from the part's
|
|
130
|
+
default framing, so the camera snaps back and the cutaway closes every time the
|
|
131
|
+
user changes anything. Snapshot with `runtime.getViewerState()` *before*
|
|
132
|
+
`dispose()` and hand the result to the next `mount()` as `viewerState`: the
|
|
133
|
+
part changes, the user's view of it does not.
|
|
134
|
+
|
|
135
|
+
Restore is best-effort per field, so a state that no longer fits is dropped
|
|
136
|
+
rather than fatal. The cut plane's world pose is restored exactly; its
|
|
137
|
+
on-screen size is re-derived from the new geometry, since that is a property of
|
|
138
|
+
the part rather than of the user's choice. Omit `viewerState` on a first mount
|
|
139
|
+
— the viewer then restores its own persisted camera and projection, as it
|
|
140
|
+
always has.
|
|
141
|
+
|
|
126
142
|
**Park the viewer when you hide it.** A host that hides the canvas with
|
|
127
143
|
`display: none` needs nothing — the container collapses and the ResizeObserver
|
|
128
144
|
shrinks the drawing buffer for free. A host that hides it any other way
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -139,6 +139,9 @@ export default {
|
|
|
139
139
|
grammar and preload timing as `fonts` above — but the source resolves to **JSON** in the
|
|
140
140
|
`partforge-vector` format, never to raw `.svg`. That JSON is either **authored** by hand
|
|
141
141
|
(millimetre coordinates, placed as drawn) or the **ingested** output of `partforge/ingest`.
|
|
142
|
+
A vector source may additionally be that JSON **already parsed** — the object itself,
|
|
143
|
+
rather than bytes or a URL pointing at it — which is the form to use when the artwork
|
|
144
|
+
lives beside the part and is meant to stay hand-editable.
|
|
142
145
|
See "Vector geometry" below for the full contract.
|
|
143
146
|
|
|
144
147
|
---
|
|
@@ -1553,6 +1556,28 @@ import outside a Vite build, so `partforge lint`/`measure`/`render` can't resolv
|
|
|
1553
1556
|
source must resolve to the `.vector.json`, never to a raw `.svg` — `k.vector2d` does no
|
|
1554
1557
|
SVG parsing at all.
|
|
1555
1558
|
|
|
1559
|
+
**A source may also be the parsed file itself.** Alongside bytes, a URL and a thunk, a
|
|
1560
|
+
`vectors` entry accepts the **contents** of a `.vector.json` — the object a JSON import
|
|
1561
|
+
yields, or anything else that already holds it:
|
|
1562
|
+
|
|
1563
|
+
```js
|
|
1564
|
+
import plate from "./assets/plate.vector.json" with { type: "json" };
|
|
1565
|
+
export default { vectors: { plate }, /* … */ };
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
The `with { type: "json" }` attribute is required — Node refuses a JSON import without it.
|
|
1569
|
+
Reach for this form when the artwork is **hand-authored and meant to stay editable**: the
|
|
1570
|
+
numbers sit in a file a reader can open and change, next to the part that uses them, with
|
|
1571
|
+
nothing to fetch in order to see them. Reach for `new URL(…)` instead when the file is
|
|
1572
|
+
**ingested output** — generated, large, and not read by hand. `src/parts/emblem.js`
|
|
1573
|
+
declares one of each, side by side, for exactly this contrast.
|
|
1574
|
+
|
|
1575
|
+
Two consequences worth knowing. `partforge/lint`'s document-aware rules can read a parsed
|
|
1576
|
+
source on the very first lint, before any build has run, because there is nothing to
|
|
1577
|
+
resolve — with a URL they stay silent until the bytes arrive. And the object is validated
|
|
1578
|
+
on every resolve, so a malformed one fails with the same message its fetched twin would;
|
|
1579
|
+
it is read and never written, so `build` stays pure.
|
|
1580
|
+
|
|
1556
1581
|
**Sizing is against the tight geometric bounding box, not a `viewBox`.** Icon sets pad
|
|
1557
1582
|
their `viewBox` inconsistently, so sizing relative to `viewBox` makes two icons declared at
|
|
1558
1583
|
the same nominal size look different on the plate. `width`/`height`/`fit` instead measure
|
|
@@ -1583,7 +1608,9 @@ a `"role": "subtract"` shape in the JSON, or `.cut()` it in `build`), and
|
|
|
1583
1608
|
|
|
1584
1609
|
**What this is not.** `k.shape2d` does **not** accept the JSON dialect — it takes the
|
|
1585
1610
|
internal contour form the polygon helpers and `pathProfile` produce — and there is no
|
|
1586
|
-
inline document form in `build`.
|
|
1611
|
+
inline document form in `build`. A parsed source (above) does not change that: it is a
|
|
1612
|
+
`vectors` **declaration**, resolved and validated before `build` runs, not a document
|
|
1613
|
+
`build` may assemble or hand to the kernel. The two vocabularies stay separated by the file boundary,
|
|
1587
1614
|
which is what lets `docs/VECTOR-FORMAT.md` be the only place they meet. Inline authoring
|
|
1588
1615
|
stays `pathProfile` (see § "Geometry: the kernel / `Solid` API" above, where `pathProfile` is
|
|
1589
1616
|
introduced, for which to reach for).
|
package/docs/VECTOR-FORMAT.md
CHANGED
|
@@ -567,9 +567,18 @@ one:
|
|
|
567
567
|
- **Existing artwork** — an `.svg` someone else made — goes through
|
|
568
568
|
`partforge/ingest` once and is then referenced like any other document.
|
|
569
569
|
|
|
570
|
+
Wherever it comes from, the file reaches a part through its `vectors` map — as a
|
|
571
|
+
URL, as bytes, or as the parsed contents themselves (`import doc from
|
|
572
|
+
"./x.vector.json" with { type: "json" }`). The last form is the one to prefer for
|
|
573
|
+
authored artwork, because it keeps the numbers in a file a reader can open and
|
|
574
|
+
edit rather than behind a fetch. See "Vector geometry" in
|
|
575
|
+
`docs/AUTHORING-PARTS.md` for the declaration rules.
|
|
576
|
+
|
|
570
577
|
`k.shape2d` does **not** accept this JSON dialect, and there is no inline
|
|
571
|
-
document form in `build
|
|
572
|
-
|
|
578
|
+
document form in `build` — a parsed source is a declaration resolved before the
|
|
579
|
+
build, not something `build` assembles. The two vocabularies stay separated by
|
|
580
|
+
the file boundary; that separation is what lets this document be the only place
|
|
581
|
+
they meet.
|
|
573
582
|
|
|
574
583
|
## 6. Converting an SVG to this format by hand
|
|
575
584
|
|
package/package.json
CHANGED
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
// thunk, is content-stable for a session — resolve it once). DOM-free and
|
|
12
12
|
// node:-free so it stays safe in the geometry worker's import closure.
|
|
13
13
|
|
|
14
|
+
// The `{ default: … }` module namespace a dynamic `import()` yields, unwrapped to
|
|
15
|
+
// the value itself; every other value passes through untouched. Exported because
|
|
16
|
+
// vectors.js applies the same rule OUTSIDE this resolver — its synchronous lint
|
|
17
|
+
// path reads a declared source directly, without resolving it — and the rule for
|
|
18
|
+
// what a module wrapper looks like must have exactly one definition.
|
|
19
|
+
export function unwrapModule(v) {
|
|
20
|
+
return v && typeof v === "object" && "default" in v && !toBuffer(v) && !(v instanceof URL) ? v.default : v;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export function toBuffer(v) {
|
|
15
24
|
if (v instanceof ArrayBuffer) return v;
|
|
16
25
|
// A view may not span its whole backing buffer — slice to its exact range (Node
|
|
@@ -33,13 +42,21 @@ function describeSource(v) {
|
|
|
33
42
|
// async); `errorMessage` is thrown when `source` doesn't match the grammar.
|
|
34
43
|
// Results are cached on the caller-supplied `cache` Map, keyed by source
|
|
35
44
|
// identity, so a repeated declaration resolves (and fetches) only once.
|
|
36
|
-
|
|
45
|
+
//
|
|
46
|
+
// `adopt(value, source)` is an optional last chance to claim a source that is
|
|
47
|
+
// neither bytes nor fetchable. vectors.js uses it for a source that is ALREADY
|
|
48
|
+
// the parsed contents of its file, where the source IS the asset rather than a
|
|
49
|
+
// way to reach its bytes. It is consulted only for values the grammar is about
|
|
50
|
+
// to refuse, so it can never shadow the bytes/URL/thunk forms; returning
|
|
51
|
+
// `undefined` declines and restores the refusal. Whatever it returns becomes
|
|
52
|
+
// resolveOne's result directly — `finish` takes bytes, and is not called.
|
|
53
|
+
export function makeAssetResolver(cache, finish, errorMessage, adopt = null) {
|
|
37
54
|
return function resolveOne(source) {
|
|
38
55
|
if (cache.has(source)) return cache.get(source);
|
|
39
56
|
const p = (async () => {
|
|
40
57
|
let v = source;
|
|
41
58
|
if (typeof v === "function") v = await v();
|
|
42
|
-
|
|
59
|
+
v = unwrapModule(v); // dynamic-import module
|
|
43
60
|
let bytes = toBuffer(v);
|
|
44
61
|
if (!bytes) {
|
|
45
62
|
if (v instanceof URL || typeof v === "string") {
|
|
@@ -53,7 +70,11 @@ export function makeAssetResolver(cache, finish, errorMessage) {
|
|
|
53
70
|
}
|
|
54
71
|
bytes = await res.arrayBuffer();
|
|
55
72
|
}
|
|
56
|
-
else
|
|
73
|
+
else {
|
|
74
|
+
const claimed = adopt ? adopt(v, source) : undefined;
|
|
75
|
+
if (claimed !== undefined) return claimed;
|
|
76
|
+
throw new Error(errorMessage);
|
|
77
|
+
}
|
|
57
78
|
}
|
|
58
79
|
return finish(bytes, v, source);
|
|
59
80
|
})();
|
|
@@ -25,7 +25,7 @@ function actionButton(label, title) {
|
|
|
25
25
|
// Wire the optional cutaway button to the viewer and create its contextual
|
|
26
26
|
// actions. Hosts that omit the primary button opt out of all DOM behavior.
|
|
27
27
|
export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip, escapeGuard } = {}) {
|
|
28
|
-
if (!button) return { reset: noop, detach: noop };
|
|
28
|
+
if (!button) return { reset: noop, sync: noop, detach: noop };
|
|
29
29
|
|
|
30
30
|
const canvas = viewer.domElement;
|
|
31
31
|
const addedCanvasTabIndex = !canvas.hasAttribute("tabindex");
|
|
@@ -109,6 +109,12 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
|
|
|
109
109
|
|
|
110
110
|
return {
|
|
111
111
|
reset: disable,
|
|
112
|
+
// Re-read the viewer and repaint the button. Every path in here that
|
|
113
|
+
// changes the mode already calls it; this exposes it for the one caller
|
|
114
|
+
// that turns the cutaway on from OUTSIDE the button — mount()'s restore of
|
|
115
|
+
// a carried viewer state, which would otherwise come back sliced open under
|
|
116
|
+
// a button still reading "off", with its Flip/Reset row missing.
|
|
117
|
+
sync,
|
|
112
118
|
detach() {
|
|
113
119
|
if (detached) return;
|
|
114
120
|
detached = true;
|
|
@@ -28,9 +28,16 @@ export function nearestCanonicalAxis(direction, target = new THREE.Vector3()) {
|
|
|
28
28
|
return target.set(0, 0, 0).setComponent(bestIndex, components[bestIndex] < 0 ? -1 : 1);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The plane's on-screen extent, as a function of the part's bounds ALONE.
|
|
32
|
+
// Split out of initialCutawayPose so a restored pose can be re-sized against
|
|
33
|
+
// whatever geometry it is landing on without inheriting anything else from the
|
|
34
|
+
// pose it came from — see the cutaway's setState.
|
|
35
|
+
export function cutawayPoseSize(box) {
|
|
36
|
+
return Math.max(box.getSize(new THREE.Vector3()).length(), 1) * 1.25;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
export function initialCutawayPose(box, camera) {
|
|
32
40
|
const position = box.getCenter(new THREE.Vector3());
|
|
33
|
-
const diagonal = Math.max(box.getSize(new THREE.Vector3()).length(), 1);
|
|
34
41
|
// Square the cut plane up with the part rather than the camera: the axis
|
|
35
42
|
// nearest the view direction, so the near half is still what gets cut away.
|
|
36
43
|
const normal = nearestCanonicalAxis(
|
|
@@ -44,7 +51,7 @@ export function initialCutawayPose(box, camera) {
|
|
|
44
51
|
return {
|
|
45
52
|
position,
|
|
46
53
|
quaternion,
|
|
47
|
-
size:
|
|
54
|
+
size: cutawayPoseSize(box),
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
|
package/src/framework/cutaway.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|
|
2
2
|
|
|
3
3
|
import { createCutawayGizmo } from "./cutaway-gizmo.js";
|
|
4
4
|
import {
|
|
5
|
+
cutawayPoseSize,
|
|
5
6
|
initialCutawayPose,
|
|
6
7
|
planeFromPose,
|
|
7
8
|
pointSurvivesPlane,
|
|
@@ -362,6 +363,62 @@ export function createCutaway({
|
|
|
362
363
|
return true;
|
|
363
364
|
}
|
|
364
365
|
|
|
366
|
+
// --- state carry-over ------------------------------------------------------
|
|
367
|
+
// The cutaway lives and dies with its mount, and an embedder that applies
|
|
368
|
+
// every edit by REMOUNTING (partforge-cloud does — the agent's edits, undo,
|
|
369
|
+
// redo, a settings commit) would otherwise close the user's slice on every
|
|
370
|
+
// turn. These two carry it across; mount.js owns the handoff.
|
|
371
|
+
//
|
|
372
|
+
// The snapshot is plain JSON on purpose. It outlives the mount that produced
|
|
373
|
+
// it, so a live THREE object would hand the next mount a reference into a
|
|
374
|
+
// disposed scene — and a host free to store or post it needs something
|
|
375
|
+
// structured-cloneable either way.
|
|
376
|
+
const isFiniteTuple = (value, length) =>
|
|
377
|
+
Array.isArray(value) && value.length === length && value.every(Number.isFinite);
|
|
378
|
+
|
|
379
|
+
function getState() {
|
|
380
|
+
// `size` is deliberately absent. It is a function of the part's bounds, and
|
|
381
|
+
// the part is exactly what changed between the snapshot and the restore —
|
|
382
|
+
// carrying it would size the cap and the gizmo for geometry that no longer
|
|
383
|
+
// exists. What the user actually chose is where the plane sits, which way
|
|
384
|
+
// it faces, and the flip; setState re-derives the rest.
|
|
385
|
+
if (!enabled || !pose) return { enabled: false, flipped: false, pose: null };
|
|
386
|
+
return {
|
|
387
|
+
enabled: true,
|
|
388
|
+
flipped,
|
|
389
|
+
pose: {
|
|
390
|
+
position: pose.position.toArray(),
|
|
391
|
+
quaternion: pose.quaternion.toArray(),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function setState(state) {
|
|
397
|
+
if (disposed || disabling) return false;
|
|
398
|
+
// A snapshot taken with the cutaway off carries nothing to restore. This is
|
|
399
|
+
// not an instruction to disable — setState only ever turns the mode ON, so
|
|
400
|
+
// restoring into a fresh mount (already off) is correctly a no-op.
|
|
401
|
+
if (!state?.enabled) return true;
|
|
402
|
+
// Enable first: this is the path that refuses when the restore is
|
|
403
|
+
// impossible (no stencil, no geometry yet), and it seeds a pose against the
|
|
404
|
+
// CURRENT bounds for the malformed-snapshot fallback below to land on.
|
|
405
|
+
if (!setEnabled(true)) return false;
|
|
406
|
+
if (!isFiniteTuple(state.pose?.position, 3) || !isFiniteTuple(state.pose?.quaternion, 4)) {
|
|
407
|
+
return true; // enabled at the fresh pose, which beats refusing the restore outright
|
|
408
|
+
}
|
|
409
|
+
const bounds = validBounds(getBounds);
|
|
410
|
+
flipped = Boolean(state.flipped); // read by applyPose, through planeFromPose and gizmo.setFlipped
|
|
411
|
+
applyPose({
|
|
412
|
+
position: new THREE.Vector3().fromArray(state.pose.position),
|
|
413
|
+
quaternion: new THREE.Quaternion().fromArray(state.pose.quaternion).normalize(),
|
|
414
|
+
// Re-derived, never carried — see getState above. The fallback covers
|
|
415
|
+
// setState on an ALREADY-enabled cutaway, where setEnabled returned early
|
|
416
|
+
// and bounds may since have gone away.
|
|
417
|
+
size: bounds ? cutawayPoseSize(bounds) : pose.size,
|
|
418
|
+
}, { activeAppearance: true });
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
365
422
|
function setTheme(mode, edgeColor) {
|
|
366
423
|
if (disposed) return false;
|
|
367
424
|
theme = mode;
|
|
@@ -526,6 +583,8 @@ export function createCutaway({
|
|
|
526
583
|
resyncSubpart,
|
|
527
584
|
reset,
|
|
528
585
|
flip,
|
|
586
|
+
getState,
|
|
587
|
+
setState,
|
|
529
588
|
setTheme,
|
|
530
589
|
setViewportSize,
|
|
531
590
|
isPointVisible,
|
package/src/framework/mount.js
CHANGED
|
@@ -85,6 +85,20 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
85
85
|
? { ...opts, recenter: false }
|
|
86
86
|
: opts,
|
|
87
87
|
),
|
|
88
|
+
// Everything about the CURRENT VIEW that a host would lose by remounting,
|
|
89
|
+
// as one plain-JSON token: pass it back as mount()'s `viewerState` and the
|
|
90
|
+
// remount comes up where the user left it. An embedder that applies edits
|
|
91
|
+
// by remounting (partforge-cloud applies every one that way) needs this or
|
|
92
|
+
// the camera snaps and the cutaway closes on every turn.
|
|
93
|
+
//
|
|
94
|
+
// Read at teardown time, so it is the LIVE pose — unlike the camera the
|
|
95
|
+
// viewer persists for a page reload, which only records the end of a drag
|
|
96
|
+
// and so misses a view-cube click, Reframe, or an animation cue.
|
|
97
|
+
getViewerState: () => ({
|
|
98
|
+
camera: viewer.getCameraState(),
|
|
99
|
+
projection: viewer.getProjection?.() ?? "perspective",
|
|
100
|
+
cutaway: viewer.getCutawayState?.() ?? null,
|
|
101
|
+
}),
|
|
88
102
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
89
103
|
// buffer and the cached capture target. For an embedder that hides the
|
|
90
104
|
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
|
@@ -264,6 +278,13 @@ function createCleanupStack() {
|
|
|
264
278
|
// // rather than at its own hi-DPI size. Still hundreds of
|
|
265
279
|
// // KB of base64 apiece, so a host should not assume this
|
|
266
280
|
// // payload is small, only that it is bounded.
|
|
281
|
+
// viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
|
|
282
|
+
// // resume the camera, projection and cutaway where that mount
|
|
283
|
+
// // left them. For a host that applies edits by REMOUNTING: the
|
|
284
|
+
// // part changed, the user's view of it should not. Omit on a
|
|
285
|
+
// // first mount — the viewer then restores its own persisted
|
|
286
|
+
// // camera as before. Restore is best-effort per field: a pose
|
|
287
|
+
// // this part cannot support is dropped, never fatal.
|
|
267
288
|
// annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
|
|
268
289
|
// // Send in the sketch toolbar alongside the other tools.
|
|
269
290
|
// // "host" drops it: the host draws its own send control —
|
|
@@ -276,6 +297,7 @@ function createCleanupStack() {
|
|
|
276
297
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
277
298
|
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
|
|
278
299
|
fontCatalog,
|
|
300
|
+
viewerState,
|
|
279
301
|
annotateSend = "viewbar",
|
|
280
302
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
281
303
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
@@ -508,7 +530,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
508
530
|
// declares it, so an embedder gets it for free. Restored BEFORE any framing
|
|
509
531
|
// happens so a reload into ortho frames once instead of framing in
|
|
510
532
|
// perspective and then visibly re-framing.
|
|
511
|
-
|
|
533
|
+
// A carried state outranks the persisted preference: it is this session's
|
|
534
|
+
// live answer, where the stored one is the last page-reload's.
|
|
535
|
+
viewer.setProjection(viewerState?.projection ?? loadProjection());
|
|
512
536
|
const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
|
|
513
537
|
cleanup.defer(() => viewcube.detach());
|
|
514
538
|
cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
|
|
@@ -710,8 +734,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
710
734
|
if (frame) {
|
|
711
735
|
framedView = view();
|
|
712
736
|
if (!cameraRestored) {
|
|
713
|
-
|
|
737
|
+
// A carried camera beats the persisted one for the same reason the
|
|
738
|
+
// projection above does — and it is also the accurate one, since the
|
|
739
|
+
// persisted pose is only written at the end of an orbit drag.
|
|
740
|
+
const cam = viewerState?.camera ?? loadCamera();
|
|
714
741
|
if (cam) viewer.setCameraState(cam);
|
|
742
|
+
// The cutaway goes back on AFTER the camera and only here, on the
|
|
743
|
+
// first accepted build: enabling it needs the sub-parts registered
|
|
744
|
+
// and real bounds to size the plane against, neither of which exists
|
|
745
|
+
// until showAssembly above has run. (Its initial pose also reads the
|
|
746
|
+
// camera direction, so a restore before the camera would seed the
|
|
747
|
+
// fallback pose from the wrong view.)
|
|
748
|
+
if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
|
|
749
|
+
cutawayChrome.sync(); // the button was not what turned it on
|
|
750
|
+
}
|
|
715
751
|
cameraRestored = true;
|
|
716
752
|
}
|
|
717
753
|
}
|
package/src/framework/vectors.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// argument kernel-front.js:117-121 records for text2d.
|
|
13
13
|
//
|
|
14
14
|
// DOM-free and node:-free.
|
|
15
|
-
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
15
|
+
import { makeAssetResolver, resolveDecl, unwrapModule } from "./asset-resolve.js";
|
|
16
16
|
import { toInternalDocument } from "./geometry/vector-format.js";
|
|
17
17
|
|
|
18
18
|
const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
|
|
@@ -26,9 +26,32 @@ const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
|
|
|
26
26
|
// under the next name that declares it, with that name in the message.
|
|
27
27
|
const parsed = new Map();
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// A source that IS the parsed contents of a partforge-vector file, rather than a
|
|
30
|
+
// way to reach its bytes — the in-tree form, `import doc from "./x.vector.json"`.
|
|
31
|
+
// Returns that object, or null for every other source form.
|
|
32
|
+
//
|
|
33
|
+
// `unwrapModule` first, so a dynamic `import("./x.vector.json")` namespace reads
|
|
34
|
+
// the same as the static default import, matching the rule the resolver applies
|
|
35
|
+
// to bytes and URLs.
|
|
36
|
+
//
|
|
37
|
+
// Deliberately STRUCTURAL, not a format check: anything object-shaped is claimed
|
|
38
|
+
// here and judged afterwards by toInternalDocument, so an object that is not
|
|
39
|
+
// artwork draws the validator's specific complaint (`has format "svg"`) rather
|
|
40
|
+
// than the source grammar's generic one. Arrays are not claimed — an array is
|
|
41
|
+
// never a file, and for it the grammar error names the real mistake.
|
|
42
|
+
function asParsedFile(source) {
|
|
43
|
+
const v = unwrapModule(source);
|
|
44
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
45
|
+
if (v instanceof ArrayBuffer || ArrayBuffer.isView(v) || v instanceof URL) return null;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `payload` is what resolveOne produced: the resolved bytes, or — for a source
|
|
50
|
+
// `asParsedFile` claimed — the file's contents themselves, already parsed.
|
|
51
|
+
function parseDocument(payload, label) {
|
|
52
|
+
if (!(payload instanceof ArrayBuffer)) return toInternalDocument(payload, label);
|
|
30
53
|
let text;
|
|
31
|
-
try { text = new TextDecoder().decode(
|
|
54
|
+
try { text = new TextDecoder().decode(payload); }
|
|
32
55
|
catch { throw new Error(`vector2d: "${label}" could not be decoded as UTF-8 text`); }
|
|
33
56
|
let doc;
|
|
34
57
|
try { doc = JSON.parse(text); }
|
|
@@ -55,7 +78,9 @@ const rawBySource = new Map();
|
|
|
55
78
|
const resolveOne = makeAssetResolver(
|
|
56
79
|
cache,
|
|
57
80
|
(bytes, _value, source) => { bytesBySource.set(source, bytes); return bytes; },
|
|
58
|
-
"resolveVectors: a vector source must be bytes, a URL,
|
|
81
|
+
"resolveVectors: a vector source must be bytes, a URL, a thunk returning one, "
|
|
82
|
+
+ "or the already-parsed contents of a partforge-vector file",
|
|
83
|
+
(value) => asParsedFile(value) ?? undefined,
|
|
59
84
|
);
|
|
60
85
|
|
|
61
86
|
export async function resolveVectors(vectorsDecl) {
|
|
@@ -73,9 +98,9 @@ export async function resolveVectors(vectorsDecl) {
|
|
|
73
98
|
}
|
|
74
99
|
const raw = await resolveDecl(vectorsDecl, resolveOne);
|
|
75
100
|
const out = new Map();
|
|
76
|
-
for (const [name,
|
|
77
|
-
let doc = parsed.get(
|
|
78
|
-
if (!doc) { doc = parseDocument(
|
|
101
|
+
for (const [name, payload] of raw) {
|
|
102
|
+
let doc = parsed.get(payload);
|
|
103
|
+
if (!doc) { doc = parseDocument(payload, name); parsed.set(payload, doc); }
|
|
79
104
|
out.set(name, doc);
|
|
80
105
|
}
|
|
81
106
|
return out;
|
|
@@ -99,8 +124,13 @@ export async function resolveVectorDocs(vectorsDecl) {
|
|
|
99
124
|
const out = new Map();
|
|
100
125
|
await Promise.all(Object.entries(decl).map(async ([name, source]) => {
|
|
101
126
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
127
|
+
const payload = await resolveOne(source);
|
|
128
|
+
// An adopted source resolves to the raw JSON itself — there is nothing to
|
|
129
|
+
// decode, and running it through TextDecoder would map a perfectly good
|
|
130
|
+
// file to null.
|
|
131
|
+
const doc = payload instanceof ArrayBuffer
|
|
132
|
+
? JSON.parse(new TextDecoder().decode(payload))
|
|
133
|
+
: payload;
|
|
104
134
|
out.set(name, doc && typeof doc === "object" ? doc : null);
|
|
105
135
|
} catch {
|
|
106
136
|
out.set(name, null);
|
|
@@ -139,6 +169,11 @@ export function cachedVectorDocs(vectorsDecl) {
|
|
|
139
169
|
for (const entry of entries) {
|
|
140
170
|
try {
|
|
141
171
|
const [name, source] = entry;
|
|
172
|
+
// An already-parsed source has nothing to resolve, so unlike bytes and URLs
|
|
173
|
+
// it is readable on the very first lint — before any build has run. That is
|
|
174
|
+
// the state a hosted editor spends most of its time in.
|
|
175
|
+
const inline = asParsedFile(source);
|
|
176
|
+
if (inline) { out.set(name, inline); continue; }
|
|
142
177
|
if (!bytesBySource.has(source)) continue; // not resolved yet — stay silent
|
|
143
178
|
if (!rawBySource.has(source)) {
|
|
144
179
|
let doc = null;
|
|
@@ -50,6 +50,13 @@ export function attachViewerControls(
|
|
|
50
50
|
|
|
51
51
|
return {
|
|
52
52
|
detach: () => {
|
|
53
|
+
// Third save site, and the one that catches what the other two miss: the
|
|
54
|
+
// `end` event above fires for an orbit or a wheel-zoom, but NOT for a
|
|
55
|
+
// view-cube click, Reframe, or an animation camera cue, so a session that
|
|
56
|
+
// finished on one of those used to persist a pose the user had already
|
|
57
|
+
// moved away from. Taking the live pose at teardown makes the stored
|
|
58
|
+
// camera honest whatever last moved it.
|
|
59
|
+
saveCamera(viewer.getCameraState());
|
|
53
60
|
themeBtn?.removeEventListener("click", onThemeClick);
|
|
54
61
|
reframeBtn?.removeEventListener("click", onReframeClick);
|
|
55
62
|
window.removeEventListener("pagehide", onPageHide);
|
package/src/framework/viewer.js
CHANGED
|
@@ -765,6 +765,16 @@ export function createViewer(container, part) {
|
|
|
765
765
|
return result;
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
+
// Restoring a snapshot enables the mode, so it reassigns sub-part materials
|
|
769
|
+
// exactly the way setCutawayEnabled above does — and therefore has to
|
|
770
|
+
// re-assert live fades for the same reason a paused mid-fade part would
|
|
771
|
+
// otherwise stick at full opacity.
|
|
772
|
+
function setCutawayState(state) {
|
|
773
|
+
const result = cutaway.setState(state);
|
|
774
|
+
reassertLiveFades();
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
|
|
768
778
|
// Swap the scene background, grid, and edge-line colors for the given theme.
|
|
769
779
|
function setTheme(mode) {
|
|
770
780
|
const t = THEME[mode] ?? THEME.dark;
|
|
@@ -1386,6 +1396,9 @@ export function createViewer(container, part) {
|
|
|
1386
1396
|
cutawaySupported: () => cutaway.isSupported,
|
|
1387
1397
|
cutawayEnabled: () => cutaway.isEnabled,
|
|
1388
1398
|
setCutawayEnabled,
|
|
1399
|
+
// Carry the slice across a remount — see cutaway.js's getState/setState.
|
|
1400
|
+
getCutawayState: cutaway.getState,
|
|
1401
|
+
setCutawayState,
|
|
1389
1402
|
flipCutaway: cutaway.flip,
|
|
1390
1403
|
resetCutaway: cutaway.reset,
|
|
1391
1404
|
isWorldPointVisible: cutaway.isPointVisible,
|
package/src/parts/emblem.js
CHANGED
|
@@ -3,20 +3,32 @@
|
|
|
3
3
|
// millimetre drawing (`plate`, `units: "mm"`, placed exactly as drawn). The two
|
|
4
4
|
// vectors share one build, composed together with an ordinary boolean.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// The two entries also demonstrate the two SOURCE forms, deliberately:
|
|
7
|
+
//
|
|
8
|
+
// `emblem` is `new URL(..., import.meta.url)` — the form import-demo.js uses
|
|
9
|
+
// for its STL. Vite turns it into a bundled asset URL; in Node it is a file:
|
|
10
|
+
// URL src/testing/assets.js reads off disk. Right for ingested output, which is
|
|
11
|
+
// generated, large, and not meant to be read by hand.
|
|
12
|
+
//
|
|
13
|
+
// `plate` is the file's parsed CONTENTS, imported directly. Right for artwork
|
|
14
|
+
// that is hand-authored and meant to STAY hand-editable: the numbers live in a
|
|
15
|
+
// .json a reader can open, and nothing has to fetch anything to see them —
|
|
16
|
+
// which is also what lets lint read the file before the first build has run.
|
|
17
|
+
// The `with { type: "json" }` attribute is required: Node refuses a JSON import
|
|
18
|
+
// without it, and a bare `() => import("./assets/plate.vector.json")` would
|
|
19
|
+
// work under Vite and fail in the CLI.
|
|
10
20
|
//
|
|
11
21
|
// The source artwork lives beside it as emblem.svg, and the .json is regenerated
|
|
12
22
|
// with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
|
|
13
23
|
// is hand-authored — no ingest step, no source SVG — and is kept legible enough
|
|
14
24
|
// to serve as documentation's worked example of a multi-shape, role-composed file.
|
|
25
|
+
import plate from "./assets/plate.vector.json" with { type: "json" };
|
|
26
|
+
|
|
15
27
|
export default {
|
|
16
28
|
meta: { title: "Emblem", units: "mm", background: 0x15181d },
|
|
17
29
|
vectors: {
|
|
18
30
|
emblem: new URL("./assets/emblem.vector.json", import.meta.url),
|
|
19
|
-
plate
|
|
31
|
+
plate,
|
|
20
32
|
},
|
|
21
33
|
parameters: [
|
|
22
34
|
{
|
package/types/index.d.ts
CHANGED
|
@@ -132,12 +132,52 @@ export interface MountOptions {
|
|
|
132
132
|
* the sketch with a typed message — and calls `runtime.annotate.send()`.
|
|
133
133
|
*/
|
|
134
134
|
annotateSend?: "viewbar" | "host";
|
|
135
|
+
/**
|
|
136
|
+
* A previous mount's `runtime.getViewerState()`, handed back so this mount
|
|
137
|
+
* resumes the camera, projection and cutaway where that one left them. For a
|
|
138
|
+
* host that applies edits by REMOUNTING: the part changed, the user's view of
|
|
139
|
+
* it should not.
|
|
140
|
+
*
|
|
141
|
+
* Omit on a first mount — the viewer then restores its own persisted camera
|
|
142
|
+
* and projection as before. Restore is best-effort per field: a pose this
|
|
143
|
+
* part cannot support is dropped, never fatal.
|
|
144
|
+
*/
|
|
145
|
+
viewerState?: ViewerState | null;
|
|
135
146
|
/** @deprecated alias for `elements.viewer`. */
|
|
136
147
|
container?: HTMLElement | null;
|
|
137
148
|
/** @deprecated alias for `elements.controls`. */
|
|
138
149
|
controls?: HTMLElement | null;
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
/** A cut plane, as `ViewerState` carries it across a remount. */
|
|
153
|
+
export interface CutawayState {
|
|
154
|
+
/** Whether the cutaway was on. `false` means there is nothing to restore. */
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
/** Which half the plane keeps. */
|
|
157
|
+
flipped: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* The plane's world pose: `position` as `[x, y, z]`, `quaternion` as
|
|
160
|
+
* `[x, y, z, w]`. `null` while disabled. The plane's on-screen SIZE is
|
|
161
|
+
* deliberately absent — it follows the part's bounds, and the part is what
|
|
162
|
+
* changed, so a restore re-derives it from the geometry it lands on.
|
|
163
|
+
*/
|
|
164
|
+
pose: { position: [number, number, number]; quaternion: [number, number, number, number] } | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Everything about the current view that a remount would otherwise lose. Plain
|
|
169
|
+
* JSON: it outlives the mount that produced it, so nothing in it points into a
|
|
170
|
+
* disposed scene, and a host may store or post it rather than only handing it
|
|
171
|
+
* straight back. Read it with `runtime.getViewerState()`; hand it back as
|
|
172
|
+
* `MountOptions.viewerState`.
|
|
173
|
+
*/
|
|
174
|
+
export interface ViewerState {
|
|
175
|
+
/** The live camera pose, or `null` when the viewer could not report one. */
|
|
176
|
+
camera: { pos: [number, number, number]; target: [number, number, number] } | null;
|
|
177
|
+
projection: "perspective" | "orthographic";
|
|
178
|
+
cutaway: CutawayState | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
141
181
|
export interface ExportPartsOptions {
|
|
142
182
|
/** Sub-part names, as `listExportableParts()` reports them. */
|
|
143
183
|
parts: string[];
|
|
@@ -346,6 +386,14 @@ export interface PartRuntime {
|
|
|
346
386
|
* unmounting it. Captures still work while parked. Safe after `dispose()`.
|
|
347
387
|
*/
|
|
348
388
|
setActive(active: boolean): void;
|
|
389
|
+
/**
|
|
390
|
+
* Snapshot the camera, projection and cutaway so a REMOUNT can resume them —
|
|
391
|
+
* pass the result as the next `mount()`'s `viewerState`. Read at teardown
|
|
392
|
+
* time, so it is the live pose, unlike the camera the viewer persists for a
|
|
393
|
+
* page reload (which only records the end of an orbit drag, and so misses a
|
|
394
|
+
* view-cube click, Reframe, or an animation cue).
|
|
395
|
+
*/
|
|
396
|
+
getViewerState(): ViewerState;
|
|
349
397
|
/**
|
|
350
398
|
* Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
|
|
351
399
|
* can say so rather than showing a dead canvas. The listener takes no
|
package/types/part.d.ts
CHANGED
|
@@ -257,6 +257,57 @@ export type FontSource =
|
|
|
257
257
|
|
|
258
258
|
type FontSourceValue = string | ArrayBuffer | ArrayBufferView | { default: string };
|
|
259
259
|
|
|
260
|
+
// --- imports and vectors ------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One entry of a part's `imports` map: the STEP/STL/3MF file a `k.import()` call
|
|
264
|
+
* names. Same source grammar and preload timing as {@link FontSource}.
|
|
265
|
+
*/
|
|
266
|
+
export type ImportSource = FontSource;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One entry of a part's `vectors` map: a `partforge-vector` file for `k.vector2d()`
|
|
270
|
+
* to place — never a raw `.svg`, which nothing in the geometry worker can read.
|
|
271
|
+
*
|
|
272
|
+
* Beyond the bytes/URL/thunk forms every asset source accepts, a vector source may
|
|
273
|
+
* be the file's ALREADY-PARSED contents: the object a `.vector.json` yields when
|
|
274
|
+
* something has imported or fetched it. That is the form to reach for when the
|
|
275
|
+
* artwork lives in the part's own tree and is meant to stay readable and editable,
|
|
276
|
+
* rather than sitting behind an opaque asset token.
|
|
277
|
+
*
|
|
278
|
+
* The object is read, never written, and is validated on every resolve — so a
|
|
279
|
+
* malformed one fails with the same message its on-disk twin would produce.
|
|
280
|
+
*/
|
|
281
|
+
export type VectorSource =
|
|
282
|
+
| string
|
|
283
|
+
| ArrayBuffer
|
|
284
|
+
| ArrayBufferView
|
|
285
|
+
| VectorDocument
|
|
286
|
+
| (() => VectorSourceValue | Promise<VectorSourceValue>);
|
|
287
|
+
|
|
288
|
+
type VectorSourceValue =
|
|
289
|
+
| string
|
|
290
|
+
| ArrayBuffer
|
|
291
|
+
| ArrayBufferView
|
|
292
|
+
| VectorDocument
|
|
293
|
+
| { default: string | VectorDocument };
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The parsed contents of a `partforge-vector` file. `docs/VECTOR-FORMAT.md` is the
|
|
297
|
+
* normative spec; this type is deliberately shallow — it pins the envelope every
|
|
298
|
+
* reader depends on and leaves contour shapes to the runtime validator, which
|
|
299
|
+
* reports far better errors than a structural type mismatch can.
|
|
300
|
+
*/
|
|
301
|
+
export interface VectorDocument {
|
|
302
|
+
format: "partforge-vector";
|
|
303
|
+
version: number;
|
|
304
|
+
units: "mm" | "artwork";
|
|
305
|
+
shapes: Record<string, unknown>;
|
|
306
|
+
source?: unknown;
|
|
307
|
+
bbox?: unknown;
|
|
308
|
+
note?: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
260
311
|
// --- derive -----------------------------------------------------------------
|
|
261
312
|
|
|
262
313
|
/**
|
|
@@ -316,6 +367,11 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
|
|
|
316
367
|
|
|
317
368
|
export interface ViewDefinition {
|
|
318
369
|
label: string;
|
|
370
|
+
/**
|
|
371
|
+
* Open this view first. With none flagged, the first key wins — see
|
|
372
|
+
* `default-view.js`, which also falls back when the flagged view is empty.
|
|
373
|
+
*/
|
|
374
|
+
default?: boolean;
|
|
319
375
|
/**
|
|
320
376
|
* Named animations belonging to this view — keyframe data driving this view's
|
|
321
377
|
* params and sub-part opacity over time. See `AnimationSpec` below; the
|
|
@@ -548,6 +604,10 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
|
|
|
548
604
|
defaults: Defaults;
|
|
549
605
|
/** Outline fonts a part's `k.text2d()` calls need, as `{ name: source }`. */
|
|
550
606
|
fonts?: Record<string, FontSource>;
|
|
607
|
+
/** STEP/STL/3MF files a part's `k.import()` calls need, as `{ name: source }`. */
|
|
608
|
+
imports?: Record<string, ImportSource>;
|
|
609
|
+
/** Vector artwork a part's `k.vector2d()` calls place, as `{ name: source }`. */
|
|
610
|
+
vectors?: Record<string, VectorSource>;
|
|
551
611
|
/** Dependent values computed once per build. */
|
|
552
612
|
derive?: DeriveSpec<P, D>;
|
|
553
613
|
/** Named sub-parts; each builds exactly one solid. */
|
|
@@ -556,4 +616,9 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
|
|
|
556
616
|
views: Record<string, ViewDefinition>;
|
|
557
617
|
/** Self-verification, co-located with the schema. */
|
|
558
618
|
verify?: VerifyBlock<P, D>;
|
|
619
|
+
/**
|
|
620
|
+
* Named measurements reported by `measure`/`inspect` — never rendered, never
|
|
621
|
+
* exported. Each entry returns either a solid to measure or plain JSON.
|
|
622
|
+
*/
|
|
623
|
+
probes?: Record<string, (k: GeometryKernel, p: P, d: D) => unknown>;
|
|
559
624
|
}
|