partforge 0.126.0 → 0.128.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/package.json +1 -1
- package/src/framework/animation-controls.js +3 -0
- package/src/framework/cutaway.js +4 -1
- package/src/framework/materials/patterns.js +78 -16
- package/src/framework/measure/dim3-scene.js +8 -1
- package/src/framework/selection/feature-highlight.js +6 -1
- package/src/framework/viewer.js +78 -4
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
|
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
|
|
@@ -85,23 +85,79 @@ vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
|
|
|
85
85
|
}
|
|
86
86
|
`;
|
|
87
87
|
|
|
88
|
+
// Layer lines are 0.2 mm, so at an ordinary viewing distance a layer spans a
|
|
89
|
+
// few pixels or less, and point-sampling the bead profile per pixel aliased
|
|
90
|
+
// into moiré (worst in the specular, through the bead normals). So one layer's
|
|
91
|
+
// profile is baked into a small repeating texture sampled by layer height, and
|
|
92
|
+
// the GPU's mipmaps band-limit it. Channels: R = groove shade, G = bead slope
|
|
93
|
+
// (encoded slope/6 + 0.5), B = slope^2 / 9. A mip level averages G and B
|
|
94
|
+
// separately, so B - G^2 is the slope VARIANCE inside the pixel — the ridges it
|
|
95
|
+
// can no longer show — which widens the specular lobe instead (Toksvig / LEAN
|
|
96
|
+
// mapping), so a far wall keeps its satin sheen rather than turning glossy.
|
|
97
|
+
export const LAYER_PROFILE_WIDTH = 256;
|
|
98
|
+
// Gradient scale on the profile lookup (+0.58 mip). A box-filtered mip chain at
|
|
99
|
+
// 1x still carries the full fundamental at 2 px per layer (its 2-texel level),
|
|
100
|
+
// right at Nyquist, which banded across a whole-part view; 1.5 clears that and
|
|
101
|
+
// keeps the lines crisp close up (2 visibly softened them).
|
|
102
|
+
const LAYER_BLUR = "1.5";
|
|
103
|
+
|
|
104
|
+
// One layer's bead, t running -1 (a seam) through 0 (the crest) to 1 (the next
|
|
105
|
+
// seam): the groove shade darkens the 35% of each half-bead nearest a seam, and
|
|
106
|
+
// the slope is a round bead's (t / sqrt(1 - t^2)), clamped to ±3 where it goes
|
|
107
|
+
// vertical. (The procedural version darkened the CREST instead — its ridge was
|
|
108
|
+
// |t|, not 1 - |t| — half a layer off the valleys its own normals drew.)
|
|
109
|
+
export function layerProfileData(width = LAYER_PROFILE_WIDTH) {
|
|
110
|
+
const data = new Uint8Array(width * 4);
|
|
111
|
+
for (let i = 0; i < width; i++) {
|
|
112
|
+
const t = ((i + 0.5) / width) * 2 - 1;
|
|
113
|
+
const g = Math.min(1, (1 - Math.abs(t)) / 0.35);
|
|
114
|
+
const groove = g * g * (3 - 2 * g);
|
|
115
|
+
const slope = Math.min(3, Math.max(-3, t / Math.sqrt(Math.max(1 - t * t, 0.04))));
|
|
116
|
+
data[i * 4] = Math.round(groove * 255);
|
|
117
|
+
data[i * 4 + 1] = Math.round((slope / 6 + 0.5) * 255);
|
|
118
|
+
data[i * 4 + 2] = Math.round((slope * slope / 9) * 255);
|
|
119
|
+
data[i * 4 + 3] = 255;
|
|
120
|
+
}
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// One texture shared by every layer-lines material, built on first use.
|
|
125
|
+
let layerProfileTex = null;
|
|
126
|
+
function layerProfileTexture() {
|
|
127
|
+
if (layerProfileTex) return layerProfileTex;
|
|
128
|
+
const t = new THREE.DataTexture(layerProfileData(), LAYER_PROFILE_WIDTH, 1, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
129
|
+
t.wrapS = THREE.RepeatWrapping;
|
|
130
|
+
t.wrapT = THREE.ClampToEdgeWrapping;
|
|
131
|
+
t.magFilter = THREE.LinearFilter;
|
|
132
|
+
t.minFilter = THREE.LinearMipmapLinearFilter;
|
|
133
|
+
t.generateMipmaps = true;
|
|
134
|
+
t.colorSpace = THREE.NoColorSpace;
|
|
135
|
+
t.needsUpdate = true;
|
|
136
|
+
return (layerProfileTex = t);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FRAG_DECL_LAYERS = "uniform sampler2D pfLayerMap;\n";
|
|
140
|
+
|
|
88
141
|
// Per-kind fragment code, injected after <roughnessmap_fragment> so it can
|
|
89
142
|
// modulate both diffuseColor (already written by <map_fragment>) and roughnessFactor.
|
|
90
143
|
const FRAG_BODY = {
|
|
144
|
+
// Declared at main() scope, not in a block: FRAG_NORMAL reads the same
|
|
145
|
+
// profile sample (pfLayer) rather than looking it up again.
|
|
91
146
|
"layer-lines": `
|
|
147
|
+
float pfH = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale; // in layers
|
|
148
|
+
// fract() keeps the texel coordinate small (hundreds of layers x 256 texels
|
|
149
|
+
// loses sub-texel precision in the sampler); the explicit gradients are the
|
|
150
|
+
// unwrapped height's, so the wrap at each seam doesn't drop to the 1x1 mip.
|
|
151
|
+
vec4 pfLayer = textureGrad(pfLayerMap, vec2(fract(pfH), 0.5),
|
|
152
|
+
vec2(dFdx(pfH) * ${LAYER_BLUR}, 0.0), vec2(dFdy(pfH) * ${LAYER_BLUR}, 0.0));
|
|
92
153
|
{
|
|
93
|
-
float
|
|
94
|
-
float ridge = abs(fract(h) - 0.5) * 2.0; // 0 at a layer seam, 1 mid-layer
|
|
95
|
-
float groove = smoothstep(0.0, 0.35, ridge);
|
|
96
|
-
// Anti-alias: fwidth(h) is layers per pixel. Where a layer spans fewer
|
|
97
|
-
// than ~6 px the band fades to its mean over a period (0.825: the groove
|
|
98
|
-
// ramps over 35% of it, the rest is flat), and past ~2.5 px it is gone —
|
|
99
|
-
// point-sampling finer layers than that is what aliased into moiré.
|
|
100
|
-
groove = mix(0.825, groove, 1.0 - smoothstep(0.16, 0.4, fwidth(h)));
|
|
154
|
+
float groove = pfLayer.r;
|
|
101
155
|
diffuseColor.rgb *= mix(0.9, 1.0, groove); // the normal map (FRAG_NORMAL) carries most of the relief
|
|
102
156
|
roughnessFactor = clamp(roughnessFactor + (1.0 - groove) * 0.18, 0.0, 1.0);
|
|
103
157
|
// Filament mottling: a fine grain (~0.17 mm) over slow blotches (~2 mm),
|
|
104
158
|
// fixed to the part, so a print doesn't read as flat injection-moulded plastic.
|
|
159
|
+
// Hash noise, not a noise volume: a mipmapped 64^3 texture measured ~10%
|
|
160
|
+
// slower per frame on an M1 Max (a filtered 3D fetch reads 16 texels).
|
|
105
161
|
float mottle = pfNoise(vPfObjPos * 6.0) * 0.6 + pfNoise(vPfObjPos * 0.51) * 0.4;
|
|
106
162
|
diffuseColor.rgb *= mix(0.893, 1.047, mottle);
|
|
107
163
|
roughnessFactor = clamp(roughnessFactor + (mottle - 0.5) * 0.084, 0.0, 1.0);
|
|
@@ -149,15 +205,14 @@ const FRAG_BODY = {
|
|
|
149
205
|
// layer is a round bead, so across one layer the surface normal swings from
|
|
150
206
|
// facing down (bottom of the bead) through straight out to facing up, along
|
|
151
207
|
// the print direction projected onto the surface. Faces parallel to the layers
|
|
152
|
-
// (tops and bottoms) get no ridges, as on a real print.
|
|
153
|
-
// colour bands,
|
|
208
|
+
// (tops and bottoms) get no ridges, as on a real print. The slope comes from the
|
|
209
|
+
// same mip-filtered profile sample as the colour bands, and the slope variance
|
|
210
|
+
// the filter averaged away goes into roughness: alpha^2 += var(tilt).
|
|
154
211
|
const FRAG_NORMAL = {
|
|
155
212
|
"layer-lines": `
|
|
156
213
|
{
|
|
157
|
-
float
|
|
158
|
-
float
|
|
159
|
-
float t = fract(h) * 2.0 - 1.0; // -1 at a seam, 0 bead crest, 1 next seam
|
|
160
|
-
float slope = clamp(t / sqrt(max(1.0 - t * t, 0.04)), -3.0, 3.0);
|
|
214
|
+
float slope = pfLayer.g * 6.0 - 3.0;
|
|
215
|
+
float slopeVar = max(pfLayer.b * 9.0 - slope * slope, 0.0);
|
|
161
216
|
vec3 up = normalize(vPfPrintUpView);
|
|
162
217
|
// NOT normalised: its length is how steeply the surface cuts across the
|
|
163
218
|
// layers (1 on a vertical wall, 0 on a face parallel to them), so ridges fade
|
|
@@ -165,7 +220,10 @@ const FRAG_NORMAL = {
|
|
|
165
220
|
// full-strength tilt in a direction set by rounding noise, flipping sign
|
|
166
221
|
// wherever the face sat on a layer boundary — a visible patch.
|
|
167
222
|
vec3 along = up - normal * dot(normal, up);
|
|
168
|
-
normal = normalize(normal + along * slope * 0.35
|
|
223
|
+
normal = normalize(normal + along * slope * 0.35);
|
|
224
|
+
// tilt = slope * 0.35 * |along|, so var(tilt) = slopeVar * 0.35^2 * |along|^2
|
|
225
|
+
float a = roughnessFactor * roughnessFactor;
|
|
226
|
+
roughnessFactor = min(sqrt(sqrt(a * a + slopeVar * 0.1225 * dot(along, along))), 1.0);
|
|
169
227
|
}`,
|
|
170
228
|
// Wood replaces the view-space normal with its triplanar normal map, built in
|
|
171
229
|
// object space and carried to view space through the normal matrix. A
|
|
@@ -216,7 +274,11 @@ export function setGrainAxis(material, axis) {
|
|
|
216
274
|
export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1, grain = "u" } = {}) {
|
|
217
275
|
if (!kind || !FRAG_BODY[kind]) return material;
|
|
218
276
|
const wood = kind === "wood";
|
|
277
|
+
const layers = kind === "layer-lines";
|
|
219
278
|
const uniforms = {
|
|
279
|
+
...(layers ? {
|
|
280
|
+
pfLayerMap: { value: layerProfileTexture() },
|
|
281
|
+
} : {}),
|
|
220
282
|
pfPatternScale: { value: scale },
|
|
221
283
|
pfPrintFrame: { value: new THREE.Matrix4().fromArray(printFrame ?? new THREE.Matrix4().toArray()) },
|
|
222
284
|
pfPatternMap: { value: texture ?? null },
|
|
@@ -236,7 +298,7 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
|
|
|
236
298
|
.replace("#include <common>", `#include <common>\n${VERT_DECL}${wood ? VERT_DECL_NM : ""}`)
|
|
237
299
|
.replace("#include <begin_vertex>", `#include <begin_vertex>\n${VERT_BODY}${wood ? VERT_BODY_NM : ""}`);
|
|
238
300
|
shader.fragmentShader = shader.fragmentShader
|
|
239
|
-
.replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}`)
|
|
301
|
+
.replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}${layers ? FRAG_DECL_LAYERS : ""}`)
|
|
240
302
|
.replace("#include <roughnessmap_fragment>", `#include <roughnessmap_fragment>\n${FRAG_BODY[kind]}`)
|
|
241
303
|
.replace("#include <normal_fragment_maps>", `#include <normal_fragment_maps>\n${FRAG_NORMAL[kind] ?? ""}`);
|
|
242
304
|
};
|
|
@@ -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
|
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
|
}
|