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 +1 -1
- package/src/framework/mount.js +30 -2
- package/src/framework/pick-flash.js +31 -0
- package/src/framework/selection/pick.js +16 -1
- package/src/framework/viewer.js +106 -7
- package/types/index.d.ts +22 -0
package/package.json
CHANGED
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1184
|
-
|
|
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
|
|
1215
|
-
// sphere geometry is shared
|
|
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`. */
|