copper3d 3.6.8 → 3.6.10

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.
@@ -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 anchors of the in-progress geodesic
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
- // First check whether an existing anchor was clicked cancel that point (any point can be canceled).
98
- const pick = this.pickGeoMarker(e);
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.rebuildGeoMarkers();
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
- // Geodesic in progressfinish and close it; otherwise close the most recent freehand line.
211
- if (this.activeGeo)
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, discard the in-progress geodesic not yet committed with Enter (clear leftover anchors and line).
285
- if (m !== "geodesic" && this.activeGeo)
286
- this.clearActiveGeo();
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
- // Geodesic in progress → undo the most recent anchor edit (both adding and canceling a point can be rolled back);
304
- // otherwise undo the most recently committed annotation.
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
- if (this.activeGeo.anchorCount === 0)
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.selectedId = null;
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
- const [x, y, z] = p;
369
- let nx = p[3], ny = p[4], nz = p[5];
370
- if (nx === undefined || ny === undefined || nz === undefined) {
371
- const nrm = this.graph.nearestNormalLocal(new THREE.Vector3(x, y, z));
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
- (_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, { drawing: this.drawing, armed: this.armed, locked: this.drawLock });
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
- var _a, _b, _c;
483
- const any = o;
484
- (_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
485
- const mat = any.material;
486
- if (Array.isArray(mat))
487
- mat.forEach((m) => m.dispose());
488
- else
489
- (_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
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
- /** Set the currently hovered anchor (-1 = none): update highlight scale, cursor, and the "✕ (cancel)" floating badge. */
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.3);
558
- this.ensureGeoBadge().style.display = "flex";
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
- /** Lazily create the floating badge (appended inside the container, pointer-events:none so it doesn't block clicks). */
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 = makePointMarker(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.15);
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 in-progress geodesic from the current anchors (not closed); remove the line when fewer than two points. */
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 verts = this.activeGeo.buildVertices(false);
776
+ const closed = this.geoClosed;
777
+ const verts = this.activeGeo.buildVertices(closed);
629
778
  if (verts.length < 2) {
630
- if (this.activeGeoLine) {
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, false, this.o.container, this.epsilon, this.o.mesh);
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, false, this.epsilon, this.o.mesh);
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;