partforge 0.90.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.90.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",
@@ -387,6 +387,14 @@ export function createCutaway({
387
387
  return !enabled || pointSurvivesPlane(plane, point);
388
388
  }
389
389
 
390
+ // The live clip plane, for a caller that has to reason about the CUT itself
391
+ // rather than test a point against it — clicking the cut face is the only one
392
+ // today. Null while disabled, and a copy either way, so no caller can hold a
393
+ // stale plane or move ours.
394
+ function getPlane(target = new THREE.Plane()) {
395
+ return enabled ? target.copy(plane) : null;
396
+ }
397
+
390
398
  function registerClippableMaterial(material) {
391
399
  if (disposed || !material) return () => {};
392
400
  let entry = auxiliaryMaterials.get(material);
@@ -521,6 +529,7 @@ export function createCutaway({
521
529
  setTheme,
522
530
  setViewportSize,
523
531
  isPointVisible,
532
+ getPlane,
524
533
  registerClippableMaterial,
525
534
  updateForCamera,
526
535
  renderOverlay,
@@ -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 {
@@ -0,0 +1,75 @@
1
+ // Sizing for the transient pick marker (viewer.flashPoint).
2
+ //
3
+ // The marker used to be a fixed 1.2 mm sphere — a constant WORLD size, so it
4
+ // swelled to cover the very feature it was pointing at as soon as you zoomed
5
+ // in, and shrank to nothing on a large part. Sizing it from the camera each
6
+ // frame keeps it a constant handful of CSS pixels under both projections.
7
+ import * as THREE from "three";
8
+
9
+ export const FLASH_PIXEL_RADIUS = 5; // CSS px — a ~10px dot, the size it read as at default framing
10
+
11
+ const MIN_RADIUS = 1e-6; // never a zero-scale matrix
12
+
13
+ const _offset = new THREE.Vector3();
14
+ const _forward = new THREE.Vector3();
15
+
16
+ // World units spanned by one CSS pixel at `worldPoint`, for `camera`.
17
+ //
18
+ // Perspective: the frustum widens with view depth, so the answer is measured
19
+ // along the view direction rather than as a straight distance to the camera —
20
+ // an off-axis point is farther from the eye than it is deep, and would size a
21
+ // fraction too large. Orthographic: depth does not enter into it at all; the
22
+ // frustum height (as zoom leaves it) is the whole story.
23
+ export function worldPerPixel(camera, worldPoint, viewportHeightPx) {
24
+ const height = viewportHeightPx > 0 ? viewportHeightPx : 1;
25
+ if (camera.isOrthographicCamera) {
26
+ const extent = Math.abs(camera.top - camera.bottom) / (camera.zoom || 1);
27
+ return extent / height;
28
+ }
29
+ camera.getWorldDirection(_forward);
30
+ const depth = Math.abs(_offset.copy(worldPoint).sub(camera.position).dot(_forward));
31
+ const fov = ((camera.fov ?? 50) * Math.PI) / 180;
32
+ return (2 * Math.tan(fov / 2) * depth) / height;
33
+ }
34
+
35
+ // Radius, in world units, for a marker that should read as `pixelRadius` CSS px.
36
+ export function flashWorldRadius(
37
+ camera,
38
+ worldPoint,
39
+ viewportHeightPx,
40
+ pixelRadius = FLASH_PIXEL_RADIUS,
41
+ ) {
42
+ const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
43
+ return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
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
+ }
@@ -10,13 +10,18 @@ const fmtParams = (p) => Object.entries(p).map(([k, v]) => `${k}:${v}`).join(","
10
10
  function tokenStyle(s) {
11
11
  const head = `@${s.subPart}`;
12
12
  const feat = s.feature ? ` · ${s.feature.label}` : "";
13
- return `${head}${feat} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
13
+ const cut = s.onCutPlane ? " · section" : "";
14
+ return `${head}${feat}${cut} · pt(${s.point.join(",")}) n(${fmtNormal(s.normal)}) · {${fmtParams(s.params)}}`;
14
15
  }
15
16
 
16
17
  function promptStyle(s) {
17
18
  const params = Object.entries(s.params).map(([k, v]) => `${k}: ${v}`).join(", ");
18
19
  const feat = s.feature ? ` **${s.feature.label}**,` : "";
19
- return `On sub-part **${s.subPart}**, the user pointed at${feat} local point (${s.point.join(", ")}), `
20
+ // The section wording is load-bearing: without it the point reads as a spot
21
+ // on the part's surface, when it is a spot inside the material that the
22
+ // cutaway happens to have opened up.
23
+ const cut = s.onCutPlane ? " the cutaway section face at" : "";
24
+ return `On sub-part **${s.subPart}**, the user pointed at${feat}${cut} local point (${s.point.join(", ")}), `
20
25
  + `normal ${fmtNormal(s.normal)}, with params {${params}}.`;
21
26
  }
22
27
 
@@ -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();
@@ -19,11 +22,25 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
19
22
  // a suppressed click must still clear its just-dragged flag.
20
23
  const wasDragged = drag.consumeClick();
21
24
  if (!active || wasDragged || suppressed?.()) return;
22
- const hit = raycastViewer(viewer, ev.clientX, ev.clientY);
25
+ // includeSection: in a cutaway the flat cut face is the biggest thing on
26
+ // screen, and it is the one surface with no geometry behind it to hit.
27
+ const hit = raycastViewer(viewer, ev.clientX, ev.clientY, { includeSection: true });
23
28
  if (!hit) return;
24
29
  const selection = resolveSelection(part, getContext(), hit);
25
30
  viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
26
- 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 });
27
44
  }
28
45
 
29
46
  viewer.domElement.addEventListener("pointerdown", drag.onDown);
@@ -26,6 +26,18 @@ function normalInSubPartFrame(mesh, normal) {
26
26
  return [_normal.x, _normal.y, _normal.z];
27
27
  }
28
28
 
29
+ const _inverseWorld = new THREE.Matrix4();
30
+
31
+ // The same trip for a WORLD direction: back through the mesh's world transform
32
+ // into the geometry frame, then forward by its local matrix like any other
33
+ // geometry-frame direction. The cut plane's normal is the only one that arrives
34
+ // this way — it belongs to the scene, not to a triangle.
35
+ function worldNormalInSubPartFrame(mesh, worldNormal) {
36
+ _inverseWorld.copy(mesh.matrixWorld).invert();
37
+ _normal.copy(worldNormal).transformDirection(_inverseWorld).transformDirection(mesh.matrix);
38
+ return [_normal.x, _normal.y, _normal.z];
39
+ }
40
+
29
41
  // The feature carried by a mesh triangle, or null (unlabeled / no attribution data).
30
42
  export function featureAt(mesh, triIndex) {
31
43
  const { featureIds, features } = mesh.geometry.userData;
@@ -33,7 +45,84 @@ export function featureAt(mesh, triIndex) {
33
45
  return id > 0 ? { id, label: features[id - 1] } : null;
34
46
  }
35
47
 
36
- export function raycastViewer(viewer, clientX, clientY) {
48
+ const SECTION_EPSILON = 1e-6;
49
+ const _sectionPoint = new THREE.Vector3();
50
+ const _sectionNormal = new THREE.Vector3();
51
+ const _reverseDirection = new THREE.Vector3();
52
+ const _cutPlane = new THREE.Plane();
53
+
54
+ // The cut FACE under the pointer, when a cutaway is showing one.
55
+ //
56
+ // Nothing real is there to hit: a section view removes the near half by
57
+ // clipping, which the raycaster knows nothing about, so every triangle along
58
+ // that ray is either on the discarded side (rejected by isWorldPointVisible) or
59
+ // a back face three does not report at all. Clicking the largest surface in the
60
+ // view therefore selected nothing.
61
+ //
62
+ // What IS there is solid material, sliced open at the plane. So: find where the
63
+ // ray crosses into the retained half, and ask whether the sub-part is solid at
64
+ // that crossing. It is solid when the ray has entered it more often than it has
65
+ // left it — and the exits of a ray are the entries of the same ray reversed,
66
+ // which is how this stays inside three's front-face raycasting rather than
67
+ // flipping every material to DoubleSide behind the renderer's back.
68
+ function sectionHit(viewer, meshes, forwardHits, surfaceHit) {
69
+ const plane = viewer.getCutawayPlane?.(_cutPlane);
70
+ if (!plane) return null;
71
+ const ray = raycaster.ray;
72
+ // Only a ray crossing INTO the retained half can reveal a cut face; if the
73
+ // camera is already on that side, nothing in front of it was removed.
74
+ if (plane.normal.dot(ray.direction) <= SECTION_EPSILON) return null;
75
+ const distance = ray.distanceToPlane(plane);
76
+ if (distance == null || distance <= 0) return null;
77
+ // A retained surface nearer than the crossing is simply in front of the cut.
78
+ if (surfaceHit && surfaceHit.distance <= distance) return null;
79
+ // Nothing entered before the plane can be solid at it, so the reverse
80
+ // raycast below is pure cost on the common miss — a click on the backdrop.
81
+ if (!forwardHits.some((hit) => hit.distance < distance)) return null;
82
+ ray.at(distance, _sectionPoint);
83
+
84
+ raycaster.set(_sectionPoint, _reverseDirection.copy(ray.direction).negate());
85
+ const exitHits = raycaster.intersectObjects(meshes, false);
86
+
87
+ let best = null;
88
+ for (const mesh of meshes) {
89
+ let net = 0;
90
+ let entry = null;
91
+ for (const hit of forwardHits) {
92
+ if (hit.object !== mesh || hit.distance >= distance) continue;
93
+ net += 1;
94
+ entry ??= hit; // hits arrive sorted, so this is the nearest entry
95
+ }
96
+ for (const hit of exitHits) {
97
+ if (hit.object === mesh && hit.distance < distance) net -= 1;
98
+ }
99
+ if (net > 0 && (best == null || entry.distance < best.entry.distance)) best = { mesh, entry };
100
+ }
101
+ if (!best) return null;
102
+
103
+ const { mesh, entry } = best;
104
+ return {
105
+ mesh,
106
+ subPart: mesh.name,
107
+ // The triangle the ray entered through. It is not the one under the
108
+ // pointer — a cut face has no triangles — but it is the material the cut
109
+ // opened, and callers that index by triangle need something real.
110
+ triIndex: entry.faceIndex,
111
+ pointWorld: _sectionPoint.clone(),
112
+ pointLocal: worldToSubPartLocal(mesh, _sectionPoint),
113
+ // The cut face looks back at the discarded half, opposite the plane normal.
114
+ normalLocal: worldNormalInSubPartFrame(mesh, _sectionNormal.copy(plane.normal).negate()),
115
+ // Deliberately unattributed: a cut face is an artefact of the section, not
116
+ // a surface the part's build ever labelled.
117
+ feature: null,
118
+ onCutPlane: true,
119
+ };
120
+ }
121
+
122
+ // `includeSection` opts into the cut-face pick above. It is off by default so
123
+ // hover labels and measurement mode keep hitting real geometry only — a
124
+ // dimension pinned to a plane the user can move is not a dimension.
125
+ export function raycastViewer(viewer, clientX, clientY, { includeSection = false } = {}) {
37
126
  const rect = viewer.domElement.getBoundingClientRect();
38
127
  ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
39
128
  ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
@@ -43,6 +132,13 @@ export function raycastViewer(viewer, clientX, clientY) {
43
132
  const hit = hits.find((candidate) =>
44
133
  viewer.isWorldPointVisible?.(candidate.point) ?? true
45
134
  );
135
+ // Ordered before the surface answer, not after: the crossing into the
136
+ // retained half is nearer than anything retained, so when a cut face is
137
+ // there it is what the pointer is over.
138
+ if (includeSection) {
139
+ const section = sectionHit(viewer, meshes, hits, hit);
140
+ if (section) return section;
141
+ }
46
142
  if (!hit) return null;
47
143
  return {
48
144
  mesh: hit.object,
@@ -45,5 +45,9 @@ export function resolveSelection(part, ctx, hit) {
45
45
  // Feature attribution from the mesh payload (Solid.label() in the part's build) —
46
46
  // the same name the hover tooltip shows, so user, agent, and viewer share vocabulary.
47
47
  if (hit.feature) selection.feature = { label: hit.feature.label };
48
+ // A cutaway section pick: the point is inside the material, on the cut face,
49
+ // not on a surface the part was built with. Callers that describe the click
50
+ // in words need to say so — the coordinates alone read as a normal surface.
51
+ if (hit.onCutPlane) selection.onCutPlane = true;
48
52
  return selection;
49
53
  }
@@ -5,6 +5,8 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  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
+ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
9
+ import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
8
10
  import { createCameraTween } from "./camera-tween.js";
9
11
  import { orbitPose } from "./camera-orbit.js";
10
12
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
@@ -869,6 +871,17 @@ export function createViewer(container, part) {
869
871
  liveLights.key.visible = false;
870
872
  liveLights.fill.visible = false;
871
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); }
872
885
  try {
873
886
  renderer.setRenderTarget(rt);
874
887
  renderer.render(renderScene, cam);
@@ -876,11 +889,13 @@ export function createViewer(container, part) {
876
889
  // reads antialiased pixels.
877
890
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
878
891
  } finally {
879
- // 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.
880
894
  renderer.setRenderTarget(null);
881
895
  scene.remove(capKey, capKey.target, capFill, capFill.target);
882
896
  liveLights.key.visible = true;
883
897
  liveLights.fill.visible = true;
898
+ for (const dot of reshowFlashDots) dot.visible = true;
884
899
  if (!cachedSize) rt.dispose();
885
900
  }
886
901
  const canvas = document.createElement("canvas");
@@ -1049,6 +1064,16 @@ export function createViewer(container, part) {
1049
1064
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
1050
1065
  }
1051
1066
  if (cutaway.isEnabled) cutaway.updateForCamera();
1067
+ // Re-size the pick markers against the pose this frame will actually draw:
1068
+ // a dot is only alive for about a second, but orbiting or zooming inside
1069
+ // that second must not resize it.
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
+ }
1052
1077
  renderer.render(scene, activeCamera);
1053
1078
  cutaway.renderOverlay(renderer, activeCamera);
1054
1079
  }
@@ -1139,22 +1164,119 @@ export function createViewer(container, part) {
1139
1164
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
1140
1165
 
1141
1166
  // Transient marker at a world-space point — visual confirmation of a pick.
1167
+ //
1168
+ // Two things keep it visible where it used to disappear. It is ordered above
1169
+ // every band the cutaway raises its surfaces, edges and outlines into (a
1170
+ // section view moves them past 1,000,000, so the old fixed 999 was painted
1171
+ // over by the very geometry the marker sat on — and `depthTest: false` means
1172
+ // no depth is written either, so nothing behind it survives the overdraw).
1173
+ // And it is `transparent`, which puts it in the pass three draws LAST: a
1174
+ // translucent part puts the cutaway's caps in the transparent list, where an
1175
+ // opaque marker is already behind whatever its render order.
1176
+ //
1177
+ // The sphere is a UNIT sphere scaled per frame (see below) so it reads as a
1178
+ // constant handful of CSS pixels instead of a fixed millimetre size.
1179
+ const _flashWorld = new THREE.Vector3();
1180
+ const FLASH_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
1142
1181
  const flashTimers = new Set();
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();
1190
+ const flashGeometry = new THREE.SphereGeometry(1, 16, 12);
1191
+ const flashViewport = new THREE.Vector2();
1192
+
1193
+ function scaleFlashDot(dot) {
1194
+ renderer.getSize(flashViewport); // CSS px, which is what a pixel radius means
1195
+ dot.scale.setScalar(flashWorldRadius(activeCamera, dot.position, flashViewport.y));
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
+
1143
1221
  function flashPoint(world) {
1144
1222
  const dot = new THREE.Mesh(
1145
- new THREE.SphereGeometry(1.2, 16, 12),
1146
- new THREE.MeshBasicMaterial({ color: 0xffcc33, depthTest: false })
1223
+ flashGeometry,
1224
+ new THREE.MeshBasicMaterial({
1225
+ color: 0xffcc33, depthTest: false, depthWrite: false, transparent: true,
1226
+ })
1147
1227
  );
1148
- dot.renderOrder = 999;
1228
+ dot.renderOrder = FLASH_RENDER_ORDER;
1149
1229
  dot.position.set(world[0], world[1], world[2]);
1230
+ scaleFlashDot(dot); // sized before its first frame, not one frame late
1150
1231
  scene.add(dot);
1232
+ flashDots.add(dot);
1233
+ lastFlashed = dot;
1151
1234
  const t = setTimeout(() => {
1152
1235
  flashTimers.delete(t);
1153
- scene.remove(dot); dot.geometry.dispose(); dot.material.dispose();
1236
+ dot.userData.fadeTimer = null;
1237
+ dropFlashDot(dot);
1154
1238
  }, 1200);
1239
+ dot.userData.fadeTimer = t;
1155
1240
  flashTimers.add(t);
1156
1241
  }
1157
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
+
1158
1280
  // Full teardown: render loop, observers, controls, timers, GPU resources, DOM.
1159
1281
  // Idempotent. Cached sub-part geometries and their edge lines are freed; the
1160
1282
  // shared and per-part cloned materials tolerate double-dispose.
@@ -1179,6 +1301,16 @@ export function createViewer(container, part) {
1179
1301
  controls.dispose();
1180
1302
  for (const t of flashTimers) clearTimeout(t);
1181
1303
  flashTimers.clear();
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.
1306
+ for (const dot of flashDots) { scene.remove(dot); dot.material.dispose(); }
1307
+ flashDots.clear();
1308
+ heldDots.clear();
1309
+ lastFlashed = null;
1310
+ anchorDot = null;
1311
+ lastAnchor = null;
1312
+ anchorListeners.clear();
1313
+ flashGeometry.dispose();
1182
1314
  cutaway.dispose();
1183
1315
  for (const n of names) {
1184
1316
  const g = subCache[n];
@@ -1247,12 +1379,17 @@ export function createViewer(container, part) {
1247
1379
  __subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
1248
1380
  __subLines: (n) => subLines[n],
1249
1381
  flashPoint,
1382
+ projectPoint,
1383
+ holdFlashPoint,
1384
+ releaseFlashPoints,
1385
+ onFlashAnchorChange,
1250
1386
  cutawaySupported: () => cutaway.isSupported,
1251
1387
  cutawayEnabled: () => cutaway.isEnabled,
1252
1388
  setCutawayEnabled,
1253
1389
  flipCutaway: cutaway.flip,
1254
1390
  resetCutaway: cutaway.reset,
1255
1391
  isWorldPointVisible: cutaway.isPointVisible,
1392
+ getCutawayPlane: cutaway.getPlane,
1256
1393
  registerCutawayMaterial: cutaway.registerClippableMaterial,
1257
1394
  registerCanonicalCaptureHidden,
1258
1395
  onCutawayHandleHover: cutaway.onHandleHoverChange,
package/types/index.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface Selection {
28
28
  params: Record<string, ParamValue>;
29
29
  /** Present when the hit surface carries a `Solid.label()` name. */
30
30
  feature?: { label: string };
31
+ /**
32
+ * Present when the click landed on a cutaway's cut face. The point is inside
33
+ * the material rather than on a surface the part was built with, and the
34
+ * normal faces the half the section removed.
35
+ */
36
+ onCutPlane?: true;
31
37
  }
32
38
 
33
39
  export interface PickEvent {
@@ -38,6 +44,8 @@ export interface PickEvent {
38
44
  prompt: string;
39
45
  /** The selection formatted as a compact token. */
40
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 };
41
49
  }
42
50
 
43
51
  /** Fired once per completed build. NOT fired for a pose-only edit. */
@@ -238,6 +246,24 @@ export interface AnnotateRuntime {
238
246
  onModeChange(cb: () => void): () => void;
239
247
  }
240
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
+
241
267
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
242
268
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
243
269
 
@@ -353,6 +379,8 @@ export interface PartRuntime {
353
379
  measure: MeasureRuntime;
354
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. */
355
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;
356
384
  }
357
385
 
358
386
  /** Mount a full parametric-part app from a `PartDefinition`. */