partforge 0.91.0 → 0.92.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.91.0",
3
+ "version": "0.92.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",
@@ -61,7 +61,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
61
61
  // carries the worker's own error text. See the correlated "error" case below.
62
62
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
63
63
 
64
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
64
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
65
  return {
66
66
  ready, dispose, setParams,
67
67
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -123,6 +123,16 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
123
123
  set: () => {},
124
124
  onChange: () => () => {},
125
125
  },
126
+ // The pick marker as a thing with a lifetime, for a host that hangs its own
127
+ // UI off it: hold() keeps the newest marker on screen (earlier held ones
128
+ // stay held), release() clears them all, and onAnchorChange reports where
129
+ // the newest one is on the canvas as the camera moves. Same shape
130
+ // convention as `measure`, `annotate` and `projection`.
131
+ pickMarker: pickMarker ?? {
132
+ hold: () => false,
133
+ release: () => {},
134
+ onAnchorChange: () => () => {},
135
+ },
126
136
  };
127
137
  }
128
138
 
@@ -188,6 +198,15 @@ function createCleanupStack() {
188
198
  // // { sync, hide, detach } — call sync() after you
189
199
  // // toggle a button's disabled state. Detached
190
200
  // // automatically on dispose().
201
+ // runtime.pickMarker.hold(); // keep the marker from the last pick on screen
202
+ // // (earlier held markers stay held), and start
203
+ // // reporting where it is. False when there is
204
+ // // nothing to hold — a marker's flash has a
205
+ // // lifetime, so hold promptly after a pick.
206
+ // runtime.pickMarker.onAnchorChange((a) => …); // {x, y, visible} in CSS px from the
207
+ // // canvas's top-left, as the camera moves; null when
208
+ // // nothing is held. Returns an unsubscribe.
209
+ // runtime.pickMarker.release(); // clear every held marker
191
210
  // runtime.setActive(false); // park the viewer: stop the render loop and release
192
211
  // // both large GPU allocations (the drawing buffer and
193
212
  // // the cached capture target). For a host that hides the
@@ -580,11 +599,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
580
599
  // deliberately not guarded — one is armed by an explicit dev toggle,
581
600
  // the other per agent request.
582
601
  suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
583
- onPick: (selection) => onPick({
602
+ onPick: (selection, anchor) => onPick({
584
603
  selection,
585
604
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
586
605
  prompt: formatSelection(selection, { style: "prompt" }),
587
606
  token: formatSelection(selection, { style: "token" }),
607
+ // Where the marker is on the canvas, in CSS px from its top-left, so
608
+ // a host can put its own UI beside the dot on the first frame rather
609
+ // than a round trip later.
610
+ anchor,
588
611
  }),
589
612
  });
590
613
  cleanup.defer(() => picker.detach());
@@ -1098,6 +1121,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1098
1121
  set: (mode) => viewer.setProjection(mode),
1099
1122
  onChange: (cb) => viewer.onProjectionChange(cb),
1100
1123
  },
1124
+ pickMarker: {
1125
+ hold: () => viewer.holdFlashPoint(),
1126
+ release: () => viewer.releaseFlashPoints(),
1127
+ onAnchorChange: (cb) => viewer.onFlashAnchorChange(cb),
1128
+ },
1101
1129
  });
