copper3d 3.6.9 → 3.6.12
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/dist/Scene/copperScene.d.ts +1 -1
- package/dist/Scene/copperScene.js +4 -1
- package/dist/Scene/copperScene.js.map +1 -1
- package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +41 -19
- package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js +329 -115
- package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js.map +1 -1
- package/dist/Utils/surfaceAnnotation/annotationStore.d.ts +3 -10
- package/dist/Utils/surfaceAnnotation/annotationStore.js +21 -10
- package/dist/Utils/surfaceAnnotation/annotationStore.js.map +1 -1
- package/dist/Utils/surfaceAnnotation/geodesicContour.d.ts +26 -0
- package/dist/Utils/surfaceAnnotation/geodesicContour.js +101 -1
- package/dist/Utils/surfaceAnnotation/geodesicContour.js.map +1 -1
- package/dist/Utils/surfaceAnnotation/pointMarkers.d.ts +8 -0
- package/dist/Utils/surfaceAnnotation/pointMarkers.js +19 -0
- package/dist/Utils/surfaceAnnotation/pointMarkers.js.map +1 -1
- package/dist/Utils/surfaceAnnotation/types.d.ts +6 -0
- package/dist/Utils/surfaceAnnotation/types.js.map +1 -1
- package/dist/bundle.esm.js +475 -129
- package/dist/bundle.umd.js +475 -129
- package/dist/types/Scene/copperScene.d.ts +1 -1
- package/dist/types/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +41 -19
- package/dist/types/Utils/surfaceAnnotation/annotationStore.d.ts +3 -10
- package/dist/types/Utils/surfaceAnnotation/geodesicContour.d.ts +26 -0
- package/dist/types/Utils/surfaceAnnotation/pointMarkers.d.ts +8 -0
- package/dist/types/Utils/surfaceAnnotation/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { worldHitToLocalVertex } from "./types";
|
|
3
3
|
import { raycastSurface } from "./raycastSurface";
|
|
4
|
-
import { makePointMarker } from "./pointMarkers";
|
|
4
|
+
import { makePointMarker, makeAnchorHandle } from "./pointMarkers";
|
|
5
5
|
import { StrokeContour } from "./strokeContour";
|
|
6
6
|
import { makeContourLine, updateContourLine, setContourColor } from "./contourRender";
|
|
7
7
|
import { MeshGraph } from "./MeshGraph";
|
|
@@ -30,11 +30,20 @@ export class SurfaceAnnotator {
|
|
|
30
30
|
this.managed = new Set();
|
|
31
31
|
this.seq = 0;
|
|
32
32
|
this.selectedId = null;
|
|
33
|
-
this.activeGeoMarkers = []; // visible
|
|
33
|
+
this.activeGeoMarkers = []; // visible anchor handles of the active geodesic
|
|
34
34
|
this.geoRay = new THREE.Raycaster();
|
|
35
35
|
this.geoNdc = new THREE.Vector2();
|
|
36
36
|
this.hoveredGeoMarker = -1;
|
|
37
|
+
this.draggingAnchor = -1; // anchor index currently being dragged (-1 = none)
|
|
38
|
+
this.geoRedrawScheduled = false; // rAF coalescing flag for live drag redraw
|
|
39
|
+
this.editingId = null; // committed geodesic re-opened for editing (Task 1.5)
|
|
40
|
+
this.geoClosed = false; // whether the active/edited geodesic is a closed loop
|
|
37
41
|
this._projV = new THREE.Vector3();
|
|
42
|
+
/** Block the OS right-click menu when the pointer is over the WebGL canvas (not the HTML panels). */
|
|
43
|
+
this.onContextMenu = (e) => {
|
|
44
|
+
if (this.insideContainer(e))
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
};
|
|
38
47
|
/** Update the pixel resolution of all fat lines on window resize, otherwise line width distorts. */
|
|
39
48
|
this.onResize = () => {
|
|
40
49
|
const w = this.o.container.clientWidth;
|
|
@@ -51,10 +60,50 @@ export class SurfaceAnnotator {
|
|
|
51
60
|
(this.activeGeoLine.material.resolution.set(w, h));
|
|
52
61
|
};
|
|
53
62
|
this.onPointerDown = (e) => {
|
|
54
|
-
if (e.button !== 0)
|
|
55
|
-
return; // left button only
|
|
56
63
|
if (!this.insideContainer(e))
|
|
57
64
|
return;
|
|
65
|
+
// Geodesic anchor editing (drag to move / right-click to delete) is available whenever an
|
|
66
|
+
// editable geodesic exists — even outside draw-lock — because it requires precisely hitting an
|
|
67
|
+
// existing anchor. This lets the user rotate the model freely and still grab a point. Handled
|
|
68
|
+
// BEFORE the button/drawing gates below.
|
|
69
|
+
if (this.armed === "geodesic" && this.activeGeo) {
|
|
70
|
+
if (e.button === 2) {
|
|
71
|
+
const pick = this.pickGeoMarker(e);
|
|
72
|
+
if (pick >= 0) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
this.activeGeo.removeAnchorAt(pick);
|
|
75
|
+
this.afterGeoEdit();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (e.button === 0) {
|
|
80
|
+
const pick = this.pickGeoMarker(e);
|
|
81
|
+
if (pick >= 0) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
this.draggingAnchor = pick;
|
|
84
|
+
this.pointerDown = true;
|
|
85
|
+
this.o.controls.enabled = false; // suppress camera while dragging the anchor
|
|
86
|
+
this.o.container.style.cursor = "grabbing";
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Missed every anchor → if the click lands on the line body, insert a new anchor there
|
|
90
|
+
// (works in an edit session or an in-progress geodesic, regardless of draw-lock).
|
|
91
|
+
if (this.activeGeo.anchorCount >= 2) {
|
|
92
|
+
const h = this.hit(e);
|
|
93
|
+
if (h) {
|
|
94
|
+
const insertAt = this.activeGeo.nearestInsertIndex(h.point, this.o.mesh.matrixWorld, this.geoClosed, this.minGap * 4);
|
|
95
|
+
if (insertAt >= 0) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
this.activeGeo.insertAnchorAt(insertAt + 1, this.o.mesh.worldToLocal(h.point.clone()));
|
|
98
|
+
this.afterGeoEdit();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (e.button !== 0)
|
|
106
|
+
return; // the remaining tools are left-button only
|
|
58
107
|
this.pointerDown = true;
|
|
59
108
|
// Only act with the armed tool when in drawing mode (drawLock or spaceHeld).
|
|
60
109
|
// When not drawing, navigation is the default — pointer events go to camera controls.
|
|
@@ -94,19 +143,8 @@ export class SurfaceAnnotator {
|
|
|
94
143
|
return;
|
|
95
144
|
}
|
|
96
145
|
if (activeTool === "geodesic") {
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
if (pick >= 0 && this.activeGeo) {
|
|
100
|
-
this.activeGeo.removeAnchorAt(pick);
|
|
101
|
-
if (this.activeGeo.anchorCount === 0)
|
|
102
|
-
this.clearActiveGeo();
|
|
103
|
-
else {
|
|
104
|
-
this.rebuildGeoMarkers();
|
|
105
|
-
this.redrawGeoLine();
|
|
106
|
-
}
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
// Otherwise drop a new anchor on the surface.
|
|
146
|
+
// Anchor grab/delete was already handled above; here we only drop a NEW anchor on the surface
|
|
147
|
+
// (clicks on an existing anchor never reach this point).
|
|
110
148
|
const h = this.hit(e);
|
|
111
149
|
if (!h)
|
|
112
150
|
return;
|
|
@@ -115,11 +153,20 @@ export class SurfaceAnnotator {
|
|
|
115
153
|
}
|
|
116
154
|
const local = this.o.mesh.worldToLocal(h.point.clone());
|
|
117
155
|
this.activeGeo.addAnchor(local);
|
|
118
|
-
this.
|
|
119
|
-
this.redrawGeoLine();
|
|
156
|
+
this.afterGeoEdit();
|
|
120
157
|
}
|
|
121
158
|
};
|
|
122
159
|
this.onPointerMove = (e) => {
|
|
160
|
+
// Live anchor drag: move the grabbed anchor to the surface point under the cursor and redraw
|
|
161
|
+
// (both adjacent segments recompute inside moveAnchorTo). Takes priority over everything else.
|
|
162
|
+
if (this.draggingAnchor >= 0) {
|
|
163
|
+
const h = this.hit(e);
|
|
164
|
+
if (h && this.activeGeo) {
|
|
165
|
+
this.activeGeo.moveAnchorTo(this.draggingAnchor, this.o.mesh.worldToLocal(h.point.clone()));
|
|
166
|
+
this.scheduleGeoRedraw();
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
123
170
|
// Track if user dragged while space was held (so a hold-drag is not treated as a tap).
|
|
124
171
|
if (this.spaceHeld && this.pointerDown) {
|
|
125
172
|
this.spaceDragged = true;
|
|
@@ -151,6 +198,15 @@ export class SurfaceAnnotator {
|
|
|
151
198
|
}
|
|
152
199
|
};
|
|
153
200
|
this.onPointerUp = () => {
|
|
201
|
+
// Finish an anchor drag: restore camera gating + cursor. (Re-commit of an edited committed
|
|
202
|
+
// contour is wired in Task 1.5.)
|
|
203
|
+
if (this.draggingAnchor >= 0) {
|
|
204
|
+
this.draggingAnchor = -1;
|
|
205
|
+
this.pointerDown = false;
|
|
206
|
+
this.o.container.style.cursor = this.hoveredGeoMarker >= 0 ? "grab" : "";
|
|
207
|
+
this.applyCameraGating();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
154
210
|
if (!this.pointerDown)
|
|
155
211
|
return;
|
|
156
212
|
this.pointerDown = false;
|
|
@@ -192,6 +248,18 @@ export class SurfaceAnnotator {
|
|
|
192
248
|
return;
|
|
193
249
|
}
|
|
194
250
|
if (e.key === "Escape") {
|
|
251
|
+
// First Esc clears an active selection / in-progress geodesic while staying in the current
|
|
252
|
+
// tool; a second Esc (nothing selected) returns to Navigate.
|
|
253
|
+
if (this.selectedId || this.activeGeo) {
|
|
254
|
+
if (this.editingId)
|
|
255
|
+
this.recommitGeodesic();
|
|
256
|
+
else if (this.activeGeo)
|
|
257
|
+
this.clearActiveGeo();
|
|
258
|
+
this.setSelected(null);
|
|
259
|
+
this.drawLock = false;
|
|
260
|
+
this.applyCameraGating();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
195
263
|
this.drawLock = false;
|
|
196
264
|
this.setMode("navigate");
|
|
197
265
|
return;
|
|
@@ -207,8 +275,11 @@ export class SurfaceAnnotator {
|
|
|
207
275
|
return;
|
|
208
276
|
}
|
|
209
277
|
if (e.key === "Enter") {
|
|
210
|
-
//
|
|
211
|
-
|
|
278
|
+
// Editing a committed geodesic → finalize the edit; a fresh geodesic in progress → finish and
|
|
279
|
+
// close it; otherwise close the most recent freehand line.
|
|
280
|
+
if (this.editingId)
|
|
281
|
+
this.recommitGeodesic();
|
|
282
|
+
else if (this.activeGeo)
|
|
212
283
|
this.finishGeodesic();
|
|
213
284
|
else
|
|
214
285
|
this.closeLastContour();
|
|
@@ -261,6 +332,10 @@ export class SurfaceAnnotator {
|
|
|
261
332
|
window.addEventListener("keydown", this.onKeyDown);
|
|
262
333
|
window.addEventListener("keyup", this.onKeyUp);
|
|
263
334
|
window.addEventListener("resize", this.onResize);
|
|
335
|
+
// Suppress the browser's native context menu over the WebGL canvas in EVERY mode: right-click
|
|
336
|
+
// there is used for camera pan (and, in geodesic mode, delete-anchor), so the OS menu must
|
|
337
|
+
// never appear. Guarded by insideContainer → right-clicking the HTML panels keeps its menu.
|
|
338
|
+
window.addEventListener("contextmenu", this.onContextMenu, true);
|
|
264
339
|
this.applyCameraGating();
|
|
265
340
|
}
|
|
266
341
|
get freehandColor() {
|
|
@@ -281,14 +356,22 @@ export class SurfaceAnnotator {
|
|
|
281
356
|
}
|
|
282
357
|
setMode(m) {
|
|
283
358
|
var _a, _b;
|
|
284
|
-
// When leaving geodesic mode
|
|
285
|
-
|
|
286
|
-
|
|
359
|
+
// When leaving geodesic mode: commit an in-progress EDIT of a committed contour, or discard a
|
|
360
|
+
// not-yet-committed new geodesic (clear leftover anchors and line).
|
|
361
|
+
if (m !== "geodesic") {
|
|
362
|
+
if (this.editingId)
|
|
363
|
+
this.recommitGeodesic();
|
|
364
|
+
else if (this.activeGeo)
|
|
365
|
+
this.clearActiveGeo();
|
|
366
|
+
}
|
|
287
367
|
this.mode = m;
|
|
288
368
|
// Record armed tool when choosing a drawing mode (not navigate).
|
|
289
369
|
// Camera gating is NOT changed here — the user must use Space to enter drawing.
|
|
290
370
|
if (m !== "navigate")
|
|
291
371
|
this.armed = m;
|
|
372
|
+
// Returning to Navigate clears the selection too (put everything away).
|
|
373
|
+
if (m === "navigate")
|
|
374
|
+
this.setSelected(null);
|
|
292
375
|
this.applyCameraGating();
|
|
293
376
|
(_b = (_a = this.o).onModeChange) === null || _b === void 0 ? void 0 : _b.call(_a, m);
|
|
294
377
|
}
|
|
@@ -300,21 +383,23 @@ export class SurfaceAnnotator {
|
|
|
300
383
|
return this.store.list();
|
|
301
384
|
}
|
|
302
385
|
undo() {
|
|
303
|
-
//
|
|
304
|
-
//
|
|
386
|
+
// A geodesic being drawn/edited → undo the most recent anchor edit. afterGeoEdit() handles both
|
|
387
|
+
// the in-progress and committed-edit cases uniformly, including degenerate cleanup (so undo can't
|
|
388
|
+
// dispose a committed line or leave a stale edit session).
|
|
305
389
|
if (this.activeGeo && this.activeGeo.canUndo()) {
|
|
306
390
|
this.activeGeo.undoEdit();
|
|
307
|
-
|
|
308
|
-
this.clearActiveGeo();
|
|
309
|
-
else {
|
|
310
|
-
this.rebuildGeoMarkers();
|
|
311
|
-
this.redrawGeoLine();
|
|
312
|
-
}
|
|
391
|
+
this.afterGeoEdit();
|
|
313
392
|
return;
|
|
314
393
|
}
|
|
315
394
|
this.store.undo();
|
|
316
395
|
}
|
|
317
396
|
clearAll() {
|
|
397
|
+
// Drop any edit session first; the edited line is owned by the store and disposed by clear() below.
|
|
398
|
+
if (this.editingId) {
|
|
399
|
+
this.activeGeoLine = undefined;
|
|
400
|
+
this.editingId = null;
|
|
401
|
+
this.geoClosed = false;
|
|
402
|
+
}
|
|
318
403
|
const removed = this.store.clear();
|
|
319
404
|
removed.forEach((a) => {
|
|
320
405
|
if (a.object3D) {
|
|
@@ -327,13 +412,49 @@ export class SurfaceAnnotator {
|
|
|
327
412
|
this.clearActiveGeo();
|
|
328
413
|
}
|
|
329
414
|
deleteAnnotation(id) {
|
|
415
|
+
// If we're deleting the geodesic currently being edited, tear the edit session down first so
|
|
416
|
+
// its anchor handles aren't orphaned in the scene (the line itself is removed by reconcile).
|
|
417
|
+
if (this.editingId === id) {
|
|
418
|
+
this.removeGeoMarkers();
|
|
419
|
+
this.activeGeo = undefined;
|
|
420
|
+
this.activeGeoLine = undefined;
|
|
421
|
+
this.editingId = null;
|
|
422
|
+
this.geoClosed = false;
|
|
423
|
+
}
|
|
330
424
|
if (this.selectedId === id)
|
|
331
|
-
this.
|
|
425
|
+
this.setSelected(null);
|
|
332
426
|
this.store.remove(id);
|
|
427
|
+
this.emitInteraction();
|
|
333
428
|
}
|
|
334
429
|
selectAnnotation(id) {
|
|
430
|
+
var _a;
|
|
431
|
+
// Commit any open geodesic edit before the selection changes.
|
|
432
|
+
if (this.editingId && this.editingId !== id)
|
|
433
|
+
this.recommitGeodesic();
|
|
434
|
+
this.selectedId = id;
|
|
435
|
+
this.applySelection();
|
|
436
|
+
if (!id)
|
|
437
|
+
return;
|
|
438
|
+
const a = this.store.get(id);
|
|
439
|
+
if (!a)
|
|
440
|
+
return;
|
|
441
|
+
// Selecting an annotation switches to the tool that drew it, so you can carry on editing it
|
|
442
|
+
// (and a geodesic opens its anchor handles). points → Place point; contour → its draw mode.
|
|
443
|
+
const tool = a.type === "points" ? "point" : (_a = a.mode) !== null && _a !== void 0 ? _a : "freehand";
|
|
444
|
+
if (this.mode !== tool)
|
|
445
|
+
this.setMode(tool);
|
|
446
|
+
// With Geodesic now active, re-open the contour's anchors for editing.
|
|
447
|
+
if (this.mode === "geodesic")
|
|
448
|
+
this.tryOpenGeoEdit(id);
|
|
449
|
+
}
|
|
450
|
+
/** Change the selection from inside the engine (delete / deselect-on-navigate) and mirror it to the UI. */
|
|
451
|
+
setSelected(id) {
|
|
452
|
+
var _a, _b;
|
|
453
|
+
if (this.selectedId === id)
|
|
454
|
+
return;
|
|
335
455
|
this.selectedId = id;
|
|
336
456
|
this.applySelection();
|
|
457
|
+
(_b = (_a = this.o).onSelectionChange) === null || _b === void 0 ? void 0 : _b.call(_a, id);
|
|
337
458
|
}
|
|
338
459
|
/** Redraw the corresponding three object after a color change. */
|
|
339
460
|
refreshAnnotation(id) {
|
|
@@ -364,17 +485,11 @@ export class SurfaceAnnotator {
|
|
|
364
485
|
let count = 0;
|
|
365
486
|
let maxImported = this.seq;
|
|
366
487
|
for (const a of (_a = payload.annotations) !== null && _a !== void 0 ? _a : []) {
|
|
367
|
-
const verts = a.points.map((p) =>
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
nx = nrm.x;
|
|
373
|
-
ny = nrm.y;
|
|
374
|
-
nz = nrm.z;
|
|
375
|
-
}
|
|
376
|
-
return { x, y, z, nx, ny, nz, faceIndex: 0 };
|
|
377
|
-
});
|
|
488
|
+
const verts = a.points.map((p) => this.rawToVertex(p));
|
|
489
|
+
// Geodesic control points, if the payload carries them → keep the contour editable.
|
|
490
|
+
const anchors = a.mode === "geodesic" && a.anchors && a.anchors.length
|
|
491
|
+
? a.anchors.map((p) => this.rawToVertex(p))
|
|
492
|
+
: undefined;
|
|
378
493
|
if (a.type === "points") {
|
|
379
494
|
for (const v of verts) {
|
|
380
495
|
// Only reuse the provided id for a single-point entry (the round-trip case);
|
|
@@ -415,6 +530,7 @@ export class SurfaceAnnotator {
|
|
|
415
530
|
closed: a.closed,
|
|
416
531
|
visible: (_d = a.visible) !== null && _d !== void 0 ? _d : true,
|
|
417
532
|
vertices: verts,
|
|
533
|
+
anchors,
|
|
418
534
|
object3D: line,
|
|
419
535
|
});
|
|
420
536
|
const m = id.match(/^a(\d+)$/);
|
|
@@ -433,10 +549,19 @@ export class SurfaceAnnotator {
|
|
|
433
549
|
return this.drawLock || this.spaceHeld;
|
|
434
550
|
}
|
|
435
551
|
applyCameraGating() {
|
|
436
|
-
var _a, _b;
|
|
437
552
|
// Default is navigate (camera enabled). Drawing only when drawLock or spaceHeld.
|
|
438
553
|
this.o.controls.enabled = !this.drawing;
|
|
439
|
-
|
|
554
|
+
this.emitInteraction();
|
|
555
|
+
}
|
|
556
|
+
/** Emit the current interaction state (camera gating + whether a geodesic is editable). */
|
|
557
|
+
emitInteraction() {
|
|
558
|
+
var _a, _b;
|
|
559
|
+
(_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, {
|
|
560
|
+
drawing: this.drawing,
|
|
561
|
+
armed: this.armed,
|
|
562
|
+
locked: this.drawLock,
|
|
563
|
+
editing: !!this.activeGeo,
|
|
564
|
+
});
|
|
440
565
|
}
|
|
441
566
|
/** Reconcile the scene against store.list(): add missing objects, remove deleted ones (no dispose, kept for undo restore). */
|
|
442
567
|
reconcile() {
|
|
@@ -479,18 +604,33 @@ export class SurfaceAnnotator {
|
|
|
479
604
|
}
|
|
480
605
|
}
|
|
481
606
|
disposeObject(o) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
mat
|
|
488
|
-
|
|
489
|
-
|
|
607
|
+
// Traverse so composite handles (Group of rim + core meshes) are fully freed, not just the root.
|
|
608
|
+
o.traverse((c) => {
|
|
609
|
+
var _a, _b, _c;
|
|
610
|
+
const any = c;
|
|
611
|
+
(_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
612
|
+
const mat = any.material;
|
|
613
|
+
if (Array.isArray(mat))
|
|
614
|
+
mat.forEach((m) => m.dispose());
|
|
615
|
+
else
|
|
616
|
+
(_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
|
|
617
|
+
});
|
|
490
618
|
}
|
|
491
619
|
nextId() {
|
|
492
620
|
return "a" + ++this.seq;
|
|
493
621
|
}
|
|
622
|
+
/** Parse a raw exported point ([x,y,z] or [x,y,z,nx,ny,nz], local space) into an AnnotationVertex, recovering the normal from the nearest graph vertex when absent. */
|
|
623
|
+
rawToVertex(p) {
|
|
624
|
+
const [x, y, z] = p;
|
|
625
|
+
let nx = p[3], ny = p[4], nz = p[5];
|
|
626
|
+
if (nx === undefined || ny === undefined || nz === undefined) {
|
|
627
|
+
const nrm = this.graph.nearestNormalLocal(new THREE.Vector3(x, y, z));
|
|
628
|
+
nx = nrm.x;
|
|
629
|
+
ny = nrm.y;
|
|
630
|
+
nz = nrm.z;
|
|
631
|
+
}
|
|
632
|
+
return { x, y, z, nx, ny, nz, faceIndex: 0 };
|
|
633
|
+
}
|
|
494
634
|
hit(e) {
|
|
495
635
|
return raycastSurface(this.o.camera, this.o.container, this.o.mesh, e.clientX, e.clientY);
|
|
496
636
|
}
|
|
@@ -505,6 +645,55 @@ export class SurfaceAnnotator {
|
|
|
505
645
|
const t = e.target;
|
|
506
646
|
return !!t && t.tagName === "CANVAS" && this.o.container.contains(t);
|
|
507
647
|
}
|
|
648
|
+
/** After any anchor edit: refresh markers + line; drop/delete the contour when it becomes degenerate. */
|
|
649
|
+
afterGeoEdit() {
|
|
650
|
+
if (!this.activeGeo)
|
|
651
|
+
return;
|
|
652
|
+
if (this.editingId) {
|
|
653
|
+
// Editing a committed contour: fewer than 2 anchors → delete the whole annotation.
|
|
654
|
+
if (this.activeGeo.anchorCount < 2) {
|
|
655
|
+
const id = this.editingId;
|
|
656
|
+
this.removeGeoMarkers();
|
|
657
|
+
this.activeGeo = undefined;
|
|
658
|
+
this.activeGeoLine = undefined; // owned by the annotation; deleteAnnotation disposes it
|
|
659
|
+
this.editingId = null;
|
|
660
|
+
this.geoClosed = false;
|
|
661
|
+
this.deleteAnnotation(id);
|
|
662
|
+
this.emitInteraction();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
this.rebuildGeoMarkers();
|
|
666
|
+
this.redrawGeoLine();
|
|
667
|
+
this.emitInteraction();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// In-progress new geodesic.
|
|
671
|
+
if (this.activeGeo.anchorCount === 0) {
|
|
672
|
+
this.clearActiveGeo();
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
this.rebuildGeoMarkers();
|
|
676
|
+
this.redrawGeoLine();
|
|
677
|
+
}
|
|
678
|
+
this.emitInteraction();
|
|
679
|
+
}
|
|
680
|
+
/** Coalesce live drag redraws to one per animation frame (moveAnchorTo runs Dijkstra per move). */
|
|
681
|
+
scheduleGeoRedraw() {
|
|
682
|
+
if (this.geoRedrawScheduled)
|
|
683
|
+
return;
|
|
684
|
+
this.geoRedrawScheduled = true;
|
|
685
|
+
requestAnimationFrame(() => {
|
|
686
|
+
var _a;
|
|
687
|
+
this.geoRedrawScheduled = false;
|
|
688
|
+
this.rebuildGeoMarkers();
|
|
689
|
+
this.redrawGeoLine();
|
|
690
|
+
if (this.draggingAnchor >= 0) {
|
|
691
|
+
this.o.container.style.cursor = "grabbing";
|
|
692
|
+
// keep the grabbed handle emphasized through the live rebuilds
|
|
693
|
+
(_a = this.activeGeoMarkers[this.draggingAnchor]) === null || _a === void 0 ? void 0 : _a.scale.setScalar(1.4);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
508
697
|
/**
|
|
509
698
|
* Pick an in-progress anchor: return the index of the nearest anchor (screen pixel distance <
|
|
510
699
|
* tolerance), or -1 on a miss.
|
|
@@ -541,71 +730,30 @@ export class SurfaceAnnotator {
|
|
|
541
730
|
}
|
|
542
731
|
return best;
|
|
543
732
|
}
|
|
544
|
-
/**
|
|
733
|
+
/**
|
|
734
|
+
* Set the currently hovered anchor (-1 = none): scale it up as a highlight and show a "grab"
|
|
735
|
+
* cursor to signal it's draggable. Delete is now a right-click (no floating ✕ affordance).
|
|
736
|
+
*/
|
|
545
737
|
setGeoHover(idx) {
|
|
546
|
-
if (idx === this.hoveredGeoMarker)
|
|
547
|
-
if (idx >= 0)
|
|
548
|
-
this.positionGeoBadge(this.activeGeoMarkers[idx]);
|
|
738
|
+
if (idx === this.hoveredGeoMarker)
|
|
549
739
|
return;
|
|
550
|
-
}
|
|
551
740
|
const prev = this.activeGeoMarkers[this.hoveredGeoMarker];
|
|
552
741
|
if (prev)
|
|
553
742
|
prev.scale.setScalar(1);
|
|
554
743
|
this.hoveredGeoMarker = idx;
|
|
555
744
|
const cur = this.activeGeoMarkers[idx];
|
|
745
|
+
// Don't fight the "grabbing" cursor set during an active drag.
|
|
746
|
+
if (this.draggingAnchor >= 0)
|
|
747
|
+
return;
|
|
556
748
|
if (cur) {
|
|
557
|
-
cur.scale.setScalar(1.
|
|
558
|
-
this.
|
|
559
|
-
this.positionGeoBadge(cur);
|
|
560
|
-
this.o.container.style.cursor = "pointer";
|
|
749
|
+
cur.scale.setScalar(1.35);
|
|
750
|
+
this.o.container.style.cursor = "grab";
|
|
561
751
|
}
|
|
562
752
|
else {
|
|
563
|
-
if (this.geoHoverBadge)
|
|
564
|
-
this.geoHoverBadge.style.display = "none";
|
|
565
753
|
this.o.container.style.cursor = "";
|
|
566
754
|
}
|
|
567
755
|
}
|
|
568
|
-
/**
|
|
569
|
-
ensureGeoBadge() {
|
|
570
|
-
if (this.geoHoverBadge)
|
|
571
|
-
return this.geoHoverBadge;
|
|
572
|
-
const b = document.createElement("div");
|
|
573
|
-
b.textContent = "✕";
|
|
574
|
-
Object.assign(b.style, {
|
|
575
|
-
position: "absolute",
|
|
576
|
-
width: "16px",
|
|
577
|
-
height: "16px",
|
|
578
|
-
borderRadius: "50%",
|
|
579
|
-
background: "#ff5d6c",
|
|
580
|
-
color: "#fff",
|
|
581
|
-
font: "700 10px/1 system-ui, sans-serif",
|
|
582
|
-
display: "none",
|
|
583
|
-
alignItems: "center",
|
|
584
|
-
justifyContent: "center",
|
|
585
|
-
pointerEvents: "none",
|
|
586
|
-
zIndex: "15",
|
|
587
|
-
boxShadow: "0 2px 6px rgba(0,0,0,.45)",
|
|
588
|
-
transform: "translate(-50%, -50%)",
|
|
589
|
-
left: "0px",
|
|
590
|
-
top: "0px",
|
|
591
|
-
});
|
|
592
|
-
this.o.container.appendChild(b);
|
|
593
|
-
this.geoHoverBadge = b;
|
|
594
|
-
return b;
|
|
595
|
-
}
|
|
596
|
-
/** Position the ✕ badge at the anchor's screen projection (dead center, i.e. where the cursor hovers, to avoid ambiguity). */
|
|
597
|
-
positionGeoBadge(marker) {
|
|
598
|
-
if (!marker)
|
|
599
|
-
return;
|
|
600
|
-
const b = this.ensureGeoBadge();
|
|
601
|
-
this._projV.copy(marker.position).project(this.o.camera);
|
|
602
|
-
const rect = this.o.container.getBoundingClientRect();
|
|
603
|
-
const x = (this._projV.x * 0.5 + 0.5) * rect.width;
|
|
604
|
-
const y = (-this._projV.y * 0.5 + 0.5) * rect.height;
|
|
605
|
-
b.style.left = `${x}px`;
|
|
606
|
-
b.style.top = `${y}px`;
|
|
607
|
-
}
|
|
608
|
-
/** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and click to cancel). */
|
|
756
|
+
/** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and grab). */
|
|
609
757
|
rebuildGeoMarkers() {
|
|
610
758
|
this.setGeoHover(-1);
|
|
611
759
|
for (const m of this.activeGeoMarkers) {
|
|
@@ -616,18 +764,20 @@ export class SurfaceAnnotator {
|
|
|
616
764
|
if (!this.activeGeo)
|
|
617
765
|
return;
|
|
618
766
|
for (const v of this.activeGeo.getAnchorLocals()) {
|
|
619
|
-
const marker =
|
|
767
|
+
const marker = makeAnchorHandle(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.7);
|
|
620
768
|
this.o.scene.add(marker);
|
|
621
769
|
this.activeGeoMarkers.push(marker);
|
|
622
770
|
}
|
|
623
771
|
}
|
|
624
|
-
/** Redraw the
|
|
772
|
+
/** Redraw the active geodesic from its current anchors (honoring the closed state); when editing a committed contour, also keep the stored annotation data in sync so export stays correct. */
|
|
625
773
|
redrawGeoLine() {
|
|
626
774
|
if (!this.activeGeo)
|
|
627
775
|
return;
|
|
628
|
-
const
|
|
776
|
+
const closed = this.geoClosed;
|
|
777
|
+
const verts = this.activeGeo.buildVertices(closed);
|
|
629
778
|
if (verts.length < 2) {
|
|
630
|
-
|
|
779
|
+
// Only dispose the line for an in-progress geodesic; when editing, the line belongs to the annotation.
|
|
780
|
+
if (this.activeGeoLine && !this.editingId) {
|
|
631
781
|
this.o.scene.remove(this.activeGeoLine);
|
|
632
782
|
this.disposeObject(this.activeGeoLine);
|
|
633
783
|
this.activeGeoLine = undefined;
|
|
@@ -635,11 +785,20 @@ export class SurfaceAnnotator {
|
|
|
635
785
|
return;
|
|
636
786
|
}
|
|
637
787
|
if (!this.activeGeoLine) {
|
|
638
|
-
this.activeGeoLine = makeContourLine(verts, this.geodesicColor,
|
|
788
|
+
this.activeGeoLine = makeContourLine(verts, this.geodesicColor, closed, this.o.container, this.epsilon, this.o.mesh);
|
|
639
789
|
this.o.scene.add(this.activeGeoLine);
|
|
640
790
|
}
|
|
641
791
|
else {
|
|
642
|
-
updateContourLine(this.activeGeoLine, verts,
|
|
792
|
+
updateContourLine(this.activeGeoLine, verts, closed, this.epsilon, this.o.mesh);
|
|
793
|
+
}
|
|
794
|
+
// Live-sync the committed annotation's data while it is being edited.
|
|
795
|
+
if (this.editingId) {
|
|
796
|
+
const a = this.store.get(this.editingId);
|
|
797
|
+
if (a) {
|
|
798
|
+
a.vertices = verts;
|
|
799
|
+
a.closed = closed;
|
|
800
|
+
a.anchors = this.activeGeo.getAnchorLocals();
|
|
801
|
+
}
|
|
643
802
|
}
|
|
644
803
|
}
|
|
645
804
|
/** Discard the in-progress geodesic: remove and dispose the line and all anchors. */
|
|
@@ -656,6 +815,7 @@ export class SurfaceAnnotator {
|
|
|
656
815
|
}
|
|
657
816
|
this.activeGeoMarkers = [];
|
|
658
817
|
this.activeGeo = undefined;
|
|
818
|
+
this.emitInteraction();
|
|
659
819
|
}
|
|
660
820
|
/** Remove all anchor spheres of the in-progress geodesic (called after committing: only the line remains). */
|
|
661
821
|
removeGeoMarkers() {
|
|
@@ -702,6 +862,7 @@ export class SurfaceAnnotator {
|
|
|
702
862
|
closed,
|
|
703
863
|
visible: true,
|
|
704
864
|
vertices: verts,
|
|
865
|
+
anchors: this.activeGeo.getAnchorLocals(),
|
|
705
866
|
object3D: this.activeGeoLine,
|
|
706
867
|
};
|
|
707
868
|
this.store.add(ann);
|
|
@@ -714,6 +875,61 @@ export class SurfaceAnnotator {
|
|
|
714
875
|
this.removeGeoMarkers();
|
|
715
876
|
this.activeGeo = undefined;
|
|
716
877
|
this.activeGeoLine = undefined;
|
|
878
|
+
this.geoClosed = false;
|
|
879
|
+
this.emitInteraction();
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Re-open a committed geodesic contour for editing: rebuild its anchors (from the stored positions)
|
|
883
|
+
* as draggable markers and reuse its line for live updates. No-op for non-geodesic / anchorless
|
|
884
|
+
* annotations. The edit session ends on Enter / mode-switch / selecting another / Esc.
|
|
885
|
+
*/
|
|
886
|
+
tryOpenGeoEdit(id) {
|
|
887
|
+
var _a;
|
|
888
|
+
if (this.editingId === id)
|
|
889
|
+
return;
|
|
890
|
+
const a = this.store.get(id);
|
|
891
|
+
if (!a || a.type !== "contour" || a.mode !== "geodesic" || !a.anchors || a.anchors.length < 2) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
// Discard a half-drawn (uncommitted) geodesic before entering the edit session.
|
|
895
|
+
if (this.activeGeo && !this.editingId)
|
|
896
|
+
this.clearActiveGeo();
|
|
897
|
+
const indices = a.anchors.map((v) => this.graph.nearestVertex(new THREE.Vector3(v.x, v.y, v.z)));
|
|
898
|
+
this.activeGeo = GeodesicContour.fromAnchors(this.graph, this.o.mesh, indices);
|
|
899
|
+
this.activeGeoLine = (_a = a.object3D) !== null && _a !== void 0 ? _a : undefined;
|
|
900
|
+
this.editingId = id;
|
|
901
|
+
this.geoClosed = a.closed;
|
|
902
|
+
this.rebuildGeoMarkers();
|
|
903
|
+
this.redrawGeoLine();
|
|
904
|
+
this.emitInteraction();
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Finalize an edit session on a committed geodesic: the annotation data was kept in sync live
|
|
908
|
+
* (redrawGeoLine), so here we just remove the edit markers, notify subscribers, and end the
|
|
909
|
+
* session — or delete the annotation if it was reduced below 2 anchors.
|
|
910
|
+
*/
|
|
911
|
+
recommitGeodesic() {
|
|
912
|
+
const id = this.editingId;
|
|
913
|
+
if (!id)
|
|
914
|
+
return;
|
|
915
|
+
if (!this.activeGeo || this.activeGeo.anchorCount < 2) {
|
|
916
|
+
this.removeGeoMarkers();
|
|
917
|
+
this.activeGeo = undefined;
|
|
918
|
+
this.activeGeoLine = undefined; // owned by the annotation
|
|
919
|
+
this.editingId = null;
|
|
920
|
+
this.geoClosed = false;
|
|
921
|
+
this.deleteAnnotation(id);
|
|
922
|
+
this.emitInteraction();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
this.redrawGeoLine(); // final data sync
|
|
926
|
+
this.removeGeoMarkers();
|
|
927
|
+
this.activeGeo = undefined;
|
|
928
|
+
this.activeGeoLine = undefined; // owned by the annotation, do not dispose
|
|
929
|
+
this.editingId = null;
|
|
930
|
+
this.geoClosed = false;
|
|
931
|
+
this.store.touch();
|
|
932
|
+
this.emitInteraction();
|
|
717
933
|
}
|
|
718
934
|
isTypingTarget(e) {
|
|
719
935
|
const t = e.target;
|
|
@@ -723,16 +939,14 @@ export class SurfaceAnnotator {
|
|
|
723
939
|
return tag === "INPUT" || tag === "TEXTAREA" || t.isContentEditable;
|
|
724
940
|
}
|
|
725
941
|
dispose() {
|
|
726
|
-
var _a;
|
|
727
942
|
this.clearActiveGeo();
|
|
728
|
-
(_a = this.geoHoverBadge) === null || _a === void 0 ? void 0 : _a.remove();
|
|
729
|
-
this.geoHoverBadge = undefined;
|
|
730
943
|
window.removeEventListener("pointerdown", this.onPointerDown, true);
|
|
731
944
|
window.removeEventListener("pointermove", this.onPointerMove, true);
|
|
732
945
|
window.removeEventListener("pointerup", this.onPointerUp, true);
|
|
733
946
|
window.removeEventListener("keydown", this.onKeyDown);
|
|
734
947
|
window.removeEventListener("keyup", this.onKeyUp);
|
|
735
948
|
window.removeEventListener("resize", this.onResize);
|
|
949
|
+
window.removeEventListener("contextmenu", this.onContextMenu, true);
|
|
736
950
|
}
|
|
737
951
|
}
|
|
738
952
|
SurfaceAnnotator.TAP_MS = 250;
|