partforge 0.127.0 → 0.129.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/docs/AUTHORING-PARTS.md +24 -2
- package/package.json +1 -1
- package/src/framework/animation-controls.js +7 -1
- package/src/framework/animation.js +44 -9
- package/src/framework/cutaway.js +4 -1
- package/src/framework/lint/rules-animations.js +9 -7
- package/src/framework/materials/environments.js +4 -4
- package/src/framework/measure/dim3-scene.js +8 -1
- package/src/framework/selection/feature-highlight.js +6 -1
- package/src/framework/view-angles.js +7 -0
- package/src/framework/viewer.js +78 -4
- package/src/testing/render.js +14 -2
- package/types/part.d.ts +23 -6
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -259,10 +259,32 @@ Rules (all lint-enforced):
|
|
|
259
259
|
- Couple motions through `derive` (animate one master param; derive the rest),
|
|
260
260
|
not by tracking dependent params separately.
|
|
261
261
|
- `camera` cues use the seven canonical angles (`iso front back top bottom
|
|
262
|
-
left right`)
|
|
263
|
-
|
|
262
|
+
left right`) or any view-cube orientation — an edge or corner named
|
|
263
|
+
vertical, then depth, then side (`top-front-left`, `top-back-right`,
|
|
264
|
+
`front-left`, `bottom-back-right`, …; `top-front-right` is `iso`). One
|
|
265
|
+
mechanism per animation: an animation-level name (an intro cue at t=0), an
|
|
266
|
+
animation-level `[[t, angle], …]` list, or per-step names.
|
|
264
267
|
Cues fire during play only — scrubbing never moves the camera — and a user
|
|
265
268
|
orbit disarms the remaining cues for that run.
|
|
269
|
+
- **A camera move takes as long as its cue asks**: a per-step `camera` sweeps
|
|
270
|
+
across that step's whole `duration`, and a listed cue sweeps until the next
|
|
271
|
+
cue (or the end). The move that settles the camera BEFORE playback starts —
|
|
272
|
+
the first cue, or the governing one when play resumes mid-timeline — is
|
|
273
|
+
always a short swing, because params wait for it. So a slow orbit timed to a
|
|
274
|
+
motion is a camera-only opening step (the starting angle) followed by the
|
|
275
|
+
moving step carrying the destination angle.
|
|
276
|
+
- **To turn the view, move the camera — never rotate the part.** A "spin"
|
|
277
|
+
param that rotates the geometry for a turntable effect looks right in CAD
|
|
278
|
+
mode but is wrong in realistic mode: the floor, shadow and backdrop stay put
|
|
279
|
+
in the world, so the part visibly slides around on them. Use cues instead:
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
steps: [
|
|
283
|
+
{ label: "Start", camera: "top-front-left", duration: 0.3 }, // establishing angle
|
|
284
|
+
{ label: "Open lid", camera: "iso", duration: 1.5, // orbits to iso over 1.5 s
|
|
285
|
+
tracks: { lidOpen: [[0, 0], [1, 100]] } },
|
|
286
|
+
]
|
|
287
|
+
```
|
|
266
288
|
- Playback drives params through the real param pipeline: a **pose-only**
|
|
267
289
|
param (feeds only rigid placement — see "Caching & determinism" below) plays
|
|
268
290
|
at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
|
package/package.json
CHANGED
|
@@ -495,6 +495,9 @@ export function attachAnimationControls(viewer, part, {
|
|
|
495
495
|
}
|
|
496
496
|
function apply(r) {
|
|
497
497
|
if (!r || !current) return;
|
|
498
|
+
// A playing frame always draws, whatever it changed: the viewer renders on
|
|
499
|
+
// demand, and playback must never stall waiting for something to ask.
|
|
500
|
+
viewer.requestRender?.();
|
|
498
501
|
try {
|
|
499
502
|
// Params go through applyValues (the slider path) only when the animation
|
|
500
503
|
// actually drives some: an opacity-only frame must neither snapshot nor
|
|
@@ -510,7 +513,10 @@ export function attachAnimationControls(viewer, part, {
|
|
|
510
513
|
for (const [n, v] of Object.entries(r.opacity ?? {})) viewer.setSubPartOpacity?.(n, v);
|
|
511
514
|
if (r.cue) {
|
|
512
515
|
viewer.tweenCameraTo(r.cue.view, {
|
|
513
|
-
|
|
516
|
+
// An intro gates playback, so it is always the short tween; a cue
|
|
517
|
+
// crossed mid-timeline sweeps for as long as the cue asks (its step,
|
|
518
|
+
// or until the next cue) — see normalizeAnimation.
|
|
519
|
+
duration: reducedMotion ? 0 : r.status === "intro" ? tweenDuration : (r.cue.sweep ?? tweenDuration),
|
|
514
520
|
// An intro cue gates playback until the tween settles; mid-timeline
|
|
515
521
|
// cues overlap playback and need no completion signal.
|
|
516
522
|
onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
|
|
@@ -62,10 +62,22 @@ export function normalizeAnimation(name, spec) {
|
|
|
62
62
|
const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
|
|
63
63
|
let acc = 0;
|
|
64
64
|
const stepStarts = steps.map((s) => { const t = acc / totalDuration; acc += s.duration; return t; });
|
|
65
|
+
// Each cue carries `sweep`: how many seconds its camera move takes when
|
|
66
|
+
// playback crosses it. A per-step cue sweeps across its own step, and a
|
|
67
|
+
// cue-list entry until the next cue (or the end), so a slow orbit can be timed
|
|
68
|
+
// to the motion it frames. An intro cue — the one play() settles on before
|
|
69
|
+
// params move — ignores it and uses the driver's short fixed tween, since it
|
|
70
|
+
// gates playback and a long one would read as a stall.
|
|
65
71
|
let cues;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
else
|
|
72
|
+
// A lone angle name is only ever an intro, so it has no sweep of its own.
|
|
73
|
+
if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera, sweep: null }];
|
|
74
|
+
else if (Array.isArray(spec.camera)) {
|
|
75
|
+
cues = spec.camera.map(([t, view], i, all) => ({
|
|
76
|
+
t, view, sweep: ((all[i + 1]?.[0] ?? 1) - t) * totalDuration,
|
|
77
|
+
}));
|
|
78
|
+
} else {
|
|
79
|
+
cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera, sweep: s.duration }] : []));
|
|
80
|
+
}
|
|
69
81
|
// Keys with at least one usable keyframe list in any step, for one field
|
|
70
82
|
// ("tracks" or "opacity"). Shares usableKeyframes with segmentsFor — the
|
|
71
83
|
// single rule both must agree on (see the comment on usableKeyframes).
|
|
@@ -200,6 +212,7 @@ export function createPlayback(anim) {
|
|
|
200
212
|
let firedCueT = -1; // cues with t <= firedCueT already fired this run
|
|
201
213
|
let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
|
|
202
214
|
let stopAt = null; // stepNext/playStep pause playback on reaching this t
|
|
215
|
+
let sweepingCue = null; // the cue tick() last fired, whose camera sweep may still be moving
|
|
203
216
|
|
|
204
217
|
const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
|
|
205
218
|
|
|
@@ -227,8 +240,24 @@ export function createPlayback(anim) {
|
|
|
227
240
|
stopAt = null;
|
|
228
241
|
return begin();
|
|
229
242
|
}
|
|
243
|
+
// A stop mid-sweep cancels the camera where it is. Un-fire that cue so the
|
|
244
|
+
// next play() re-honors it (as an intro) instead of resuming with the camera
|
|
245
|
+
// stranded partway; a sweep that has already landed stays fired, so an
|
|
246
|
+
// ordinary pause/resume costs no extra tween.
|
|
247
|
+
// Only a cue crossed DURING playback sweeps; an intro lands its pose fully
|
|
248
|
+
// before params move, so it is never abandoned here.
|
|
249
|
+
function abandonSweep() {
|
|
250
|
+
const c = sweepingCue;
|
|
251
|
+
sweepingCue = null;
|
|
252
|
+
if (c && t < c.t + c.sweep / anim.totalDuration) {
|
|
253
|
+
let prev = -1;
|
|
254
|
+
for (const o of anim.cues) if (o.t < c.t) prev = o.t;
|
|
255
|
+
firedCueT = prev;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
230
259
|
function pause() {
|
|
231
|
-
if (status === "playing" || status === "intro") status = "paused";
|
|
260
|
+
if (status === "playing" || status === "intro") { status = "paused"; abandonSweep(); }
|
|
232
261
|
pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
|
|
233
262
|
return snapshot();
|
|
234
263
|
}
|
|
@@ -246,6 +275,7 @@ export function createPlayback(anim) {
|
|
|
246
275
|
stopAt = null;
|
|
247
276
|
firedCueT = -1; // a later play() re-honors the cue governing the new position
|
|
248
277
|
pendingCueT = null;
|
|
278
|
+
sweepingCue = null;
|
|
249
279
|
return snapshot();
|
|
250
280
|
}
|
|
251
281
|
function playStep(i) {
|
|
@@ -254,6 +284,7 @@ export function createPlayback(anim) {
|
|
|
254
284
|
stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
255
285
|
firedCueT = -1;
|
|
256
286
|
pendingCueT = null;
|
|
287
|
+
sweepingCue = null;
|
|
257
288
|
return begin();
|
|
258
289
|
}
|
|
259
290
|
function stepNext() {
|
|
@@ -264,11 +295,11 @@ export function createPlayback(anim) {
|
|
|
264
295
|
return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
|
|
265
296
|
}
|
|
266
297
|
function reset() {
|
|
267
|
-
t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
|
|
298
|
+
t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true; sweepingCue = null;
|
|
268
299
|
return snapshot();
|
|
269
300
|
}
|
|
270
301
|
function disarmCues() { armed = false; }
|
|
271
|
-
function userEdited() { if (status === "playing" || status === "intro") status = "paused"; }
|
|
302
|
+
function userEdited() { if (status === "playing" || status === "intro") { status = "paused"; abandonSweep(); } }
|
|
272
303
|
|
|
273
304
|
function tick(dt) {
|
|
274
305
|
if (status !== "playing" || !(dt > 0)) return null;
|
|
@@ -277,17 +308,21 @@ export function createPlayback(anim) {
|
|
|
277
308
|
// animation, so the two rarely coexist — but when they do, an explicit
|
|
278
309
|
// "play this step" must still stop where it was told to, rather than being
|
|
279
310
|
// swallowed by the wrap and running forever.
|
|
311
|
+
let stoppedAtBoundary = false;
|
|
280
312
|
if (stopAt != null && t >= stopAt) {
|
|
281
|
-
t = stopAt; stopAt = null; status = "paused";
|
|
313
|
+
t = stopAt; stopAt = null; status = "paused"; stoppedAtBoundary = true;
|
|
282
314
|
} else if (anim.loop) {
|
|
283
315
|
if (t >= 1) t -= Math.floor(t);
|
|
284
316
|
} else if (t >= 1) {
|
|
285
317
|
t = 1; status = "done";
|
|
286
318
|
}
|
|
287
319
|
let cue = null;
|
|
288
|
-
|
|
320
|
+
// Stopping on a step boundary does not start the next step's camera move:
|
|
321
|
+
// that cue belongs to the step that has not played yet, and would otherwise
|
|
322
|
+
// sweep on while playback sits paused. Playing that step intros to it.
|
|
323
|
+
if (armed && !stoppedAtBoundary) {
|
|
289
324
|
for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) cue = c;
|
|
290
|
-
if (cue) firedCueT = cue.t;
|
|
325
|
+
if (cue) { firedCueT = cue.t; sweepingCue = cue; }
|
|
291
326
|
}
|
|
292
327
|
return snapshot(cue);
|
|
293
328
|
}
|
package/src/framework/cutaway.js
CHANGED
|
@@ -40,6 +40,9 @@ export function createCutaway({
|
|
|
40
40
|
edgeColor,
|
|
41
41
|
schedule = defaultSchedule,
|
|
42
42
|
now,
|
|
43
|
+
// The viewer draws on demand; the idle fade lands on a timer, with nothing
|
|
44
|
+
// else to ask for the frame that shows it.
|
|
45
|
+
requestRender = () => {},
|
|
43
46
|
}) {
|
|
44
47
|
// Reassignable: a projection swap (perspective <-> ortho) hands the cutaway
|
|
45
48
|
// a new camera after construction, and the gizmo must follow it too.
|
|
@@ -191,7 +194,7 @@ export function createCutaway({
|
|
|
191
194
|
gizmo.setActiveAppearance(true);
|
|
192
195
|
const scheduled = schedule(() => {
|
|
193
196
|
cancelIdle = null;
|
|
194
|
-
if (enabled && !disposed) gizmo.setActiveAppearance(false);
|
|
197
|
+
if (enabled && !disposed) { gizmo.setActiveAppearance(false); requestRender(); }
|
|
195
198
|
}, IDLE_DELAY_MS);
|
|
196
199
|
cancelIdle = typeof scheduled === "function"
|
|
197
200
|
? scheduled
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// letting the author wonder why nothing plays.
|
|
13
13
|
import { err, note } from "./finding.js";
|
|
14
14
|
import { EASINGS } from "../animation.js";
|
|
15
|
-
import { CANONICAL_VIEWS } from "../view-angles.js";
|
|
15
|
+
import { CANONICAL_VIEWS, CAMERA_CUE_VIEWS } from "../view-angles.js";
|
|
16
16
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
17
17
|
import { resolveDerived } from "../derive.js";
|
|
18
18
|
import { desugar } from "../panel/legacy.js";
|
|
@@ -372,7 +372,9 @@ export const ANIMATION_RULES = [
|
|
|
372
372
|
id: "animation-camera-invalid",
|
|
373
373
|
run: ({ part }) => {
|
|
374
374
|
const out = [];
|
|
375
|
-
const badName = (v) => typeof v !== "string" || !
|
|
375
|
+
const badName = (v) => typeof v !== "string" || !CAMERA_CUE_VIEWS.includes(v);
|
|
376
|
+
// The seven canonical names plus the view cube's corners and edges.
|
|
377
|
+
const angles = `${CANONICAL_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left or top-back-right`;
|
|
376
378
|
for (const { name, a, base } of animEntries(part)) {
|
|
377
379
|
const stepCameras = Array.isArray(a.steps)
|
|
378
380
|
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
@@ -386,8 +388,8 @@ export const ANIMATION_RULES = [
|
|
|
386
388
|
for (const [cam, i] of stepCameras) {
|
|
387
389
|
if (badName(cam)) {
|
|
388
390
|
out.push(err("animation-camera-invalid",
|
|
389
|
-
`animation "${name}" step ${i} camera "${cam}" is not a
|
|
390
|
-
`Camera cues use
|
|
391
|
+
`animation "${name}" step ${i} camera "${cam}" is not a camera angle`,
|
|
392
|
+
`Camera cues use ${angles}.`,
|
|
391
393
|
`${base}.steps[${i}].camera`));
|
|
392
394
|
}
|
|
393
395
|
}
|
|
@@ -397,8 +399,8 @@ export const ANIMATION_RULES = [
|
|
|
397
399
|
if (typeof a.camera === "string") {
|
|
398
400
|
if (badName(a.camera)) {
|
|
399
401
|
out.push(err("animation-camera-invalid",
|
|
400
|
-
`animation "${name}" camera "${a.camera}" is not a
|
|
401
|
-
`Camera cues use
|
|
402
|
+
`animation "${name}" camera "${a.camera}" is not a camera angle`,
|
|
403
|
+
`Camera cues use ${angles}.`,
|
|
402
404
|
`${base}.camera`));
|
|
403
405
|
}
|
|
404
406
|
} else if (Array.isArray(a.camera)) {
|
|
@@ -409,7 +411,7 @@ export const ANIMATION_RULES = [
|
|
|
409
411
|
if (!wellFormed || !sorted) {
|
|
410
412
|
out.push(err("animation-camera-invalid",
|
|
411
413
|
`animation "${name}" has an invalid camera cue list`,
|
|
412
|
-
`Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${
|
|
414
|
+
`Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${angles}.`,
|
|
413
415
|
`${base}.camera`));
|
|
414
416
|
}
|
|
415
417
|
} else {
|
|
@@ -19,7 +19,7 @@ export const DEFAULT_ENVIRONMENT_ID = "studio";
|
|
|
19
19
|
|
|
20
20
|
export const ENVIRONMENTS = {
|
|
21
21
|
studio: {
|
|
22
|
-
id: "studio", label: "Studio", exposure: 0.
|
|
22
|
+
id: "studio", label: "Studio", exposure: 0.64,
|
|
23
23
|
hdr: "env-studio.jpg",
|
|
24
24
|
// Paper 001's fibre relief (normal + roughness) does the work: its colour
|
|
25
25
|
// map is nearly flat, so without them the sweep read as soft blotches up
|
|
@@ -35,7 +35,7 @@ export const ENVIRONMENTS = {
|
|
|
35
35
|
blurriness: 0.3,
|
|
36
36
|
},
|
|
37
37
|
workshop: {
|
|
38
|
-
id: "workshop", label: "Workshop", exposure: 0.
|
|
38
|
+
id: "workshop", label: "Workshop", exposure: 0.7,
|
|
39
39
|
hdr: "env-workshop.jpg",
|
|
40
40
|
// Unfinished maple: Poly Haven "Oak Veneer 01" lightened and desaturated
|
|
41
41
|
// at bake time, with its normal map at a gentle strength and NO roughness
|
|
@@ -47,7 +47,7 @@ export const ENVIRONMENTS = {
|
|
|
47
47
|
},
|
|
48
48
|
},
|
|
49
49
|
"print-bed": {
|
|
50
|
-
id: "print-bed", label: "Print bed", exposure: 0.
|
|
50
|
+
id: "print-bed", label: "Print bed", exposure: 0.34,
|
|
51
51
|
// The studio photo re-baked with --contrast: a darker, harsher room, like
|
|
52
52
|
// the inside of a printer's enclosure.
|
|
53
53
|
hdr: "env-print-bed.jpg",
|
|
@@ -65,7 +65,7 @@ export const ENVIRONMENTS = {
|
|
|
65
65
|
},
|
|
66
66
|
},
|
|
67
67
|
outdoor: {
|
|
68
|
-
id: "outdoor", label: "Outdoor", exposure: 0.
|
|
68
|
+
id: "outdoor", label: "Outdoor", exposure: 0.58,
|
|
69
69
|
hdr: "env-outdoor.jpg",
|
|
70
70
|
// Tinted to a warm mid-grey: the overcast sky lights an untinted concrete
|
|
71
71
|
// map near-white and blue. Exposed-aggregate concrete with its own normal
|
|
@@ -99,6 +99,10 @@ export function defaultPaintLabel({ text, palette }) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {}) {
|
|
102
|
+
// Dims are scene objects edited in place, not through a viewer call, so they
|
|
103
|
+
// ask for the frame themselves (the viewer draws on demand). tick() needs no
|
|
104
|
+
// request: it only follows the camera, and a camera move is already a frame.
|
|
105
|
+
const redraw = () => viewer.requestRender?.();
|
|
102
106
|
const group = new THREE.Group();
|
|
103
107
|
group.name = "pf-dims";
|
|
104
108
|
let attached = false;
|
|
@@ -238,6 +242,7 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
|
|
|
238
242
|
|
|
239
243
|
function update(drawings) {
|
|
240
244
|
if (!ensureAttached()) return;
|
|
245
|
+
redraw();
|
|
241
246
|
disposeChildren();
|
|
242
247
|
for (const d of drawings) {
|
|
243
248
|
const key = matFor(d.tier);
|
|
@@ -423,6 +428,7 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
|
|
|
423
428
|
function setTheme(mode) {
|
|
424
429
|
if (!DIM_THEME[mode] || mode === theme) return;
|
|
425
430
|
theme = mode;
|
|
431
|
+
redraw();
|
|
426
432
|
lineMats.static.color.set(DIM_THEME[theme].static);
|
|
427
433
|
lineMats.strong.color.set(DIM_THEME[theme].strong);
|
|
428
434
|
fillMats.static.color.set(DIM_THEME[theme].static);
|
|
@@ -434,9 +440,10 @@ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {})
|
|
|
434
440
|
}
|
|
435
441
|
}
|
|
436
442
|
|
|
437
|
-
function clear() { disposeChildren(); }
|
|
443
|
+
function clear() { disposeChildren(); redraw(); }
|
|
438
444
|
|
|
439
445
|
function dispose() {
|
|
446
|
+
redraw();
|
|
440
447
|
disposeChildren();
|
|
441
448
|
unregisterCapture();
|
|
442
449
|
if (attached) group.parent?.remove(group);
|
|
@@ -61,8 +61,13 @@ export function createFeatureHighlight(viewer) {
|
|
|
61
61
|
overlay.visible = true;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// The overlay is edited in place, not through a viewer call, so it asks for
|
|
65
|
+
// the frame itself (the viewer draws on demand).
|
|
66
|
+
const redraw = () => viewer.requestRender?.();
|
|
67
|
+
|
|
64
68
|
return {
|
|
65
69
|
show(hit) {
|
|
70
|
+
redraw();
|
|
66
71
|
if (!hit.feature) { mount(hit.mesh.geometry, hit.mesh); return; }
|
|
67
72
|
const cached = subsets.get(hit.subPart);
|
|
68
73
|
let byId = cached?.geo === hit.mesh.geometry ? cached.byId : null;
|
|
@@ -75,7 +80,7 @@ export function createFeatureHighlight(viewer) {
|
|
|
75
80
|
if (!g) { g = featureSubset(hit.mesh.geometry, hit.feature.id); byId.set(hit.feature.id, g); }
|
|
76
81
|
mount(g, hit.mesh);
|
|
77
82
|
},
|
|
78
|
-
clear() { overlay.visible = false; },
|
|
83
|
+
clear() { overlay.visible = false; redraw(); },
|
|
79
84
|
dispose() {
|
|
80
85
|
// Every step isolated: a throw disposing one cached subset (or
|
|
81
86
|
// unregistering from cutaway) must not skip the rest — same discipline
|
|
@@ -81,6 +81,13 @@ function buildOrientations() {
|
|
|
81
81
|
export const ORIENTATIONS = buildOrientations();
|
|
82
82
|
export const ORIENTATION_IDS = Object.keys(ORIENTATIONS);
|
|
83
83
|
|
|
84
|
+
// Every angle an animation camera cue may name: the seven canonical names first,
|
|
85
|
+
// then the view cube's other orientations (so "top-front-left" is iso seen from
|
|
86
|
+
// the other front corner). cameraPoseForView resolves all of them; this list is
|
|
87
|
+
// what lint holds cues to. Kept apart from CANONICAL_VIEWS for the reason the
|
|
88
|
+
// comment on ORIENTATIONS gives.
|
|
89
|
+
export const CAMERA_CUE_VIEWS = [...new Set([...CANONICAL_VIEWS, ...ORIENTATION_IDS])];
|
|
90
|
+
|
|
84
91
|
const norm = (v) => {
|
|
85
92
|
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
86
93
|
return [v[0] / l, v[1] / l, v[2] / l];
|
package/src/framework/viewer.js
CHANGED
|
@@ -232,6 +232,31 @@ export function createViewer(container, part) {
|
|
|
232
232
|
const controls = new OrbitControls(activeCamera, renderer.domElement);
|
|
233
233
|
controls.enableDamping = true;
|
|
234
234
|
|
|
235
|
+
// --- render on demand -------------------------------------------------------
|
|
236
|
+
// The loop ticks every animation frame (controls, tweens, frame listeners —
|
|
237
|
+
// playback, measure, the view cube all run there), but only DRAWS when
|
|
238
|
+
// something asked for it: a still view costs no GPU work. A frame is asked
|
|
239
|
+
// for by: any call through the returned viewer API (wrapped at the bottom of
|
|
240
|
+
// this function — a regen, a pose, a mode switch, a capture), the controls
|
|
241
|
+
// moving the camera (their damping included), a camera tween, input on the
|
|
242
|
+
// stage, and the internal async landings (a texture, a contact shadow, a
|
|
243
|
+
// resize, a marker timing out). Modules that edit the scene graph directly
|
|
244
|
+
// rather than through the API (measure's dims, the hover highlight, the
|
|
245
|
+
// animation driver) call viewer.requestRender() themselves.
|
|
246
|
+
//
|
|
247
|
+
// `graceMs` keeps drawing for a while after the request: input and API calls
|
|
248
|
+
// have follow-ups that land a frame or two later (hover.js picks in its own
|
|
249
|
+
// requestAnimationFrame; a regen's measure dims rebuild on the next tick).
|
|
250
|
+
const INPUT_GRACE_MS = 400;
|
|
251
|
+
const API_GRACE_MS = 150;
|
|
252
|
+
let renderRequested = true; // the first frame
|
|
253
|
+
let renderUntil = 0;
|
|
254
|
+
function requestRender(graceMs = 0) {
|
|
255
|
+
renderRequested = true;
|
|
256
|
+
if (graceMs > 0) renderUntil = Math.max(renderUntil, performance.now() + graceMs);
|
|
257
|
+
}
|
|
258
|
+
controls.addEventListener("change", () => requestRender());
|
|
259
|
+
|
|
235
260
|
// --- lights + grid --------------------------------------------------------
|
|
236
261
|
const liveLights = addViewerLights(scene);
|
|
237
262
|
// 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
|
|
@@ -523,6 +548,7 @@ export function createViewer(container, part) {
|
|
|
523
548
|
domElement: renderer.domElement,
|
|
524
549
|
getBounds: getVisibleWorldBounds,
|
|
525
550
|
edgeColor: THEME.dark.line,
|
|
551
|
+
requestRender: () => requestRender(),
|
|
526
552
|
});
|
|
527
553
|
for (const name of names) {
|
|
528
554
|
cutaway.setSubpart(name, subMesh[name], subLines[name]);
|
|
@@ -589,7 +615,7 @@ export function createViewer(container, part) {
|
|
|
589
615
|
const settled = new Promise((resolve) => { settle = resolve; });
|
|
590
616
|
pendingTextures.add(settled);
|
|
591
617
|
settled.then(() => pendingTextures.delete(settled));
|
|
592
|
-
t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
|
|
618
|
+
t = textureLoader.load(assetUrl(file), () => { settle(); requestRender(); }, undefined, () => settle());
|
|
593
619
|
t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
|
|
594
620
|
textureCache.set(file, t);
|
|
595
621
|
}
|
|
@@ -760,6 +786,7 @@ export function createViewer(container, part) {
|
|
|
760
786
|
try {
|
|
761
787
|
const plane = cutaway.getPlane?.(_shadowClipPlane) ?? null;
|
|
762
788
|
realisticRig.shadow.render(scene, castersNow(), { ...opts, clippingPlanes: plane ? [plane] : null });
|
|
789
|
+
requestRender();
|
|
763
790
|
} catch (e) {
|
|
764
791
|
console.warn("partforge: contact shadow failed", e);
|
|
765
792
|
}
|
|
@@ -796,6 +823,7 @@ export function createViewer(container, part) {
|
|
|
796
823
|
// contact shadow: low-res while the plane is dragged, full once it settles —
|
|
797
824
|
// the same schedule as a moving sub-part.
|
|
798
825
|
cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
|
|
826
|
+
cutaway.onChange?.(() => requestRender());
|
|
799
827
|
|
|
800
828
|
const livePixelRatio = (mode) =>
|
|
801
829
|
Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
|
|
@@ -1725,6 +1753,7 @@ export function createViewer(container, part) {
|
|
|
1725
1753
|
lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
|
|
1726
1754
|
for (const m of fadeLineMats.values()) m.resolution.set(w, h); // clones need it too
|
|
1727
1755
|
cutaway.setViewportSize(w, h, renderer.getPixelRatio());
|
|
1756
|
+
requestRender(); // a resize clears the drawing buffer
|
|
1728
1757
|
}
|
|
1729
1758
|
const ro = new ResizeObserver(resize);
|
|
1730
1759
|
ro.observe(container);
|
|
@@ -2254,11 +2283,12 @@ export function createViewer(container, part) {
|
|
|
2254
2283
|
function renderFrame(time) {
|
|
2255
2284
|
const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
|
|
2256
2285
|
lastFrameTime = time;
|
|
2257
|
-
controls.update();
|
|
2286
|
+
controls.update(); // fires "change" (→ requestRender) while the camera moves, damping included
|
|
2258
2287
|
const tw = camTween.update(dt);
|
|
2259
2288
|
if (tw) {
|
|
2260
2289
|
activeCamera.position.fromArray(tw.position);
|
|
2261
2290
|
controls.target.fromArray(tw.target);
|
|
2291
|
+
requestRender();
|
|
2262
2292
|
}
|
|
2263
2293
|
checkAutoProjection();
|
|
2264
2294
|
// Per-listener guard, because three re-arms requestAnimationFrame only AFTER
|
|
@@ -2269,6 +2299,9 @@ export function createViewer(container, part) {
|
|
|
2269
2299
|
try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
|
|
2270
2300
|
}
|
|
2271
2301
|
updateMovingShadow(); // after the listeners, so it sees this frame's poses
|
|
2302
|
+
// Nothing asked for a frame: the canvas keeps showing the last one.
|
|
2303
|
+
if (!renderRequested && performance.now() >= renderUntil) return;
|
|
2304
|
+
renderRequested = false;
|
|
2272
2305
|
// After the frame listeners, before anything reads the camera to draw with:
|
|
2273
2306
|
// a playback frame may have moved sub-parts or the camera itself, and both
|
|
2274
2307
|
// change where the planes belong.
|
|
@@ -2336,6 +2369,20 @@ export function createViewer(container, part) {
|
|
|
2336
2369
|
// recoverable (three's own listener re-uploads on restore); the subscribers
|
|
2337
2370
|
// let an embedder surface it instead of showing a dead rectangle.
|
|
2338
2371
|
const contextLostListeners = new Set();
|
|
2372
|
+
// Input on the stage: hover highlights, measure dims, cutaway gizmo hover and
|
|
2373
|
+
// drag all react to it, some a frame or two later — draw through the grace.
|
|
2374
|
+
const INPUT_EVENTS = ["pointermove", "pointerdown", "pointerup", "pointerleave", "pointercancel", "wheel"];
|
|
2375
|
+
const onStageInput = () => requestRender(INPUT_GRACE_MS);
|
|
2376
|
+
for (const type of INPUT_EVENTS) container.addEventListener(type, onStageInput, { capture: true, passive: true });
|
|
2377
|
+
const onKeyInput = () => requestRender(INPUT_GRACE_MS);
|
|
2378
|
+
window.addEventListener("keydown", onKeyInput, { capture: true, passive: true });
|
|
2379
|
+
window.addEventListener("keyup", onKeyInput, { capture: true, passive: true });
|
|
2380
|
+
// A restored context has re-uploaded everything but drawn nothing, and a
|
|
2381
|
+
// returning tab may have had its drawing buffer discarded.
|
|
2382
|
+
const onRedrawNeeded = () => requestRender();
|
|
2383
|
+
renderer.domElement.addEventListener("webglcontextrestored", onRedrawNeeded);
|
|
2384
|
+
document.addEventListener("visibilitychange", onRedrawNeeded);
|
|
2385
|
+
|
|
2339
2386
|
const onContextLostEvent = (event) => {
|
|
2340
2387
|
event.preventDefault();
|
|
2341
2388
|
for (const listener of [...contextLostListeners]) listener();
|
|
@@ -2453,6 +2500,7 @@ export function createViewer(container, part) {
|
|
|
2453
2500
|
}
|
|
2454
2501
|
|
|
2455
2502
|
function dropFlashDot(dot) {
|
|
2503
|
+
requestRender();
|
|
2456
2504
|
scene.remove(dot);
|
|
2457
2505
|
dot.material.dispose(); // the geometry is shared and freed in dispose()
|
|
2458
2506
|
flashDots.delete(dot);
|
|
@@ -2532,6 +2580,11 @@ export function createViewer(container, part) {
|
|
|
2532
2580
|
// context left to lose, and a surviving listener would keep the embedder's
|
|
2533
2581
|
// closure (and whatever it captured) alive.
|
|
2534
2582
|
renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
|
|
2583
|
+
for (const type of INPUT_EVENTS) container.removeEventListener(type, onStageInput, { capture: true });
|
|
2584
|
+
window.removeEventListener("keydown", onKeyInput, { capture: true });
|
|
2585
|
+
window.removeEventListener("keyup", onKeyInput, { capture: true });
|
|
2586
|
+
renderer.domElement.removeEventListener("webglcontextrestored", onRedrawNeeded);
|
|
2587
|
+
document.removeEventListener("visibilitychange", onRedrawNeeded);
|
|
2535
2588
|
contextLostListeners.clear();
|
|
2536
2589
|
controls.removeEventListener("start", onControlsStart);
|
|
2537
2590
|
cameraStartListeners.clear();
|
|
@@ -2586,7 +2639,8 @@ export function createViewer(container, part) {
|
|
|
2586
2639
|
renderer.domElement.remove();
|
|
2587
2640
|
}
|
|
2588
2641
|
|
|
2589
|
-
return {
|
|
2642
|
+
return withRenderRequests({
|
|
2643
|
+
requestRender,
|
|
2590
2644
|
showAssembly,
|
|
2591
2645
|
hideAssembly,
|
|
2592
2646
|
setSubGeometry,
|
|
@@ -2666,5 +2720,25 @@ export function createViewer(container, part) {
|
|
|
2666
2720
|
invalidatePrintFrames,
|
|
2667
2721
|
whenRealisticReady,
|
|
2668
2722
|
dispose,
|
|
2669
|
-
};
|
|
2723
|
+
}, () => requestRender(API_GRACE_MS));
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// Every API method that can change what is drawn asks for a frame when called,
|
|
2727
|
+
// and again when a returned promise settles (a mode switch lands async). Pure
|
|
2728
|
+
// reads, subscriptions and hot per-frame queries are left unwrapped — a frame
|
|
2729
|
+
// listener polling getCutawayPlane() must not keep the loop drawing forever.
|
|
2730
|
+
// Getters (viewer.camera) and non-functions pass through untouched.
|
|
2731
|
+
const NO_RENDER_REQUEST = /^(get|is|has|on|__)|^(requestRender|projectPoint|subTriangles|cutawaySupported|cutawayEnabled|whenRealisticReady|dispose)$/;
|
|
2732
|
+
export function withRenderRequests(api, request) {
|
|
2733
|
+
for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(api))) {
|
|
2734
|
+
if (typeof desc.value !== "function" || NO_RENDER_REQUEST.test(key)) continue;
|
|
2735
|
+
const fn = desc.value;
|
|
2736
|
+
api[key] = function (...args) {
|
|
2737
|
+
request();
|
|
2738
|
+
const out = fn.apply(this, args);
|
|
2739
|
+
if (out && typeof out.then === "function") out.then(request, request);
|
|
2740
|
+
return out;
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
return api;
|
|
2670
2744
|
}
|
package/src/testing/render.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { safeName } from "../framework/safe-name.js";
|
|
4
|
+
import { ORIENTATIONS } from "../framework/view-angles.js";
|
|
4
5
|
import { buildView } from "../framework/oracle/build.js";
|
|
5
6
|
import { bounds } from "../framework/oracle/mesh.js";
|
|
6
7
|
|
|
@@ -23,6 +24,17 @@ export const RENDER_ANGLES = {
|
|
|
23
24
|
};
|
|
24
25
|
export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
|
|
25
26
|
|
|
27
|
+
// The view cube's other orientations (animation camera cues may name them, and
|
|
28
|
+
// a still defaults to its cue's angle), mapped from the viewer's Y-up world
|
|
29
|
+
// back into model space: world (x, y, z) -> model (x, -z, y).
|
|
30
|
+
export function renderAngle(name) {
|
|
31
|
+
if (Object.hasOwn(RENDER_ANGLES, name)) return RENDER_ANGLES[name];
|
|
32
|
+
const o = Object.hasOwn(ORIENTATIONS, name) ? ORIENTATIONS[name] : null;
|
|
33
|
+
if (!o) return null;
|
|
34
|
+
const toModel = ([x, y, z]) => [x, -z, y];
|
|
35
|
+
return { dir: toModel(o.dir), up: toModel(o.up) };
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
27
39
|
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
28
40
|
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
@@ -93,8 +105,8 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
|
|
|
93
105
|
const written = [];
|
|
94
106
|
|
|
95
107
|
for (const angle of views) {
|
|
96
|
-
const a =
|
|
97
|
-
if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")})`);
|
|
108
|
+
const a = renderAngle(angle);
|
|
109
|
+
if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
|
|
98
110
|
// orthographic camera basis: zc toward camera, xc right, yc up
|
|
99
111
|
const zc = norm(a.dir), xc = norm(cross(a.up, zc)), yc = cross(zc, xc);
|
|
100
112
|
// Key direction (toward the light), placed over the viewer's shoulder — up and to
|
package/types/part.d.ts
CHANGED
|
@@ -556,11 +556,22 @@ export type Keyframes = Array<[number, number]>;
|
|
|
556
556
|
export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
|
|
557
557
|
|
|
558
558
|
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* play only; scrubbing never moves the camera.
|
|
559
|
+
* The view cube's other orientations: its edges and corners, named vertical,
|
|
560
|
+
* then depth, then side. `"top-front-right"` is the same camera as `"iso"`.
|
|
562
561
|
*/
|
|
563
|
-
export type
|
|
562
|
+
export type ViewCubeOrientation =
|
|
563
|
+
| "top-front" | "top-back" | "top-left" | "top-right"
|
|
564
|
+
| "bottom-front" | "bottom-back" | "bottom-left" | "bottom-right"
|
|
565
|
+
| "front-left" | "front-right" | "back-left" | "back-right"
|
|
566
|
+
| "top-front-left" | "top-front-right" | "top-back-left" | "top-back-right"
|
|
567
|
+
| "bottom-front-left" | "bottom-front-right" | "bottom-back-left" | "bottom-back-right";
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* A camera cue angle: one of the canonical seven or a view-cube orientation.
|
|
571
|
+
* `partforge lint` rejects anything else (`animation-camera-invalid`). Cues fire
|
|
572
|
+
* during play only; scrubbing never moves the camera.
|
|
573
|
+
*/
|
|
574
|
+
export type CameraCue = CanonicalView | ViewCubeOrientation;
|
|
564
575
|
|
|
565
576
|
/** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
|
|
566
577
|
export interface AnimationStep {
|
|
@@ -586,7 +597,12 @@ export interface AnimationStep {
|
|
|
586
597
|
* never affects params, export, measure, or verify.
|
|
587
598
|
*/
|
|
588
599
|
opacity?: Record<string, Keyframes>;
|
|
589
|
-
/**
|
|
600
|
+
/**
|
|
601
|
+
* Swing the camera to this angle, starting when the step begins and taking
|
|
602
|
+
* the step's whole duration — so a slow orbit can be timed to the motion it
|
|
603
|
+
* frames. (Starting playback at this step instead makes a short move there
|
|
604
|
+
* before anything else runs.)
|
|
605
|
+
*/
|
|
590
606
|
camera?: CameraCue;
|
|
591
607
|
}
|
|
592
608
|
|
|
@@ -604,7 +620,8 @@ export interface AnimationSpecCommon {
|
|
|
604
620
|
loop?: boolean;
|
|
605
621
|
/**
|
|
606
622
|
* One mechanism per animation: an angle (an intro cue at t=0), a
|
|
607
|
-
* `[[t, angle], …]` cue list, or per-step `camera` names.
|
|
623
|
+
* `[[t, angle], …]` cue list, or per-step `camera` names. A listed cue's
|
|
624
|
+
* camera move lasts until the next cue (or the end of the animation).
|
|
608
625
|
*/
|
|
609
626
|
camera?: CameraCue | Array<[number, CameraCue]>;
|
|
610
627
|
/**
|