1102
1130
  } catch (error) {
1103
1131
  try {
@@ -42,3 +42,34 @@ export function flashWorldRadius(
42
42
  const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
43
43
  return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
44
44
  }
45
+
46
+ const _projected = new THREE.Vector3();
47
+
48
+ // Where a world point lands on the canvas, in CSS px from its top-left.
49
+ //
50
+ // `visible` is the answer to "is the marker actually on screen", which is two
51
+ // questions: is it in FRONT of the camera (a point behind a perspective eye
52
+ // projects to a mirrored position with no warning — `ndc.z > 1` is what
53
+ // catches it), and is it inside the canvas. The position is reported either
54
+ // way: an off-screen anchor is information, and the caller decides what to do
55
+ // with it.
56
+ export function projectToScreen(camera, worldPoint, width, height) {
57
+ _projected.copy(worldPoint).project(camera);
58
+ const x = (_projected.x * 0.5 + 0.5) * width;
59
+ // NDC y grows upward, screen y downward — the flip is the whole reason this
60
+ // is a named function rather than two lines at each call site.
61
+ const y = (0.5 - _projected.y * 0.5) * height;
62
+ const inFront = _projected.z >= -1 && _projected.z <= 1;
63
+ const onCanvas = x >= 0 && x <= width && y >= 0 && y <= height;
64
+ return { x, y, visible: inFront && onCanvas };
65
+ }
66
+
67
+ // Is `next` different enough from `previous` to be worth telling anyone about?
68
+ // A still camera re-projects to the same pixel every frame, and publishing that
69
+ // 60 times a second across a postMessage boundary is pure noise.
70
+ export function anchorMoved(previous, next, epsilon = 0.5) {
71
+ if (!previous || !next) return previous !== next;
72
+ return previous.visible !== next.visible
73
+ || Math.abs(previous.x - next.x) >= epsilon
74
+ || Math.abs(previous.y - next.y) >= epsilon;
75
+ }
@@ -3,6 +3,7 @@
3
3
  import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
4
4
  import { resolveSelection } from "./resolve.js";
5
5
  import { createDragTracker } from "./drag-tracker.js";
6
+ import { projectToScreen } from "../pick-flash.js";
6
7
 
7
8
  export { worldToSubPartLocal };
8
9
 
@@ -10,6 +11,8 @@ export { worldToSubPartLocal };
10
11
  // whose suppression condition lives elsewhere (mount passes measure mode's
11
12
  // isEnabled): while it returns true a click neither raycasts, flashes, nor
12
13
  // picks — no resync bookkeeping the way an event-driven setActive would need.
14
+ // onPick receives (selection, anchor): where the marker this click flashed
15
+ // landed on the canvas, in CSS px from its top-left.
13
16
  export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
14
17
  let active = false;
15
18
  const drag = createDragTracker();
@@ -25,7 +28,19 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
25
28
  if (!hit) return;
26
29
  const selection = resolveSelection(part, getContext(), hit);
27
30
  viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
28
- onPick(selection);
31
+ // The anchor is the MARKER's projection, not the pointer's position, even
32
+ // though the two coincide at this instant: the host's follow-the-camera
33
+ // stream projects the same world point through the same function every
34
+ // frame after, so the first answer is of a piece with the rest.
35
+ // Sized from the rect the raycast just used, which is what makes this
36
+ // anchor land back on the pixel the user clicked. The stream sizes from the
37
+ // renderer's last setSize instead — also CSS px, but the CONTAINER's
38
+ // integer clientWidth/Height as of the last ResizeObserver call, and 1x1
39
+ // while the viewer is parked. They agree in steady state; the divergence is
40
+ // staleness, never units.
41
+ const rect = viewer.domElement.getBoundingClientRect();
42
+ const anchor = projectToScreen(viewer.camera, hit.pointWorld, rect.width, rect.height);
43
+ onPick(selection, { x: anchor.x, y: anchor.y });
29
44
  }
30
45
 
31
46
  viewer.domElement.addEventListener("pointerdown", drag.onDown);
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
9
- import { flashWorldRadius } from "./pick-flash.js";
9
+ import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
10
10
  import { createCameraTween } from "./camera-tween.js";
11
11
  import { orbitPose } from "./camera-orbit.js";
12
12
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
@@ -871,6 +871,17 @@ export function createViewer(container, part) {
871
871
  liveLights.key.visible = false;
872
872
  liveLights.fill.visible = false;
873
873
  scene.add(capKey, capKey.target, capFill, capFill.target);
874
+ // A pick marker is transient UI feedback about a click, never part of the
875
+ // part, so it belongs in no capture. It used to be near enough true that a
876
+ // capture would miss one — a dot faded after 1200ms — but a HELD dot lives
877
+ // as long as the host's UI hangs off it, so any capture taken during a pick
878
+ // would now bake the yellow sphere into an agent-facing render or a
879
+ // published thumbnail. Hidden HERE, at the one offscreen chokepoint, rather
880
+ // than via canonicalCaptureHidden: captureCurrent deliberately ignores that
881
+ // set. Only dots that were actually shown are restored, so this cannot
882
+ // resurrect one the live canvas had hidden for its own reasons.
883
+ const reshowFlashDots = [];
884
+ for (const dot of flashDots) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
874
885
  try {
875
886
  renderer.setRenderTarget(rt);
876
887
  renderer.render(renderScene, cam);
@@ -878,11 +889,13 @@ export function createViewer(container, part) {
878
889
  // reads antialiased pixels.
879
890
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
880
891
  } finally {
881
- // Never leave the user's own view unlit or pointed at the offscreen target.
892
+ // Never leave the user's own view unlit, pointed at the offscreen target,
893
+ // or missing a marker the user is still looking at.
882
894
  renderer.setRenderTarget(null);
883
895
  scene.remove(capKey, capKey.target, capFill, capFill.target);
884
896
  liveLights.key.visible = true;
885
897
  liveLights.fill.visible = true;
898
+ for (const dot of reshowFlashDots) dot.visible = true;
886
899
  if (!cachedSize) rt.dispose();
887
900
  }
888
901
  const canvas = document.createElement("canvas");
@@ -1055,6 +1068,12 @@ export function createViewer(container, part) {
1055
1068
  // a dot is only alive for about a second, but orbiting or zooming inside
1056
1069
  // that second must not resize it.
1057
1070
  for (const dot of flashDots) scaleFlashDot(dot);
1071
+ // …and re-project the held one, so a host anchored to it follows the
1072
+ // camera. Change-gated: a still camera publishes nothing.
1073
+ if (anchorDot) {
1074
+ const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
1075
+ if (anchorMoved(lastAnchor, next)) publishAnchor(next);
1076
+ }
1058
1077
  renderer.render(scene, activeCamera);
1059
1078
  cutaway.renderOverlay(renderer, activeCamera);
1060
1079
  }
@@ -1157,15 +1176,48 @@ export function createViewer(container, part) {
1157
1176
  //
1158
1177
  // The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
1159
1178
  // constant handful of CSS pixels instead of a fixed millimetre size.
1179
+ const _flashWorld = new THREE.Vector3();
1160
1180
  const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
1161
1181
  const flashTimers = new Set();
1162
1182
  const flashDots = new Set();
1183
+ // The subset that will not fade. A HELD marker outlives the pick that made
1184
+ // it, because the host has hung something off it — see holdFlashPoint.
1185
+ const heldDots = new Set();
1186
+ let lastFlashed = null; // the newest marker, which is what hold() holds
1187
+ let anchorDot = null; // the held marker the anchor stream follows
1188
+ let lastAnchor = null;
1189
+ const anchorListeners = new Set();
1163
1190
  const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
1164
1191
  const flashViewport = new THREE.Vector2();
1192
+
1165
1193
  function scaleFlashDot(dot) {
1166
1194
  renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
1167
1195
  dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
1168
1196
  }
1197
+
1198
+ function projectPoint(world) {
1199
+ renderer.getSize(flashViewport);
1200
+ _flashWorld.set(world[0], world[1], world[2]);
1201
+ return projectToScreen(activeCamera, _flashWorld, flashViewport.x, flashViewport.y);
1202
+ }
1203
+
1204
+ function publishAnchor(anchor) {
1205
+ lastAnchor = anchor;
1206
+ // A throwing subscriber must not stop the render loop or the other
1207
+ // subscribers — same containment the frame listeners get.
1208
+ for (const cb of [...anchorListeners]) {
1209
+ try { cb(anchor); } catch (e) { console.warn("partforge: anchor listener failed", e); }
1210
+ }
1211
+ }
1212
+
1213
+ function dropFlashDot(dot) {
1214
+ scene.remove(dot);
1215
+ dot.material.dispose(); // the geometry is shared and freed in dispose()
1216
+ flashDots.delete(dot);
1217
+ heldDots.delete(dot);
1218
+ if (lastFlashed === dot) lastFlashed = null;
1219
+ }
1220
+
1169
1221
  function flashPoint(world) {
1170
1222
  const dot = new THREE.Mesh(
1171
1223
  flashGeometry,
@@ -1178,15 +1230,53 @@ export function createViewer(container, part) {
1178
1230
  scaleFlashDot(dot); // sized before its first frame, not one frame late
1179
1231
  scene.add(dot);
1180
1232
  flashDots.add(dot);
1233
+ lastFlashed = dot;
1181
1234
  const t = setTimeout(() => {
1182
1235
  flashTimers.delete(t);
1183
- flashDots.delete(dot);
1184
- // The geometry is shared by every dot and freed in dispose(), not here.
1185
- scene.remove(dot); dot.material.dispose();
1236
+ dot.userData.fadeTimer = null;
1237
+ dropFlashDot(dot);
1186
1238
  }, 1200);
1239
+ dot.userData.fadeTimer = t;
1187
1240
  flashTimers.add(t);
1188
1241
  }
1189
1242
 
1243
+ // Keep the newest marker on screen until released. Earlier held markers stay
1244
+ // held: picking a second spot should light both, and one release() clears
1245
+ // them together. The anchor stream follows the newest, which is the one a
1246
+ // host's own UI is anchored to.
1247
+ function holdFlashPoint() {
1248
+ if (!lastFlashed) return false;
1249
+ const dot = lastFlashed;
1250
+ if (dot.userData.fadeTimer) {
1251
+ clearTimeout(dot.userData.fadeTimer);
1252
+ flashTimers.delete(dot.userData.fadeTimer);
1253
+ dot.userData.fadeTimer = null;
1254
+ }
1255
+ heldDots.add(dot);
1256
+ anchorDot = dot;
1257
+ publishAnchor(projectPoint([dot.position.x, dot.position.y, dot.position.z]));
1258
+ return true;
1259
+ }
1260
+
1261
+ function releaseFlashPoints() {
1262
+ if (heldDots.size === 0 && !anchorDot) return;
1263
+ for (const dot of [...heldDots]) dropFlashDot(dot);
1264
+ anchorDot = null;
1265
+ publishAnchor(null);
1266
+ }
1267
+
1268
+ function onFlashAnchorChange(cb) {
1269
+ // The `disposed` half is the same guard cutaway's onHandleHoverChange takes,
1270
+ // and for the same two reasons: a subscribe racing teardown (effect-cleanup
1271
+ // ordering, a StrictMode remount) would otherwise be handed an anchor for a
1272
+ // dot no longer in the scene, and would re-populate a listener set that
1273
+ // dispose() will never clear again — retaining the embedder's closure.
1274
+ if (disposed || typeof cb !== "function") return () => {};
1275
+ anchorListeners.add(cb);
1276
+ cb(lastAnchor); // current state on subscribe, like onCutawayHandleHover
1277
+ return () => anchorListeners.delete(cb);
1278
+ }
1279
+
1190
1280
  // Full teardown: render loop, observers, controls, timers, GPU resources, DOM.
1191
1281
  // Idempotent. Cached sub-part geometries and their edge lines are freed; the
1192
1282
  // shared and per-part cloned materials tolerate double-dispose.
@@ -1211,10 +1301,15 @@ export function createViewer(container, part) {
1211
1301
  controls.dispose();
1212
1302
  for (const t of flashTimers) clearTimeout(t);
1213
1303
  flashTimers.clear();
1214
- // A dot whose timer was just cancelled still holds its own material; the
1215
- // sphere geometry is shared across all of them and freed once.
1304
+ // A dot whose timer was just cancelled or one held indefinitely — still
1305
+ // holds its own material; the sphere geometry is shared and freed once.
1216
1306
  for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
1217
1307
  flashDots.clear();
1308
+ heldDots.clear();
1309
+ lastFlashed = null;
1310
+ anchorDot = null;
1311
+ lastAnchor = null;
1312
+ anchorListeners.clear();
1218
1313
  flashGeometry.dispose();
1219
1314
  cutaway.dispose();
1220
1315
  for (const n of names) {
@@ -1284,6 +1379,10 @@ export function createViewer(container, part) {
1284
1379
  __subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
1285
1380
  __subLines: (n) => subLines[n],
1286
1381
  flashPoint,
1382
+ projectPoint,
1383
+ holdFlashPoint,
1384
+ releaseFlashPoints,
1385
+ onFlashAnchorChange,
1287
1386
  cutawaySupported: () => cutaway.isSupported,
1288
1387
  cutawayEnabled: () => cutaway.isEnabled,
1289
1388
  setCutawayEnabled,
package/types/index.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface PickEvent {
44
44
  prompt: string;
45
45
  /** The selection formatted as a compact token. */
46
46
  token: string;
47
+ /** Where the pick's marker sits on the canvas, in CSS px from its top-left. */
48
+ anchor: { x: number; y: number };
47
49
  }
48
50
 
49
51
  /** Fired once per completed build. NOT fired for a pose-only edit. */
@@ -244,6 +246,24 @@ export interface AnnotateRuntime {
244
246
  onModeChange(cb: () => void): () => void;
245
247
  }
246
248
 
249
+ /**
250
+ * The yellow marker a pick flashes, as a thing with a lifetime — for a host
251
+ * that hangs its own UI (a chat bubble, a callout) off the dot. A marker fades
252
+ * on its own about a second after the pick, so `hold()` belongs in the `onPick`
253
+ * handler, not behind a later user action.
254
+ */
255
+ export interface PickMarkerRuntime {
256
+ /** Keep the newest marker on screen; false when there is none. Earlier held markers stay held. */
257
+ hold(): boolean;
258
+ /** Clear every held marker. */
259
+ release(): void;
260
+ /**
261
+ * Where the newest held marker is, as the camera moves; null when nothing is
262
+ * held. Fires immediately with the current state. Returns an unsubscribe.
263
+ */
264
+ onAnchorChange(cb: (anchor: { x: number; y: number; visible: boolean } | null) => void): () => void;
265
+ }
266
+
247
267
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
248
268
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
249
269
 
@@ -359,6 +379,8 @@ export interface PartRuntime {
359
379
  measure: MeasureRuntime;
360
380
  /** Annotation mode's runtime-controls API — mode on/off, ink state, and send. Always present; a no-op stand-in when `onAnnotationSend` was not supplied. */
361
381
  annotate: AnnotateRuntime;
382
+ /** The pick marker's lifetime — hold it on screen and follow it across the canvas. Always present (a no-op stand-in outside `makeHandle` tests). */
383
+ pickMarker: PickMarkerRuntime;
362
384
  }
363
385
 
364
386
  /** Mount a full parametric-part app from a `PartDefinition`. */