partforge 0.169.1 → 0.171.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/framework/mount-preview.js +25 -8
- package/src/framework/preview-player.js +24 -0
- package/src/framework/viewer.js +59 -4
- package/types/index.d.ts +10 -2
package/README.md
CHANGED
|
@@ -199,12 +199,14 @@ never under `prefers-reduced-motion`), `fill: 0.8` (frame the model so its
|
|
|
199
199
|
bounding box, as seen from the opening view, spans that fraction of the preview
|
|
200
200
|
along its tighter axis — larger in a small card; with a turntable the model swept
|
|
201
201
|
a full turn is framed, so it never clips as it turns and a round part still fills, and it
|
|
202
|
-
turns about its hub; a model that is not turning has its on-screen outline centred, since 0.169.0), `camera: "default"` (drop the carried camera
|
|
202
|
+
turns about its hub; a model that is not turning has its on-screen outline centred, since 0.169.0, and a playing animation is framed over every pose it passes through, since 0.171.0, so a part that swings out never leaves the frame), `camera: "default"` (drop the carried camera
|
|
203
203
|
and projection and keep the file's own framing; a carried render mode and
|
|
204
204
|
environment still apply) and `renderMode: "cad" | "realistic"` (the opening
|
|
205
205
|
mode, over any carried one; it can change afterwards), and `autostart: false`
|
|
206
206
|
(0.169.0+: the animation or turntable waits at its first frame until
|
|
207
|
-
`handle.motion.start()`)
|
|
207
|
+
`handle.motion.start()`), and `edgeWidth` / `edgeOpacity` (0.170.0+: the CAD
|
|
208
|
+
edge lines' width in CSS pixels, default 1, and opacity, default 1 — a small
|
|
209
|
+
card reads the default lines as heavy). An unknown option key
|
|
208
210
|
throws. A baked animation always plays as authored — it
|
|
209
211
|
loops, or plays once and holds its end pose. Every handle carries `drawn`, which
|
|
210
212
|
resolves after the first frame showing the meshes, so a host can cross-fade from
|
package/package.json
CHANGED
|
@@ -59,11 +59,13 @@
|
|
|
59
59
|
// about the vertical (its farthest vertex from the axis), so the turntable
|
|
60
60
|
// never clips and a round part still fills, centred on its horizontal
|
|
61
61
|
// vertex centroid (a lopsided part turns about its hub). A model that is
|
|
62
|
-
// not turning (a still, or a playing animation
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
62
|
+
// not turning (a still, or a playing animation) is fitted by its posed
|
|
63
|
+
// VERTICES and its silhouette centred on screen (0.169.0): the target moves
|
|
64
|
+
// in the screen plane so the projected outline's bounding box is centred,
|
|
65
|
+
// spanning `fill`. A playing animation is fitted over EVERY pose it passes
|
|
66
|
+
// through (0.171.0; each part's outermost points at each keyframe), so a
|
|
67
|
+
// part that swings out of its first pose's outline stays in frame — the
|
|
68
|
+
// outline of all its poses together spans `fill`.
|
|
67
69
|
// Unset, the viewer's fixed framing distance, as before.
|
|
68
70
|
// The swept framing follows the configuration, not reduced motion (0.169.0):
|
|
69
71
|
// a turntable mount is framed swept even when reduced motion keeps it still.
|
|
@@ -81,6 +83,10 @@
|
|
|
81
83
|
// `drawn` resolves on a frame showing exactly that pose, so a host can
|
|
82
84
|
// cross-fade from a still of it and then say go. A waiting animation's play
|
|
83
85
|
// button reads Play.
|
|
86
|
+
// - `edgeWidth` (0.170.0; CSS px, 0 < w <= 8, default 1) and `edgeOpacity`
|
|
87
|
+
// (0.170.0; 0 < o <= 1, default 1) — the CAD feature edge lines' thickness
|
|
88
|
+
// and strength. A small preview card reads the default 1 px lines as heavy;
|
|
89
|
+
// 0.5 px is one device pixel on a 2x screen. Fixed for the mount.
|
|
84
90
|
// Any other key throws (an option this version does not know is a host bug).
|
|
85
91
|
//
|
|
86
92
|
// `handle.motion` (0.169.0; null before ready and once disposed, present for
|
|
@@ -118,7 +124,7 @@ import { observeViewbarClear } from "./viewbar-clear.js";
|
|
|
118
124
|
import { createTooltipPresenter } from "./tooltip.js";
|
|
119
125
|
import { isFaceAlignedState } from "./projection.js";
|
|
120
126
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
121
|
-
import { createPreviewPlayer } from "./preview-player.js";
|
|
127
|
+
import { createPreviewPlayer, animationPoses } from "./preview-player.js";
|
|
122
128
|
import { attachPreviewPlayButton } from "./preview-play-button.js";
|
|
123
129
|
|
|
124
130
|
// A value the option does not know is a host bug: say so at the call rather
|
|
@@ -136,10 +142,16 @@ function flag(name, value, fallback) {
|
|
|
136
142
|
const KNOWN_OPTIONS = new Set([
|
|
137
143
|
"glb", "elements", "viewerState", "animate",
|
|
138
144
|
"chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
|
|
139
|
-
"autostart",
|
|
145
|
+
"autostart", "edgeWidth", "edgeOpacity",
|
|
140
146
|
]);
|
|
141
147
|
|
|
142
|
-
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode, autostart }) {
|
|
148
|
+
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode, autostart, edgeWidth, edgeOpacity }) {
|
|
149
|
+
if (edgeWidth !== undefined && !(typeof edgeWidth === "number" && edgeWidth > 0 && edgeWidth <= 8)) {
|
|
150
|
+
throw new TypeError("mountPreview: edgeWidth must be a number of CSS pixels above 0 and at most 8");
|
|
151
|
+
}
|
|
152
|
+
if (edgeOpacity !== undefined && !(typeof edgeOpacity === "number" && edgeOpacity > 0 && edgeOpacity <= 1)) {
|
|
153
|
+
throw new TypeError("mountPreview: edgeOpacity must be a number above 0 and at most 1");
|
|
154
|
+
}
|
|
143
155
|
if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
|
|
144
156
|
throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
|
|
145
157
|
}
|
|
@@ -157,6 +169,8 @@ function presentation({ chrome, background, contactShadow, zoom, pan, turntable,
|
|
|
157
169
|
defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
|
|
158
170
|
renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
|
|
159
171
|
autostart: flag("autostart", autostart, true),
|
|
172
|
+
edgeWidth: edgeWidth ?? 1,
|
|
173
|
+
edgeOpacity: edgeOpacity ?? 1,
|
|
160
174
|
};
|
|
161
175
|
}
|
|
162
176
|
|
|
@@ -280,6 +294,9 @@ export function mountPreview(container, options = {}) {
|
|
|
280
294
|
transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
|
|
281
295
|
// A turning model is framed swept a full turn, so no angle of it clips.
|
|
282
296
|
frameFill: opts.fill, frameSwept: sweeps,
|
|
297
|
+
// An animation is framed over every pose it passes through (0.171.0).
|
|
298
|
+
framePoses: plays ? animationPoses(decoded.animation) : null,
|
|
299
|
+
edgeWidth: opts.edgeWidth, edgeOpacity: opts.edgeOpacity,
|
|
283
300
|
});
|
|
284
301
|
cleanups.push(() => viewer.dispose());
|
|
285
302
|
let tooltip = null, toolbar = null, viewStyle = null;
|
|
@@ -95,6 +95,30 @@ export function sampleOpacity(keys, t) {
|
|
|
95
95
|
return keys[keys.length - 1][1];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// The poses an animation passes through, for framing it whole: one
|
|
99
|
+
// { name → pose } map per sampled time, each pose the matrix `apply` would hand
|
|
100
|
+
// setSubPose (keyframed transform with the rest taken out). The times are every
|
|
101
|
+
// keyframe of every track and the midpoint of each gap between them (a rotation
|
|
102
|
+
// can bulge past both its keyframes on the way), thinned evenly to at most
|
|
103
|
+
// `max`, with t = 0 and the end always kept. A baked file samples densely, so
|
|
104
|
+
// that is close to every frame it draws. Untracked sub-parts are left out of
|
|
105
|
+
// every map (they sit at rest).
|
|
106
|
+
export function animationPoses(animation, { max = 128 } = {}) {
|
|
107
|
+
const { tracks = {}, rest = {} } = animation ?? {};
|
|
108
|
+
const names = Object.keys(tracks);
|
|
109
|
+
if (!names.length) return [];
|
|
110
|
+
const restInverse = Object.fromEntries(names.map((name) => [name, invertRigid(rest[name])]));
|
|
111
|
+
const all = new Set([0]);
|
|
112
|
+
for (const name of names) for (const t of tracks[name].times) if (Number.isFinite(t)) all.add(t);
|
|
113
|
+
const keys = [...all].sort((x, y) => x - y);
|
|
114
|
+
let times = keys.flatMap((t, i) => (i === 0 ? [t] : [(keys[i - 1] + t) / 2, t]));
|
|
115
|
+
if (times.length > max) {
|
|
116
|
+
const step = (times.length - 1) / (max - 1);
|
|
117
|
+
times = Array.from({ length: max }, (_, i) => times[Math.round(i * step)]);
|
|
118
|
+
}
|
|
119
|
+
return times.map((seconds) => Object.fromEntries(names.map((name) => [name, mulMat4(sampleTrack(tracks[name], seconds), restInverse[name])])));
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
export function createPreviewPlayer(viewer, animation, { reducedMotion = false } = {}) {
|
|
99
123
|
const { duration, tracks, rest, opacity = {} } = animation;
|
|
100
124
|
const steps = animation.steps ?? [];
|
package/src/framework/viewer.js
CHANGED
|
@@ -176,8 +176,17 @@ export function captureCurrentFromScene(
|
|
|
176
176
|
// vertical instead (a turntable never clips).
|
|
177
177
|
// Unset keeps the fixed r·2.6 + 6 distance. mountPreview's presentation
|
|
178
178
|
// options are the caller.
|
|
179
|
+
// `edgeWidth` (CSS px, default 1) and `edgeOpacity` (0 < o <= 1, default 1)
|
|
180
|
+
// style the CAD feature edge lines; a small preview reads them as heavy at the
|
|
181
|
+
// default. Fades multiply into the opacity rather than replacing it.
|
|
182
|
+
// `framePoses` (with `frameFill`, not `frameSwept`) is a list of
|
|
183
|
+
// { sub-part name → pose matrix } maps — the poses an animation passes through
|
|
184
|
+
// (preview-player's animationPoses) — and the fill framing then fits the model
|
|
185
|
+
// in every one of them as well as as shown, so a part that swings out of its
|
|
186
|
+
// first pose's outline stays in frame.
|
|
179
187
|
export function createViewer(container, part, {
|
|
180
188
|
transparent = false, contactShadow = false, zoom = true, pan = true, frameFill = null, frameSwept = false,
|
|
189
|
+
edgeWidth = 1, edgeOpacity = 1, framePoses = null,
|
|
181
190
|
} = {}) {
|
|
182
191
|
const names = Object.keys(part.parts);
|
|
183
192
|
// Membership for host-supplied names. subMesh, cadMats and part.parts are
|
|
@@ -328,7 +337,9 @@ export function createViewer(container, part, {
|
|
|
328
337
|
|
|
329
338
|
// CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
|
|
330
339
|
const EDGE_ANGLE = 35; // deg — last-ditch threshold for payloads with no kernel edge data
|
|
331
|
-
const lineMaterial = new LineMaterial({
|
|
340
|
+
const lineMaterial = new LineMaterial({ // ~10% lighter, 1 px by default
|
|
341
|
+
color: THEME.dark.line, linewidth: edgeWidth, transparent: edgeOpacity < 1, opacity: edgeOpacity,
|
|
342
|
+
});
|
|
332
343
|
lineMaterial.resolution.set(1, 1); // real size set by resize() below
|
|
333
344
|
const subLines = Object.fromEntries(
|
|
334
345
|
names.map((n) => [n, new LineSegments2(new LineSegmentsGeometry(), lineMaterial)])
|
|
@@ -466,7 +477,7 @@ export function createViewer(container, part, {
|
|
|
466
477
|
mesh.material = fm;
|
|
467
478
|
const flm = fadeLineMatFor(name);
|
|
468
479
|
const so = effectiveDisplayOf(name).opacity ?? 1;
|
|
469
|
-
flm.opacity = so > 0 ? fade / so : 0; // lines fade by the multiplier alone
|
|
480
|
+
flm.opacity = so > 0 ? (fade / so) * edgeOpacity : 0; // lines fade by the multiplier alone
|
|
470
481
|
lines.material = flm;
|
|
471
482
|
}
|
|
472
483
|
mesh.visible = meshVisible;
|
|
@@ -2050,11 +2061,54 @@ export function createViewer(container, part, {
|
|
|
2050
2061
|
}
|
|
2051
2062
|
if (n > 0) center.set(sx / n, sy / n, center.z);
|
|
2052
2063
|
}
|
|
2064
|
+
// A sub-part's outermost vertices along SUPPORT_DIRECTIONS (deduplicated, in
|
|
2065
|
+
// its delivered frame): the points that decide its outline from any angle, to
|
|
2066
|
+
// within a sliver. Framing the extra `framePoses` reads these, not every
|
|
2067
|
+
// vertex, so a long animation of a dense model frames in a few milliseconds.
|
|
2068
|
+
const SUPPORT_DIRECTIONS = (() => {
|
|
2069
|
+
const out = [], n = 64, golden = Math.PI * (3 - Math.sqrt(5));
|
|
2070
|
+
for (let i = 0; i < n; i++) {
|
|
2071
|
+
const y = 1 - (2 * (i + 0.5)) / n, r = Math.sqrt(1 - y * y), a = golden * i;
|
|
2072
|
+
out.push([r * Math.cos(a), y, r * Math.sin(a)]);
|
|
2073
|
+
}
|
|
2074
|
+
return out;
|
|
2075
|
+
})();
|
|
2076
|
+
const supportCache = new WeakMap();
|
|
2077
|
+
function supportPoints(geometry) {
|
|
2078
|
+
let points = supportCache.get(geometry);
|
|
2079
|
+
if (points) return points;
|
|
2080
|
+
const pos = geometry.getAttribute("position");
|
|
2081
|
+
const best = SUPPORT_DIRECTIONS.map(() => -Infinity), at = SUPPORT_DIRECTIONS.map(() => -1);
|
|
2082
|
+
for (let i = 0; i < (pos?.count ?? 0); i++) {
|
|
2083
|
+
_sweepV.fromBufferAttribute(pos, i);
|
|
2084
|
+
for (let k = 0; k < SUPPORT_DIRECTIONS.length; k++) {
|
|
2085
|
+
const [dx, dy, dz] = SUPPORT_DIRECTIONS[k];
|
|
2086
|
+
const d = _sweepV.x * dx + _sweepV.y * dy + _sweepV.z * dz;
|
|
2087
|
+
if (d > best[k]) { best[k] = d; at[k] = i; }
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
points = [...new Set(at.filter((i) => i >= 0))].map((i) => new THREE.Vector3().fromBufferAttribute(pos, i));
|
|
2091
|
+
supportCache.set(geometry, points);
|
|
2092
|
+
return points;
|
|
2093
|
+
}
|
|
2094
|
+
const _framePose = new THREE.Matrix4();
|
|
2053
2095
|
// Every posed vertex of `names` about `center`, stood up by the pivot, as
|
|
2054
|
-
// flat x/y/z arrays (world units, pivot-relative)
|
|
2096
|
+
// flat x/y/z arrays (world units, pivot-relative) — plus, with `framePoses`,
|
|
2097
|
+
// each sub-part's support points in every one of those poses.
|
|
2055
2098
|
function posedVertices(names, center) {
|
|
2056
2099
|
let n = 0;
|
|
2057
2100
|
for (const name of names) n += subCache[name]?.getAttribute("position")?.count ?? 0;
|
|
2101
|
+
const extra = [];
|
|
2102
|
+
for (const poses of framePoses ?? []) {
|
|
2103
|
+
for (const name of names) {
|
|
2104
|
+
if (!subCache[name]) continue;
|
|
2105
|
+
const pose = poses[name] ? _framePose.fromArray(poses[name]) : subMesh[name].matrix;
|
|
2106
|
+
for (const p of supportPoints(subCache[name])) {
|
|
2107
|
+
extra.push(p.clone().applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion));
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
n += extra.length;
|
|
2058
2112
|
const px = new Float64Array(n), py = new Float64Array(n), pz = new Float64Array(n);
|
|
2059
2113
|
let k = 0;
|
|
2060
2114
|
for (const name of names) {
|
|
@@ -2066,6 +2120,7 @@ export function createViewer(container, part, {
|
|
|
2066
2120
|
px[k] = _sweepV.x; py[k] = _sweepV.y; pz[k] = _sweepV.z;
|
|
2067
2121
|
}
|
|
2068
2122
|
}
|
|
2123
|
+
for (const p of extra) { px[k] = p.x; py[k] = p.y; pz[k] = p.z; k++; }
|
|
2069
2124
|
return { px, py, pz, n };
|
|
2070
2125
|
}
|
|
2071
2126
|
|
|
@@ -2560,7 +2615,7 @@ export function createViewer(container, part, {
|
|
|
2560
2615
|
if (lines.material === fadeLineMats.get(n)) lines.material = lineMaterial;
|
|
2561
2616
|
} else {
|
|
2562
2617
|
const f = fadeMatFor(n); f.opacity = fade; mesh.material = f;
|
|
2563
|
-
const fl = fadeLineMatFor(n); fl.opacity = staticOpacity > 0 ? fade / staticOpacity : 0; lines.material = fl;
|
|
2618
|
+
const fl = fadeLineMatFor(n); fl.opacity = staticOpacity > 0 ? (fade / staticOpacity) * edgeOpacity : 0; lines.material = fl;
|
|
2564
2619
|
}
|
|
2565
2620
|
mesh.visible = meshVisible; lines.visible = linesVisible && meshVisible;
|
|
2566
2621
|
},
|
package/types/index.d.ts
CHANGED
|
@@ -830,6 +830,13 @@ export interface MountPreviewOptions {
|
|
|
830
830
|
* angle 0) until `motion.start()`. `drawn` resolves on a frame showing that pose.
|
|
831
831
|
*/
|
|
832
832
|
autostart?: boolean;
|
|
833
|
+
/**
|
|
834
|
+
* 0.170.0+: the CAD feature edge lines' width in CSS pixels, 0 < w <= 8 (default 1).
|
|
835
|
+
* A small preview card reads the default as heavy; 0.5 is one device pixel at 2x.
|
|
836
|
+
*/
|
|
837
|
+
edgeWidth?: number;
|
|
838
|
+
/** 0.170.0+: the CAD feature edge lines' opacity, 0 < o <= 1 (default 1). */
|
|
839
|
+
edgeOpacity?: number;
|
|
833
840
|
/**
|
|
834
841
|
* 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
|
|
835
842
|
* camera, along the opening view direction, so the model's bounding box as projected
|
|
@@ -839,8 +846,9 @@ export interface MountPreviewOptions {
|
|
|
839
846
|
* clips and a round part still fills, centred on its horizontal vertex centroid (a
|
|
840
847
|
* lopsided part turns about its hub). Without a turntable (0.169.0+) the posed mesh
|
|
841
848
|
* vertices are fitted and their on-screen outline is centred (the target moves in the
|
|
842
|
-
* screen plane), spanning `fill`; a baked animation
|
|
843
|
-
* pose
|
|
849
|
+
* screen plane), spanning `fill`; a playing baked animation (0.171.0+) is fitted over
|
|
850
|
+
* every pose it passes through, so a part that swings out of its first pose's outline
|
|
851
|
+
* stays in frame and all its poses together span `fill`. Unset, the
|
|
844
852
|
* fixed framing distance. Since 0.169.0 a turntable mount is framed swept under reduced
|
|
845
853
|
* motion too, so every visitor sees the same framing.
|
|
846
854
|
*/
|