partforge 0.46.0 → 0.46.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.46.0",
3
+ "version": "0.46.3",
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",
@@ -22,6 +22,24 @@ function btn(className, text, label) {
22
22
  return b;
23
23
  }
24
24
 
25
+ // A setter for an element's text that MUTATES its existing text node instead of
26
+ // replacing it. `el.textContent = x` always replaces the node, and WebKit will
27
+ // not dispatch a `click` on an element whose text node was replaced between
28
+ // mousedown and mouseup — so a label that can change while the user is pressing
29
+ // it must be written this way or the press is silently swallowed. Isolated in
30
+ // WebKit on a 120ms press: `textContent = <identical string>` loses the click,
31
+ // `node.data = <same>` never does, and neither does an attribute write.
32
+ //
33
+ // This is what makes the transport's glyph safe even when it legitimately flips
34
+ // mid-press (playback ending under the user's finger), which no amount of
35
+ // skipping redundant writes can cover.
36
+ function textSetter(element) {
37
+ const node = element.firstChild?.nodeType === 3
38
+ ? element.firstChild
39
+ : element.appendChild(document.createTextNode(""));
40
+ return (value) => { if (node.data !== value) node.data = value; };
41
+ }
42
+
25
43
  export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues }) {
26
44
  // A malformed animations block must degrade to "no transport bar", never a
27
45
  // crashed mount — lint reports the specifics; the viewer just goes without.
@@ -92,19 +110,59 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
92
110
  }
93
111
  }
94
112
 
113
+ // syncUi() runs on every playback frame. Two rules keep that from eating the
114
+ // user's clicks, and they are independent:
115
+ //
116
+ // 1. Text goes through textSetter (see above), so the button's text node is
117
+ // mutated and never replaced. This is the one that matters, because it
118
+ // holds even when the glyph legitimately changes under a held finger —
119
+ // playback ending, or a step boundary crossing, mid-press.
120
+ // 2. Each element has its own renderer that leaves early when its own value
121
+ // is unchanged, so a playing transport does no DOM work per frame at all.
122
+ // Per ELEMENT, not per write: driving several elements off one shared
123
+ // state key means a step change also redraws the button, which was still
124
+ // enough to lose the click before rule 1 was in place.
125
+ //
126
+ // Without rule 1 this cost the pause click outright: press, release, no click
127
+ // event at all, and only ever while playing — the one moment the button is
128
+ // for. Measured in WebKit, any press held >= 40ms lost it (a real click is
129
+ // ~100ms; a synthetic 0ms one survives, which is why automated clicking never
130
+ // saw it). Reset was never affected: nothing rewrites that button per frame.
131
+ const setPlayGlyph = textSetter(playBtn);
132
+ const setStepText = textSetter(stepLabel);
133
+ let shownActive = null;
134
+ let shownStep = null;
135
+
136
+ function renderPlayButton(active) {
137
+ if (active === shownActive) return;
138
+ shownActive = active;
139
+ setPlayGlyph(active ? "⏸" : "▶");
140
+ const label = active ? "Pause animation" : "Play animation";
141
+ playBtn.setAttribute("aria-label", label);
142
+ playBtn.title = label;
143
+ }
144
+
145
+ function renderStepLabel(stepIndex) {
146
+ if (current.steps.length <= 1 || stepIndex === shownStep) return;
147
+ shownStep = stepIndex;
148
+ const step = current.steps[stepIndex];
149
+ setStepText(`${stepIndex + 1}/${current.steps.length} · ${step.label}`);
150
+ }
151
+
95
152
  function syncUi() {
96
153
  const { status, t, stepIndex } = playback.state();
97
- const active = status === "playing" || status === "intro";
98
- playBtn.textContent = active ? "⏸" : "▶";
99
- playBtn.setAttribute("aria-label", active ? "Pause animation" : "Play animation");
100
- playBtn.title = playBtn.getAttribute("aria-label");
154
+ // The scrubber is the one thing that genuinely moves every frame. Assigning
155
+ // `.value` updates a property rather than replacing a child node, so it
156
+ // costs no clicks and it is not what anyone reaches for mid-playback.
101
157
  scrub.value = String(Math.round(t * 1000));
102
- if (current.steps.length > 1) {
103
- const step = current.steps[stepIndex];
104
- stepLabel.textContent = `${stepIndex + 1}/${current.steps.length} · ${step.label}`;
105
- }
158
+ renderPlayButton(status === "playing" || status === "intro");
159
+ renderStepLabel(stepIndex);
106
160
  }
107
161
 
162
+ // selectAnimation swaps in a fresh playback whose state can coincide with the
163
+ // outgoing one; the chrome still has to re-render for the new animation.
164
+ function invalidateUi() { shownActive = null; shownStep = null; }
165
+
108
166
  // --- driver -----------------------------------------------------------------
109
167
  // A frame that throws — a malformed cue or track that slipped past lint, a
110
168
  // viewer that rejects a view name — must cost that frame, not the render
@@ -162,6 +220,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
162
220
  playback = createPlayback(current);
163
221
  if (animations.length > 1) pick.value = name;
164
222
  syncStructure();
223
+ invalidateUi(); // a fresh playback starts idle at step 0 — the same key the
224
+ // outgoing one may have been showing, but for another animation
165
225
  syncUi();
166
226
  }
167
227
 
@@ -312,7 +312,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
312
312
  return cached(h("revolve", pts._hash, degrees, segs), () => T(pts._cs.revolve(segs, degrees)));
313
313
  return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
314
314
  },
315
- union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
315
+ // A one-solid union is an identity no new WASM / cache entry (avoids double-free):
316
+ // unionRaw's reduce returns the operand's own Manifold untouched, so caching it
317
+ // would pin one WASM object under two entries and eviction would dispose it twice.
318
+ union: (solids) => solids.length === 1
319
+ ? solids[0]
320
+ : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
316
321
  shape2d,
317
322
  beginSubPart: (name) => cache.begin(name),
318
323
  endSubPart: () => cache.end(),
package/types/kernel.d.ts CHANGED
@@ -267,6 +267,8 @@ export interface LoftOptions {
267
267
  ruled?: boolean;
268
268
  /** Capless loop — Manifold only. */
269
269
  closed?: boolean;
270
+ /** Overrides the facet-vs-smooth shading inference. */
271
+ shading?: "smooth" | "faceted";
270
272
  }
271
273
 
272
274
  export interface SweepOptions {