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 +1 -1
- package/src/framework/cutaway.js +9 -0
- package/src/framework/mount.js +30 -2
- package/src/framework/pick-flash.js +75 -0
- package/src/framework/selection/format.js +7 -2
- package/src/framework/selection/pick.js +19 -2
- package/src/framework/selection/raycast.js +97 -1
- package/src/framework/selection/resolve.js +4 -0
- package/src/framework/viewer.js +142 -5
- package/types/index.d.ts +28 -0
package/package.json
CHANGED
package/src/framework/cutaway.js
CHANGED
|
@@ -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,
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1146
|
-
new THREE.MeshBasicMaterial({
|
|
1223
|
+
flashGeometry,
|
|
1224
|
+
new THREE.MeshBasicMaterial({
|
|
1225
|
+
color: 0xffcc33, depthTest: false, depthWrite: false, transparent: true,
|
|
1226
|
+
})
|
|
1147
1227
|
);
|
|
1148
|
-
dot.renderOrder =
|
|
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
|
-
|
|
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`. */
|