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 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()`). An unknown option key
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.169.1",
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.
@@ -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 ?? [];
@@ -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({ color: THEME.dark.line, linewidth: 1.0 }); // ~10% lighter, 1 px
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 that moves parts outside its first
843
- * 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
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
  */