partforge 0.170.0 → 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 CHANGED
@@ -199,7 +199,7 @@ 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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.170.0",
3
+ "version": "0.171.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 — 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.
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.
@@ -122,7 +124,7 @@ import { observeViewbarClear } from "./viewbar-clear.js";
122
124
  import { createTooltipPresenter } from "./tooltip.js";
123
125
  import { isFaceAlignedState } from "./projection.js";
124
126
  import { ENVIRONMENTS } from "./materials/environments.js";
125
- import { createPreviewPlayer } from "./preview-player.js";
127
+ import { createPreviewPlayer, animationPoses } from "./preview-player.js";
126
128
  import { attachPreviewPlayButton } from "./preview-play-button.js";
127
129
 
128
130
  // A value the option does not know is a host bug: say so at the call rather
@@ -292,6 +294,8 @@ export function mountPreview(container, options = {}) {
292
294
  transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
293
295
  // A turning model is framed swept a full turn, so no angle of it clips.
294
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,
295
299
  edgeWidth: opts.edgeWidth, edgeOpacity: opts.edgeOpacity,
296
300
  });
297
301
  cleanups.push(() => viewer.dispose());
@@ -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 ?? [];
@@ -179,9 +179,14 @@ export function captureCurrentFromScene(
179
179
  // `edgeWidth` (CSS px, default 1) and `edgeOpacity` (0 < o <= 1, default 1)
180
180
  // style the CAD feature edge lines; a small preview reads them as heavy at the
181
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.
182
187
  export function createViewer(container, part, {
183
188
  transparent = false, contactShadow = false, zoom = true, pan = true, frameFill = null, frameSwept = false,
184
- edgeWidth = 1, edgeOpacity = 1,
189
+ edgeWidth = 1, edgeOpacity = 1, framePoses = null,
185
190
  } = {}) {
186
191
  const names = Object.keys(part.parts);
187
192
  // Membership for host-supplied names. subMesh, cadMats and part.parts are
@@ -2056,11 +2061,54 @@ export function createViewer(container, part, {
2056
2061
  }
2057
2062
  if (n > 0) center.set(sx / n, sy / n, center.z);
2058
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();
2059
2095
  // Every posed vertex of `names` about `center`, stood up by the pivot, as
2060
- // 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.
2061
2098
  function posedVertices(names, center) {
2062
2099
  let n = 0;
2063
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;
2064
2112
  const px = new Float64Array(n), py = new Float64Array(n), pz = new Float64Array(n);
2065
2113
  let k = 0;
2066
2114
  for (const name of names) {
@@ -2072,6 +2120,7 @@ export function createViewer(container, part, {
2072
2120
  px[k] = _sweepV.x; py[k] = _sweepV.y; pz[k] = _sweepV.z;
2073
2121
  }
2074
2122
  }
2123
+ for (const p of extra) { px[k] = p.x; py[k] = p.y; pz[k] = p.z; k++; }
2075
2124
  return { px, py, pz, n };
2076
2125
  }
2077
2126
 
package/types/index.d.ts CHANGED
@@ -846,8 +846,9 @@ export interface MountPreviewOptions {
846
846
  * clips and a round part still fills, centred on its horizontal vertex centroid (a
847
847
  * lopsided part turns about its hub). Without a turntable (0.169.0+) the posed mesh
848
848
  * vertices are fitted and their on-screen outline is centred (the target moves in the
849
- * screen plane), spanning `fill`; a baked animation that moves parts outside its first
850
- * pose's outline may leave the frame. Unset, the
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
851
852
  * fixed framing distance. Since 0.169.0 a turntable mount is framed swept under reduced
852
853
  * motion too, so every visitor sees the same framing.
853
854
  */