partforge 0.168.0 → 0.169.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 +16 -3
- package/package.json +2 -2
- package/src/framework/core/core-wasm.js +3 -3
- package/src/framework/geometry/creased-normals.js +22 -1
- package/src/framework/mount-preview.js +129 -23
- package/src/framework/preview-play-button.js +4 -3
- package/src/framework/preview-player.js +38 -8
- package/src/framework/viewer.js +104 -2
- package/types/index.d.ts +33 -5
|
@@ -281,6 +281,27 @@ export function creasedNormalsJS(g, { policies = null, featureLabels = null, sur
|
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// A thin triangle is noise only when its facet disagrees with its surface. A
|
|
285
|
+
// flat cap's triangulation makes thin triangles of its own — three consecutive
|
|
286
|
+
// rim vertices (sagitta-tall), a long fan from the rim to a bore — and gating
|
|
287
|
+
// those dropped a rim segment or two from nearly every cylinder top. A thin
|
|
288
|
+
// triangle lying within COPLANAR_ANGLE of a full-size triangle of its own
|
|
289
|
+
// surface at one of its corners carries a true normal, so it keeps its lines;
|
|
290
|
+
// the boolean slivers the gate is for tilt 18-90° off their neighbours (or have
|
|
291
|
+
// no normal at all) and stay gated. Blend bands are left out: a band curves, so
|
|
292
|
+
// a sliver folded across it still lies along SOME band facet at a corner, and
|
|
293
|
+
// its fan slivers are what the gate was written for.
|
|
294
|
+
const trueThin = new Uint8Array(nTri);
|
|
295
|
+
for (let t = 0; t < nTri; t++) {
|
|
296
|
+
if (thin[t] >= MIN_FACE || polFor(triOID[t]).boundaryLines) continue;
|
|
297
|
+
const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2];
|
|
298
|
+
search: for (let k = 0; k < 3; k++)
|
|
299
|
+
for (const t2 of incident.get(weld[tris[t * 3 + k]])) {
|
|
300
|
+
if (thin[t2] < MIN_FACE || triOID[t2] !== triOID[t]) continue;
|
|
301
|
+
if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz >= COPLANAR_COS) { trueThin[t] = 1; break search; }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
284
305
|
// Feature edge segments for CAD-style edge lines: draw a line where the
|
|
285
306
|
// surface actually BENDS. Same-surface edges draw per the surface's policy
|
|
286
307
|
// (sharper than creaseAngle, and only if the policy wants same-surface lines
|
|
@@ -318,7 +339,7 @@ export function creasedNormalsJS(g, { policies = null, featureLabels = null, sur
|
|
|
318
339
|
// filter still drops the short ones).
|
|
319
340
|
const boundary = !sameOID &&
|
|
320
341
|
!!polFor(triOID[prev]).boundaryLines !== !!polFor(triOID[t]).boundaryLines;
|
|
321
|
-
if (!boundary && (thin[prev] < MIN_FACE || thin[t] < MIN_FACE)) continue;
|
|
342
|
+
if (!boundary && ((thin[prev] < MIN_FACE && !trueThin[prev]) || (thin[t] < MIN_FACE && !trueThin[t]))) continue;
|
|
322
343
|
const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
|
|
323
344
|
// A multi-hole cap triangulation can contain an opposite-wound bridge:
|
|
324
345
|
// its two normals disagree by 180 degrees even though both triangles lie
|
|
@@ -49,17 +49,24 @@
|
|
|
49
49
|
// - `turntable: <degrees per second>` (default 0, off) — a file with no
|
|
50
50
|
// playing animation (a still file, or `animate: false`) turns about the
|
|
51
51
|
// vertical off the viewer's frame loop. Never under reduced motion; the
|
|
52
|
-
// first camera grab — a canvas drag or the view cube — stops it
|
|
53
|
-
// Dragging an animated model orbits the camera and the
|
|
52
|
+
// first camera grab — a canvas drag or the view cube — stops it (until
|
|
53
|
+
// `motion.restart()`). Dragging an animated model orbits the camera and the
|
|
54
|
+
// animation plays on.
|
|
54
55
|
// - `fill: <0 < f <= 1>` — the opening framing (and the viewer's reframe)
|
|
55
56
|
// places the camera, along the kept view direction, so the model's posed
|
|
56
57
|
// bounding box as projected on screen spans that fraction of the viewport
|
|
57
58
|
// along its limiting axis. A turning model is framed swept a full turn
|
|
58
59
|
// about the vertical (its farthest vertex from the axis), so the turntable
|
|
59
|
-
// never clips and a round part still fills
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
60
|
+
// never clips and a round part still fills, centred on its horizontal
|
|
61
|
+
// vertex centroid (a lopsided part turns about its hub). A model that is
|
|
62
|
+
// not turning (a still, or a playing animation — framed on its first
|
|
63
|
+
// pose) is fitted by its posed VERTICES and its silhouette centred on
|
|
64
|
+
// screen (0.169.0): the target moves in the screen plane so the projected
|
|
65
|
+
// outline's bounding box is centred, spanning `fill`. A baked animation
|
|
66
|
+
// that moves parts outside its first pose's outline can leave the frame.
|
|
67
|
+
// Unset, the viewer's fixed framing distance, as before.
|
|
68
|
+
// The swept framing follows the configuration, not reduced motion (0.169.0):
|
|
69
|
+
// a turntable mount is framed swept even when reduced motion keeps it still.
|
|
63
70
|
// - `camera: "default"` — the camera and projection of `viewerState` and of
|
|
64
71
|
// every setViewerState are dropped: the mount opens, and stays, at the
|
|
65
72
|
// file's own default framing. Their render mode and environment still apply.
|
|
@@ -67,8 +74,32 @@
|
|
|
67
74
|
// over any carried render mode. After that it is an ordinary mode —
|
|
68
75
|
// setViewerState, renderMode.set and the view style control change it.
|
|
69
76
|
// Unset, the carried state decides, as before.
|
|
77
|
+
// - `autostart: false` (0.169.0; default true) — the motion (a playing
|
|
78
|
+
// animation, or the turntable) is set up but WAITS: the model is drawn at
|
|
79
|
+
// the animation's first pose (the t = 0 pose a started player draws first)
|
|
80
|
+
// and at turntable angle 0, and nothing moves until `handle.motion.start()`.
|
|
81
|
+
// `drawn` resolves on a frame showing exactly that pose, so a host can
|
|
82
|
+
// cross-fade from a still of it and then say go. A waiting animation's play
|
|
83
|
+
// button reads Play.
|
|
70
84
|
// Any other key throws (an option this version does not know is a host bug).
|
|
71
85
|
//
|
|
86
|
+
// `handle.motion` (0.169.0; null before ready and once disposed, present for
|
|
87
|
+
// every decoded file) drives the motion whatever `autostart` said:
|
|
88
|
+
// - `start()` begins a waiting motion. Idempotent; a no-op once started, under
|
|
89
|
+
// reduced motion, or with nothing to move.
|
|
90
|
+
// - `restart()` puts the model back as it opened and starts it: the camera
|
|
91
|
+
// returns to the opening framing (showAssembly's framing at mount — the
|
|
92
|
+
// file's default view direction at the fill distance, before any carried
|
|
93
|
+
// camera — so a visitor's orbit is undone), the animation replays from
|
|
94
|
+
// t = 0 (intro cue included) or the turntable turns again from there,
|
|
95
|
+
// re-armed so the next grab stops it again. Under reduced motion it only
|
|
96
|
+
// resets the camera (the pose already is t = 0).
|
|
97
|
+
// - `state` — "waiting" | "playing" | "stopped" (a turntable stopped by a
|
|
98
|
+
// grab, an animation paused from its button, reduced motion, or nothing to
|
|
99
|
+
// move) | "finished" (a play-once animation resting at its end pose).
|
|
100
|
+
// - `onChange(cb)` → unsubscribe: hears each state change.
|
|
101
|
+
// `handle.animation` is unchanged.
|
|
102
|
+
//
|
|
72
103
|
// `handle.drawn` resolves after the first frame that includes the meshes has
|
|
73
104
|
// rendered (so a host can cross-fade from a still image without a flash). It
|
|
74
105
|
// rejects with `ready`'s error when the file will not decode, and rejects if
|
|
@@ -105,9 +136,10 @@ function flag(name, value, fallback) {
|
|
|
105
136
|
const KNOWN_OPTIONS = new Set([
|
|
106
137
|
"glb", "elements", "viewerState", "animate",
|
|
107
138
|
"chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
|
|
139
|
+
"autostart",
|
|
108
140
|
]);
|
|
109
141
|
|
|
110
|
-
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode }) {
|
|
142
|
+
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode, autostart }) {
|
|
111
143
|
if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
|
|
112
144
|
throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
|
|
113
145
|
}
|
|
@@ -124,30 +156,47 @@ function presentation({ chrome, background, contactShadow, zoom, pan, turntable,
|
|
|
124
156
|
fill: fill ?? null,
|
|
125
157
|
defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
|
|
126
158
|
renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
|
|
159
|
+
autostart: flag("autostart", autostart, true),
|
|
127
160
|
};
|
|
128
161
|
}
|
|
129
162
|
|
|
130
163
|
// Turn the camera about the vertical at `degPerSec` until the first camera grab.
|
|
131
164
|
// It turns through viewer.orbitBy, which itself announces a camera grab — so the
|
|
132
165
|
// turntable's own steps are told apart from the user's by a flag held across
|
|
133
|
-
// the call.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
166
|
+
// the call. It begins "waiting" (angle 0, nothing moves) until start(); the
|
|
167
|
+
// first grab — while waiting or turning — stops it for good; restart() re-arms
|
|
168
|
+
// it and turns again from wherever the camera is now (the host has just put it
|
|
169
|
+
// back). `onChange` hears every phase change.
|
|
170
|
+
function createTurntable(viewer, container, degPerSec, onChange) {
|
|
171
|
+
let turning = false, phase = "waiting", offFrame = null;
|
|
172
|
+
const set = (next) => { if (next !== phase) { phase = next; onChange(); } };
|
|
173
|
+
function step(dt) {
|
|
137
174
|
// orbitBy's rate: a drag the full stage height is a full turn.
|
|
138
175
|
const dx = ((degPerSec * dt) / 360) * Math.max(1, container.clientHeight || 1);
|
|
139
176
|
if (!(dx > 0)) return;
|
|
140
177
|
turning = true;
|
|
141
178
|
try { viewer.orbitBy(dx, 0); } finally { turning = false; }
|
|
142
|
-
});
|
|
143
|
-
const offGrab = viewer.onCameraStart(() => { if (!turning) stop(); });
|
|
144
|
-
function stop() {
|
|
145
|
-
if (stopped) return;
|
|
146
|
-
stopped = true;
|
|
147
|
-
offFrame();
|
|
148
|
-
offGrab();
|
|
149
179
|
}
|
|
150
|
-
|
|
180
|
+
function halt() { offFrame?.(); offFrame = null; }
|
|
181
|
+
const offGrab = viewer.onCameraStart(() => {
|
|
182
|
+
if (turning || phase === "stopped") return;
|
|
183
|
+
halt();
|
|
184
|
+
set("stopped");
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
phase: () => phase,
|
|
188
|
+
start() {
|
|
189
|
+
if (phase !== "waiting") return;
|
|
190
|
+
offFrame = viewer.onFrame(step);
|
|
191
|
+
set("playing");
|
|
192
|
+
},
|
|
193
|
+
restart() {
|
|
194
|
+
halt();
|
|
195
|
+
offFrame = viewer.onFrame(step);
|
|
196
|
+
set("playing");
|
|
197
|
+
},
|
|
198
|
+
dispose() { halt(); offGrab(); },
|
|
199
|
+
};
|
|
151
200
|
}
|
|
152
201
|
|
|
153
202
|
function previewPart({ meshes, view, meta }) {
|
|
@@ -192,6 +241,8 @@ export function mountPreview(container, options = {}) {
|
|
|
192
241
|
if (opts.renderMode) carried = { ...(carried ?? {}), renderMode: opts.renderMode };
|
|
193
242
|
let live = null; // { viewer, view } once the GLB has decoded
|
|
194
243
|
let player = null; // the baked animation's player, once drawn
|
|
244
|
+
let motion = null; // handle.motion, once the GLB has decoded
|
|
245
|
+
const motionListeners = new Set();
|
|
195
246
|
const cleanups = [];
|
|
196
247
|
const modeListeners = new Set();
|
|
197
248
|
const envListeners = new Set();
|
|
@@ -220,11 +271,15 @@ export function mountPreview(container, options = {}) {
|
|
|
220
271
|
const view = decoded.view ?? "preview";
|
|
221
272
|
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
222
273
|
const plays = !!decoded.animation && animate !== false;
|
|
223
|
-
|
|
274
|
+
// Framing follows the configuration, not reduced motion: a turntable mount
|
|
275
|
+
// is framed swept whether or not it is allowed to turn, so every visitor
|
|
276
|
+
// sees the model at the same size and centre.
|
|
277
|
+
const sweeps = !plays && opts.turntable > 0;
|
|
278
|
+
const turns = sweeps && !reducedMotion;
|
|
224
279
|
const viewer = createViewer(container, previewPart({ ...decoded, view }), {
|
|
225
280
|
transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
|
|
226
281
|
// A turning model is framed swept a full turn, so no angle of it clips.
|
|
227
|
-
frameFill: opts.fill, frameSwept:
|
|
282
|
+
frameFill: opts.fill, frameSwept: sweeps,
|
|
228
283
|
});
|
|
229
284
|
cleanups.push(() => viewer.dispose());
|
|
230
285
|
let tooltip = null, toolbar = null, viewStyle = null;
|
|
@@ -246,23 +301,70 @@ export function mountPreview(container, options = {}) {
|
|
|
246
301
|
cleanups.push(viewer.onContextLost(relay(lostListeners)));
|
|
247
302
|
for (const m of decoded.meshes) viewer.setSubGeometry(m.name, m);
|
|
248
303
|
viewer.showAssembly(decoded.meshes.map((m) => m.name), { frame: true });
|
|
304
|
+
// The opening framing restart() returns to: the file's default view
|
|
305
|
+
// direction at the fill distance, before any carried camera.
|
|
306
|
+
const opening = viewer.getCameraState();
|
|
249
307
|
live = { viewer, view };
|
|
250
308
|
applyState(carried);
|
|
251
309
|
if (!active) viewer.setActive(false);
|
|
252
310
|
const offRendered = viewer.onRendered(() => { offRendered(); resolveDrawn(); });
|
|
253
311
|
cleanups.push(offRendered);
|
|
312
|
+
let turntable = null;
|
|
313
|
+
let lastMotionState = null;
|
|
314
|
+
const motionState = () => {
|
|
315
|
+
if (player && !reducedMotion) {
|
|
316
|
+
if (!player.started()) return "waiting";
|
|
317
|
+
const { status } = player.state();
|
|
318
|
+
return status === "done" ? "finished" : status === "playing" || status === "intro" ? "playing" : "stopped";
|
|
319
|
+
}
|
|
320
|
+
return turntable ? turntable.phase() : "stopped";
|
|
321
|
+
};
|
|
322
|
+
const motionChanged = () => {
|
|
323
|
+
if (disposed) return;
|
|
324
|
+
const now = motionState();
|
|
325
|
+
if (now === lastMotionState) return;
|
|
326
|
+
lastMotionState = now;
|
|
327
|
+
relay(motionListeners)(now);
|
|
328
|
+
};
|
|
254
329
|
if (plays) {
|
|
255
330
|
player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
|
|
256
331
|
const own = player;
|
|
257
332
|
cleanups.push(() => own.dispose());
|
|
258
|
-
|
|
333
|
+
cleanups.push(own.onChange(motionChanged));
|
|
334
|
+
if (opts.autostart) player.start();
|
|
335
|
+
else player.prime(); // drawn at the first frame, waiting for motion.start()
|
|
259
336
|
if (!reducedMotion && toolbar) {
|
|
260
337
|
const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
|
|
261
338
|
cleanups.push(() => playButton.detach());
|
|
262
339
|
}
|
|
263
340
|
} else if (turns) {
|
|
264
|
-
|
|
341
|
+
const own = turntable = createTurntable(viewer, container, opts.turntable, motionChanged);
|
|
342
|
+
cleanups.push(() => own.dispose());
|
|
343
|
+
if (opts.autostart) turntable.start();
|
|
265
344
|
}
|
|
345
|
+
lastMotionState = motionState();
|
|
346
|
+
motion = {
|
|
347
|
+
get state() { return motionState(); },
|
|
348
|
+
start() {
|
|
349
|
+
if (disposed) return;
|
|
350
|
+
if (player) player.start(); // a no-op once started or under reduced motion
|
|
351
|
+
else turntable?.start();
|
|
352
|
+
motionChanged();
|
|
353
|
+
},
|
|
354
|
+
restart() {
|
|
355
|
+
if (disposed) return;
|
|
356
|
+
viewer.cancelCameraTween();
|
|
357
|
+
viewer.setCameraState(opening);
|
|
358
|
+
if (player) player.restart(); // nothing under reduced motion: the pose is already t = 0
|
|
359
|
+
else turntable?.restart();
|
|
360
|
+
motionChanged();
|
|
361
|
+
},
|
|
362
|
+
onChange(cb) {
|
|
363
|
+
if (disposed) return () => {};
|
|
364
|
+
motionListeners.add(cb);
|
|
365
|
+
return () => motionListeners.delete(cb);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
266
368
|
});
|
|
267
369
|
ready.catch(rejectDrawn); // the host awaits it; never an unhandled rejection
|
|
268
370
|
|
|
@@ -293,6 +395,8 @@ export function mountPreview(container, options = {}) {
|
|
|
293
395
|
rejectDrawn(new Error("mountPreview: disposed before the preview was drawn")); // a no-op once drawn
|
|
294
396
|
live = null;
|
|
295
397
|
player = null; // `animation` answers null once disposed (the cleanup below disposes it)
|
|
398
|
+
motion = null;
|
|
399
|
+
motionListeners.clear();
|
|
296
400
|
const errors = [];
|
|
297
401
|
while (cleanups.length) {
|
|
298
402
|
try { cleanups.pop()(); } catch (error) { errors.push(error); }
|
|
@@ -305,6 +409,8 @@ export function mountPreview(container, options = {}) {
|
|
|
305
409
|
get animation() {
|
|
306
410
|
return player ? { state: player.state, pause: player.pause, resume: player.resume, replay: player.replay } : null;
|
|
307
411
|
},
|
|
412
|
+
// null before ready and once disposed; present for every decoded file.
|
|
413
|
+
get motion() { return motion; },
|
|
308
414
|
getViewerState,
|
|
309
415
|
setViewerState(next) {
|
|
310
416
|
carried = carry(next);
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Three states, read off the player's onChange notifications (never polled):
|
|
7
7
|
// - Pause while playing or during the intro camera tween;
|
|
8
|
-
// - Play while paused — resumes from the pose it froze at
|
|
8
|
+
// - Play while paused — resumes from the pose it froze at — or while a
|
|
9
|
+
// mount with `autostart: false` waits at its first frame (starts it);
|
|
9
10
|
// - Replay once a one-time animation has finished at its end pose — plays it
|
|
10
11
|
// again from the start, camera cues included. A looping animation
|
|
11
12
|
// never gets here.
|
|
@@ -30,7 +31,7 @@ const STATES = {
|
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
// What a click does for a given player status.
|
|
33
|
-
const actionFor = (status) => (status === "paused" ? "play" : status === "done" ? "replay" : "pause");
|
|
34
|
+
const actionFor = (status) => (status === "paused" || status === "idle" ? "play" : status === "done" ? "replay" : "pause");
|
|
34
35
|
|
|
35
36
|
export function attachPreviewPlayButton(player, { toolbar, before = null }, { tooltip = null } = {}) {
|
|
36
37
|
const button = document.createElement("button");
|
|
@@ -58,7 +59,7 @@ export function attachPreviewPlayButton(player, { toolbar, before = null }, { to
|
|
|
58
59
|
|
|
59
60
|
const onClick = () => {
|
|
60
61
|
if (action === "pause") player.pause();
|
|
61
|
-
else if (action === "play") player.resume();
|
|
62
|
+
else if (action === "play") { if (player.state().status === "idle") player.start(); else player.resume(); }
|
|
62
63
|
else player.replay();
|
|
63
64
|
};
|
|
64
65
|
button.addEventListener("click", onClick);
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
// re-honors an abandoned intro or sweep on the next play(). An orbit while
|
|
27
27
|
// paused stays paused. onChange(cb) reports every status change, so a control
|
|
28
28
|
// never polls.
|
|
29
|
+
//
|
|
30
|
+
// A host that wants the model to WAIT at its first frame (mountPreview's
|
|
31
|
+
// `autostart: false`, 0.169.0) calls prime() instead of start(): the t = 0 pose
|
|
32
|
+
// and opacity are drawn — exactly what start() draws first — and nothing
|
|
33
|
+
// advances until start(). restart() plays again from t = 0 from any state
|
|
34
|
+
// (waiting, playing, paused, done), re-arming the cues and the intro.
|
|
29
35
|
import { createPlayback } from "./animation.js";
|
|
30
36
|
import { invertRigid, mulMat4 } from "./geometry/pose.js";
|
|
31
37
|
|
|
@@ -106,7 +112,7 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
106
112
|
// already sits at its t = 0 placement, so the identity pose is the rest pose.
|
|
107
113
|
const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
|
|
108
114
|
|
|
109
|
-
let offFrame = null, offOrbit = null, started = false, disposed = false;
|
|
115
|
+
let offFrame = null, offOrbit = null, started = false, disposed = false, held = false;
|
|
110
116
|
const listeners = new Set();
|
|
111
117
|
let lastStatus = playback.state().status;
|
|
112
118
|
|
|
@@ -151,13 +157,24 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
151
157
|
if (playback.state().status === "intro") { playback.introDone(); notify(); }
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
|
|
160
|
+
// The t = 0 pose and opacity, with the ground held for what moves next.
|
|
161
|
+
function firstFrame() {
|
|
162
|
+
apply(0);
|
|
163
|
+
if (!held) { viewer.holdGround(true); held = true; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const player = {
|
|
167
|
+
// Draw the first frame and wait there (no-op under reduced motion, once
|
|
168
|
+
// started, or once disposed).
|
|
169
|
+
prime() {
|
|
170
|
+
if (started || disposed || reducedMotion) return;
|
|
171
|
+
firstFrame();
|
|
172
|
+
},
|
|
155
173
|
start() {
|
|
156
174
|
if (started || disposed) return;
|
|
157
175
|
started = true;
|
|
158
176
|
if (reducedMotion) return;
|
|
159
|
-
|
|
160
|
-
viewer.holdGround(true);
|
|
177
|
+
firstFrame();
|
|
161
178
|
offFrame = viewer.onFrame(onFrame);
|
|
162
179
|
offOrbit = viewer.onCameraStart(onOrbit);
|
|
163
180
|
honorIntro(playback.play());
|
|
@@ -185,6 +202,20 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
185
202
|
honorIntro(playback.play());
|
|
186
203
|
notify();
|
|
187
204
|
},
|
|
205
|
+
// From any state: back to t = 0 (pose, opacity, cues re-armed) and play,
|
|
206
|
+
// its intro cue included. Starts a player that was waiting. Nothing under
|
|
207
|
+
// reduced motion — the file's own opening pose is already the t = 0 pose.
|
|
208
|
+
restart() {
|
|
209
|
+
if (disposed || reducedMotion) return;
|
|
210
|
+
if (!started) { player.start(); return; }
|
|
211
|
+
viewer.cancelCameraTween();
|
|
212
|
+
playback.reset();
|
|
213
|
+
apply(0);
|
|
214
|
+
honorIntro(playback.play());
|
|
215
|
+
notify();
|
|
216
|
+
},
|
|
217
|
+
// True once start() has run (under reduced motion too).
|
|
218
|
+
started: () => started,
|
|
188
219
|
onChange(cb) {
|
|
189
220
|
if (disposed) return () => {};
|
|
190
221
|
listeners.add(cb);
|
|
@@ -194,11 +225,10 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
194
225
|
if (disposed) return;
|
|
195
226
|
disposed = true;
|
|
196
227
|
listeners.clear();
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
offFrame = offOrbit = null;
|
|
200
|
-
viewer.holdGround(false);
|
|
228
|
+
if (offFrame) { offFrame(); offOrbit(); offFrame = offOrbit = null; }
|
|
229
|
+
if (held) { held = false; viewer.holdGround(false); } // primed or played
|
|
201
230
|
},
|
|
202
231
|
state: snapshotState,
|
|
203
232
|
};
|
|
233
|
+
return player;
|
|
204
234
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -1928,6 +1928,7 @@ export function createViewer(container, part, {
|
|
|
1928
1928
|
|
|
1929
1929
|
// --- show / hide assembly -------------------------------------------------
|
|
1930
1930
|
const _box = new THREE.Box3();
|
|
1931
|
+
const _origin = new THREE.Vector3();
|
|
1931
1932
|
const _posedBox = new THREE.Box3();
|
|
1932
1933
|
const _frameDir = new THREE.Vector3();
|
|
1933
1934
|
|
|
@@ -1967,8 +1968,10 @@ export function createViewer(container, part, {
|
|
|
1967
1968
|
if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
|
|
1968
1969
|
_frameDir.normalize();
|
|
1969
1970
|
const filled = fill?.(_frameDir);
|
|
1970
|
-
|
|
1971
|
-
|
|
1971
|
+
// A silhouette-centred fill framing aims off the pivot: the target moves in
|
|
1972
|
+
// the screen plane (and the camera with it, keeping the view direction).
|
|
1973
|
+
controls.target.copy(filled?.offset ?? _origin);
|
|
1974
|
+
activeCamera.position.copy(_frameDir).multiplyScalar(filled ? filled.distance : r * 2.6 + 6).add(controls.target);
|
|
1972
1975
|
// Framing under ortho is a frustum, not a distance — without this the
|
|
1973
1976
|
// reframe button moves the camera and nothing visibly changes. A fill
|
|
1974
1977
|
// framing sizes the frustum to the projected box directly (the sync
|
|
@@ -2001,6 +2004,16 @@ export function createViewer(container, part, {
|
|
|
2001
2004
|
// Null when unset, or when the container has no size yet (a hidden or
|
|
2002
2005
|
// unlaid-out stage): frameTo then falls back to the fixed distance, and the
|
|
2003
2006
|
// next reframe fills.
|
|
2007
|
+
//
|
|
2008
|
+
// Without `frameSwept` (0.169.0) the points are the posed mesh VERTICES, not
|
|
2009
|
+
// the box corners, and the framing is silhouette-centred: the result also
|
|
2010
|
+
// carries `offset`, a world-space shift of the target (and the camera with
|
|
2011
|
+
// it) in the screen plane that centres the vertices' projected bounding box,
|
|
2012
|
+
// and the distance makes that box span frameFill along its limiting axis.
|
|
2013
|
+
// Box-centred framing put an obliquely-viewed wide flat part low on screen —
|
|
2014
|
+
// its near-bottom corner projects far past the far-top one — and fitted
|
|
2015
|
+
// corners that no vertex reaches. The swept framing keeps the target on the
|
|
2016
|
+
// pivot, so a turntable still turns about the hub.
|
|
2004
2017
|
const SWEEP_SEGMENTS = 48;
|
|
2005
2018
|
const _sweepV = new THREE.Vector3();
|
|
2006
2019
|
// The farthest horizontal distance of any posed vertex of `names` from the
|
|
@@ -2037,12 +2050,101 @@ export function createViewer(container, part, {
|
|
|
2037
2050
|
}
|
|
2038
2051
|
if (n > 0) center.set(sx / n, sy / n, center.z);
|
|
2039
2052
|
}
|
|
2053
|
+
// Every posed vertex of `names` about `center`, stood up by the pivot, as
|
|
2054
|
+
// flat x/y/z arrays (world units, pivot-relative).
|
|
2055
|
+
function posedVertices(names, center) {
|
|
2056
|
+
let n = 0;
|
|
2057
|
+
for (const name of names) n += subCache[name]?.getAttribute("position")?.count ?? 0;
|
|
2058
|
+
const px = new Float64Array(n), py = new Float64Array(n), pz = new Float64Array(n);
|
|
2059
|
+
let k = 0;
|
|
2060
|
+
for (const name of names) {
|
|
2061
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2062
|
+
if (!pos) continue;
|
|
2063
|
+
const pose = subMesh[name].matrix;
|
|
2064
|
+
for (let i = 0; i < pos.count; i++, k++) {
|
|
2065
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion);
|
|
2066
|
+
px[k] = _sweepV.x; py[k] = _sweepV.y; pz[k] = _sweepV.z;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return { px, py, pz, n };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// The silhouette-centred fill framing of posed vertices (see above).
|
|
2073
|
+
function centredFraming({ px, py, pz, n }, aspect) {
|
|
2074
|
+
if (n === 0) return null;
|
|
2075
|
+
let any = false;
|
|
2076
|
+
for (let i = 0; i < n && !any; i++) any = px[i] !== 0 || py[i] !== 0 || pz[i] !== 0;
|
|
2077
|
+
if (!any) return null;
|
|
2078
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
2079
|
+
const tanH = tanV * aspect;
|
|
2080
|
+
const xs = new Float64Array(n), ys = new Float64Array(n), along = new Float64Array(n);
|
|
2081
|
+
return (dir) => {
|
|
2082
|
+
const forward = dir.clone().negate();
|
|
2083
|
+
const right = new THREE.Vector3().crossVectors(forward, activeCamera.up);
|
|
2084
|
+
if (right.lengthSq() < 1e-12) right.set(1, 0, 0);
|
|
2085
|
+
right.normalize();
|
|
2086
|
+
const upAxis = new THREE.Vector3().crossVectors(right, forward);
|
|
2087
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, nearest = -Infinity;
|
|
2088
|
+
for (let i = 0; i < n; i++) {
|
|
2089
|
+
xs[i] = px[i] * right.x + py[i] * right.y + pz[i] * right.z;
|
|
2090
|
+
ys[i] = px[i] * upAxis.x + py[i] * upAxis.y + pz[i] * upAxis.z;
|
|
2091
|
+
along[i] = px[i] * dir.x + py[i] * dir.y + pz[i] * dir.z;
|
|
2092
|
+
if (xs[i] < x0) x0 = xs[i]; if (xs[i] > x1) x1 = xs[i];
|
|
2093
|
+
if (ys[i] < y0) y0 = ys[i]; if (ys[i] > y1) y1 = ys[i];
|
|
2094
|
+
if (along[i] > nearest) nearest = along[i];
|
|
2095
|
+
}
|
|
2096
|
+
// Orthographic: centre the extent exactly; the half-height that fits it.
|
|
2097
|
+
const orthoHalfH = Math.max((x1 - x0) / (2 * frameFill * aspect), (y1 - y0) / (2 * frameFill)) || 1;
|
|
2098
|
+
const orthoOffset = right.clone().multiplyScalar((x0 + x1) / 2).addScaledVector(upAxis, (y0 + y1) / 2);
|
|
2099
|
+
if (projectionMode === "orthographic") {
|
|
2100
|
+
const d = Math.max(nearest, 0) + Math.max(x1 - x0, y1 - y0) * 2 + 6;
|
|
2101
|
+
return { distance: d, orthoHalfH, aspect, offset: orthoOffset };
|
|
2102
|
+
}
|
|
2103
|
+
// Perspective: the projected box, the target shifted (tx, ty) in the
|
|
2104
|
+
// screen plane, the camera at distance d along `dir`.
|
|
2105
|
+
let tx = (x0 + x1) / 2, ty = (y0 + y1) / 2;
|
|
2106
|
+
const ext = (d) => {
|
|
2107
|
+
let a0 = Infinity, a1 = -Infinity, b0 = Infinity, b1 = -Infinity;
|
|
2108
|
+
for (let i = 0; i < n; i++) {
|
|
2109
|
+
const z = d - along[i];
|
|
2110
|
+
const nx = (xs[i] - tx) / (z * tanH), ny = (ys[i] - ty) / (z * tanV);
|
|
2111
|
+
if (nx < a0) a0 = nx; if (nx > a1) a1 = nx;
|
|
2112
|
+
if (ny < b0) b0 = ny; if (ny > b1) b1 = ny;
|
|
2113
|
+
}
|
|
2114
|
+
return [a0, a1, b0, b1];
|
|
2115
|
+
};
|
|
2116
|
+
const over = (d) => { const [a0, a1, b0, b1] = ext(d); return Math.max(a1 - a0, b1 - b0) / (2 * frameFill); };
|
|
2117
|
+
const fit = () => {
|
|
2118
|
+
let lo = nearest + 1e-6 * (1 + Math.abs(nearest)), hi = Math.max(lo * 2, 1);
|
|
2119
|
+
while (over(hi) > 1 && hi < 1e9) hi *= 2;
|
|
2120
|
+
for (let i = 0; i < 48; i++) {
|
|
2121
|
+
const mid = (lo + hi) / 2;
|
|
2122
|
+
if (over(mid) > 1) lo = mid; else hi = mid;
|
|
2123
|
+
}
|
|
2124
|
+
return hi;
|
|
2125
|
+
};
|
|
2126
|
+
let d = fit();
|
|
2127
|
+
// Recentre and refit: the shift moves each vertex's NDC by 1/depth, so a
|
|
2128
|
+
// few rounds converge.
|
|
2129
|
+
for (let round = 0; round < 8; round++) {
|
|
2130
|
+
const [a0, a1, b0, b1] = ext(d);
|
|
2131
|
+
const cx = (a0 + a1) / 2, cy = (b0 + b1) / 2;
|
|
2132
|
+
if (Math.abs(cx) < 1e-5 && Math.abs(cy) < 1e-5) break;
|
|
2133
|
+
tx += cx * d * tanH;
|
|
2134
|
+
ty += cy * d * tanV;
|
|
2135
|
+
d = fit();
|
|
2136
|
+
}
|
|
2137
|
+
return { distance: d, orthoHalfH, aspect, offset: right.multiplyScalar(tx).addScaledVector(upAxis, ty) };
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2040
2141
|
function fillFraming(box, names, center) {
|
|
2041
2142
|
if (!frameFill) return null;
|
|
2042
2143
|
const w = container.clientWidth, h = container.clientHeight;
|
|
2043
2144
|
if (!(w > 0 && h > 0)) return null;
|
|
2044
2145
|
const aspect = w / h;
|
|
2045
2146
|
pivot.updateMatrix();
|
|
2147
|
+
if (!frameSwept) return centredFraming(posedVertices(names, center), aspect);
|
|
2046
2148
|
// The box corners about the framing centre (no longer the box's own centre).
|
|
2047
2149
|
let points = [];
|
|
2048
2150
|
for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) {
|
package/types/index.d.ts
CHANGED
|
@@ -821,19 +821,28 @@ export interface MountPreviewOptions {
|
|
|
821
821
|
/**
|
|
822
822
|
* Degrees per second (0.168.0+; default 0, off): a file with no playing animation
|
|
823
823
|
* turns about the vertical. Never under reduced motion; the first camera grab
|
|
824
|
-
* (a canvas drag or the view cube) stops it
|
|
824
|
+
* (a canvas drag or the view cube) stops it until `motion.restart()`.
|
|
825
825
|
*/
|
|
826
826
|
turntable?: number;
|
|
827
|
+
/**
|
|
828
|
+
* `false` (0.169.0+; default true): the motion — a playing animation or the
|
|
829
|
+
* turntable — waits at its first frame (the animation's t = 0 pose, turntable
|
|
830
|
+
* angle 0) until `motion.start()`. `drawn` resolves on a frame showing that pose.
|
|
831
|
+
*/
|
|
832
|
+
autostart?: boolean;
|
|
827
833
|
/**
|
|
828
834
|
* 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
|
|
829
835
|
* camera, along the opening view direction, so the model's bounding box as projected
|
|
830
836
|
* on screen spans this fraction of the viewport along its limiting axis. With a
|
|
831
837
|
* `turntable` (and no baked animation) the model swept a full turn is framed — its
|
|
832
838
|
* farthest vertex from the vertical axis, over the box's height — so the turn never
|
|
833
|
-
* clips and a round part still fills
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
839
|
+
* clips and a round part still fills, centred on its horizontal vertex centroid (a
|
|
840
|
+
* lopsided part turns about its hub). Without a turntable (0.169.0+) the posed mesh
|
|
841
|
+
* vertices are fitted and their on-screen outline is centred (the target moves in the
|
|
842
|
+
* screen plane), spanning `fill`; a baked animation that moves parts outside its first
|
|
843
|
+
* pose's outline may leave the frame. Unset, the
|
|
844
|
+
* fixed framing distance. Since 0.169.0 a turntable mount is framed swept under reduced
|
|
845
|
+
* motion too, so every visitor sees the same framing.
|
|
837
846
|
*/
|
|
838
847
|
fill?: number;
|
|
839
848
|
/** `"default"` (0.168.0+): drop the camera and projection of `viewerState` and `setViewerState`, so the mount keeps the file's own framing; their render mode and environment still apply. Default `"carry"`. */
|
|
@@ -876,6 +885,25 @@ export interface PreviewRuntime {
|
|
|
876
885
|
/** Play a finished one-time animation again from the start (its camera cues included); a no-op unless `state().status` is `"done"`. */
|
|
877
886
|
replay(): void;
|
|
878
887
|
} | null;
|
|
888
|
+
/**
|
|
889
|
+
* The motion — baked animation or turntable — (0.169.0+): `null` before `ready` and
|
|
890
|
+
* once disposed; present for every decoded file.
|
|
891
|
+
*/
|
|
892
|
+
readonly motion: {
|
|
893
|
+
/** Begin a waiting motion. Idempotent; a no-op once started, under reduced motion, or with nothing to move. */
|
|
894
|
+
start(): void;
|
|
895
|
+
/**
|
|
896
|
+
* Put the model back as it opened and start it: the opening camera framing (the
|
|
897
|
+
* file's default view direction, before any carried camera), the animation from
|
|
898
|
+
* t = 0 or the turntable turning again (re-armed). Under reduced motion it only
|
|
899
|
+
* resets the camera.
|
|
900
|
+
*/
|
|
901
|
+
restart(): void;
|
|
902
|
+
/** `"stopped"`: a turntable stopped by a grab, a paused animation, reduced motion, or nothing to move. */
|
|
903
|
+
readonly state: "waiting" | "playing" | "stopped" | "finished";
|
|
904
|
+
/** Each state change. Returns an unsubscribe. */
|
|
905
|
+
onChange(cb: (state: "waiting" | "playing" | "stopped" | "finished") => void): () => void;
|
|
906
|
+
} | null;
|
|
879
907
|
renderMode: {
|
|
880
908
|
get(): RenderMode;
|
|
881
909
|
set(mode: RenderMode): Promise<RenderMode>;
|