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.
Files changed (26) hide show
  1. package/dist/Scene/copperScene.d.ts +1 -1
  2. package/dist/Scene/copperScene.js +4 -1
  3. package/dist/Scene/copperScene.js.map +1 -1
  4. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +41 -19
  5. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js +329 -115
  6. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js.map +1 -1
  7. package/dist/Utils/surfaceAnnotation/annotationStore.d.ts +3 -10
  8. package/dist/Utils/surfaceAnnotation/annotationStore.js +21 -10
  9. package/dist/Utils/surfaceAnnotation/annotationStore.js.map +1 -1
  10. package/dist/Utils/surfaceAnnotation/geodesicContour.d.ts +26 -0
  11. package/dist/Utils/surfaceAnnotation/geodesicContour.js +101 -1
  12. package/dist/Utils/surfaceAnnotation/geodesicContour.js.map +1 -1
  13. package/dist/Utils/surfaceAnnotation/pointMarkers.d.ts +8 -0
  14. package/dist/Utils/surfaceAnnotation/pointMarkers.js +19 -0
  15. package/dist/Utils/surfaceAnnotation/pointMarkers.js.map +1 -1
  16. package/dist/Utils/surfaceAnnotation/types.d.ts +6 -0
  17. package/dist/Utils/surfaceAnnotation/types.js.map +1 -1
  18. package/dist/bundle.esm.js +475 -129
  19. package/dist/bundle.umd.js +475 -129
  20. package/dist/types/Scene/copperScene.d.ts +1 -1
  21. package/dist/types/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +41 -19
  22. package/dist/types/Utils/surfaceAnnotation/annotationStore.d.ts +3 -10
  23. package/dist/types/Utils/surfaceAnnotation/geodesicContour.d.ts +26 -0
  24. package/dist/types/Utils/surfaceAnnotation/pointMarkers.d.ts +8 -0
  25. package/dist/types/Utils/surfaceAnnotation/types.d.ts +6 -0
  26. package/package.json +1 -1
@@ -59654,6 +59654,25 @@ void main() {
59654
59654
  m.position.copy(p.addScaledVector(n, radius * 0.5));
59655
59655
  m.renderOrder = 999;
59656
59656
  return m;
59657
+ }
59658
+ /**
59659
+ * Create a draggable ANCHOR HANDLE (for the geodesic editor): a white outer rim around a
59660
+ * tool-colored core, so the handle stands out against both the (same-colored) contour line and the
59661
+ * grey surface. Returned as a Group; scale it to show hover/selection feedback. depthTest stays ON
59662
+ * (occluded by the model on the far side) and it's lifted a touch more than a plain marker so it
59663
+ * sits above the thickened line rather than sinking into it.
59664
+ */
59665
+ function makeAnchorHandle(v, mesh, coreColor, radius) {
59666
+ const g = new Group();
59667
+ const rim = new Mesh(new SphereGeometry(radius, 16, 16), new MeshBasicMaterial({ color: "#ffffff" }));
59668
+ rim.renderOrder = 1000;
59669
+ const core = new Mesh(new SphereGeometry(radius * 0.6, 16, 16), new MeshBasicMaterial({ color: coreColor }));
59670
+ core.renderOrder = 1001;
59671
+ g.add(rim);
59672
+ g.add(core);
59673
+ const { p, n } = localVertexToWorld(v, mesh);
59674
+ g.position.copy(p.addScaledVector(n, radius * 0.7));
59675
+ return g;
59657
59676
  }
59658
59677
 
59659
59678
  /**
@@ -61281,6 +61300,60 @@ void main() {
61281
61300
  }
61282
61301
  this.anchors.push(v);
61283
61302
  }
61303
+ /** Move the anchor at `index` to the vertex nearest `localHitPoint`, rebuilding adjacent segments live. */
61304
+ moveAnchorTo(index, localHitPoint) {
61305
+ if (index < 0 || index >= this.anchors.length)
61306
+ return;
61307
+ this.snapshot();
61308
+ this.anchors[index] = this.graph.nearestVertex(localHitPoint);
61309
+ this.rebuildSegments();
61310
+ }
61311
+ /** Anchor vertex indices (persisted on a committed geodesic so it can be re-opened for editing). */
61312
+ getAnchorIndices() {
61313
+ return this.anchors.slice();
61314
+ }
61315
+ /** Rebuild an editable contour from stored anchor vertex indices (re-opening a committed geodesic). */
61316
+ static fromAnchors(graph, mesh, indices) {
61317
+ const gc = new GeodesicContour(graph, mesh);
61318
+ gc.anchors = indices.slice();
61319
+ gc.rebuildSegments();
61320
+ return gc;
61321
+ }
61322
+ /** Insert a new anchor (snapped to the nearest vertex) at position `index`, rebuilding segments. */
61323
+ insertAnchorAt(index, localHitPoint) {
61324
+ const v = this.graph.nearestVertex(localHitPoint);
61325
+ this.snapshot();
61326
+ const i = Math.max(0, Math.min(index, this.anchors.length));
61327
+ this.anchors.splice(i, 0, v);
61328
+ this.rebuildSegments();
61329
+ }
61330
+ /**
61331
+ * Which anchor interval's surface path passes closest to `worldPoint` — i.e. the edge the user
61332
+ * clicked on, so a new anchor can be inserted there. Returns the interval index (insert after that
61333
+ * anchor, i.e. at index+1), or -1 when there are fewer than 2 anchors or the click is farther than
61334
+ * `maxDist` (world units) from every edge. For a closed contour the trailing closing edge
61335
+ * (last→first) is considered and maps to appending at the end.
61336
+ */
61337
+ nearestInsertIndex(worldPoint, matrixWorld, closed, maxDist) {
61338
+ if (this.anchors.length < 2)
61339
+ return -1;
61340
+ const segs = this.segments.slice();
61341
+ if (closed && this.anchors.length > 2) {
61342
+ segs.push(this.graph.shortestPath(this.anchors[this.anchors.length - 1], this.anchors[0]));
61343
+ }
61344
+ let best = -1;
61345
+ let bestD = maxDist;
61346
+ for (let si = 0; si < segs.length; si++) {
61347
+ for (const vi of segs[si]) {
61348
+ const d = this.graph.vertexWorld(vi, matrixWorld).distanceTo(worldPoint);
61349
+ if (d < bestD) {
61350
+ bestD = d;
61351
+ best = si;
61352
+ }
61353
+ }
61354
+ }
61355
+ return best;
61356
+ }
61284
61357
  /** Remove the anchor at the given index and recompute adjacent segments (allows canceling any middle point). */
61285
61358
  removeAnchorAt(index) {
61286
61359
  if (index < 0 || index >= this.anchors.length)
@@ -61332,7 +61405,52 @@ void main() {
61332
61405
  idxPath.push(this.anchors[0]);
61333
61406
  }
61334
61407
  // The graph geometry is already local space, so emit local vertices directly (world is derived at render time).
61335
- return idxPath.map((i) => this.graph.vertexLocal(i));
61408
+ return this.smoothPath(idxPath, closed);
61409
+ }
61410
+ /**
61411
+ * Relax the stitched shortest-path polyline so it reads as a smooth curve instead of the raw
61412
+ * voxel staircase, WITHOUT changing the point count (keeps editing/index mapping simple) and
61413
+ * WITHOUT moving the anchors (they stay exact control points). A few Laplacian passes pull each
61414
+ * interior point toward the midpoint of its neighbours; anchors and (for open paths) the two
61415
+ * endpoints are pinned. Normals are carried over from each point's original graph vertex — the
61416
+ * points only shift slightly and the line already floats above the surface by `epsilon`, so the
61417
+ * approximation is invisible and avoids an O(V) nearest-vertex scan per point on every redraw.
61418
+ */
61419
+ smoothPath(idxPath, closed) {
61420
+ const base = idxPath.map((i) => this.graph.vertexLocal(i));
61421
+ const n = base.length;
61422
+ if (n <= 2)
61423
+ return base;
61424
+ const anchorSet = new Set(this.anchors);
61425
+ const pinned = idxPath.map((i) => anchorSet.has(i));
61426
+ const P = base.map((v) => new Vector3(v.x, v.y, v.z));
61427
+ const ITER = 2;
61428
+ const LAMBDA = 0.5;
61429
+ const mid = new Vector3();
61430
+ for (let it = 0; it < ITER; it++) {
61431
+ const next = P.map((p) => p.clone());
61432
+ for (let k = 0; k < n; k++) {
61433
+ if (pinned[k])
61434
+ continue;
61435
+ if (!closed && (k === 0 || k === n - 1))
61436
+ continue;
61437
+ const a = P[(k - 1 + n) % n];
61438
+ const b = P[(k + 1) % n];
61439
+ mid.addVectors(a, b).multiplyScalar(0.5).sub(P[k]);
61440
+ next[k].copy(P[k]).addScaledVector(mid, LAMBDA);
61441
+ }
61442
+ for (let k = 0; k < n; k++)
61443
+ P[k].copy(next[k]);
61444
+ }
61445
+ return P.map((p, k) => ({
61446
+ x: p.x,
61447
+ y: p.y,
61448
+ z: p.z,
61449
+ nx: base[k].nx,
61450
+ ny: base[k].ny,
61451
+ nz: base[k].nz,
61452
+ faceIndex: -1,
61453
+ }));
61336
61454
  }
61337
61455
  }
61338
61456
 
@@ -61417,6 +61535,10 @@ void main() {
61417
61535
  this.notify();
61418
61536
  }
61419
61537
  }
61538
+ /** Notify subscribers after an in-place mutation of an existing annotation (e.g. geodesic edit re-commit). */
61539
+ touch() {
61540
+ this.notify();
61541
+ }
61420
61542
  toJSON(modelName, mesh, opts = {}) {
61421
61543
  var _a;
61422
61544
  const space = (_a = opts.space) !== null && _a !== void 0 ? _a : "local";
@@ -61434,16 +61556,23 @@ void main() {
61434
61556
  model: modelName,
61435
61557
  exportedAt: new Date().toISOString(),
61436
61558
  space,
61437
- annotations: this.items.map((a) => ({
61438
- id: a.id,
61439
- type: a.type,
61440
- mode: a.mode,
61441
- label: a.label,
61442
- color: a.color,
61443
- closed: a.closed,
61444
- visible: a.visible,
61445
- points: a.vertices.map(toPt),
61446
- })),
61559
+ annotations: this.items.map((a) => {
61560
+ const out = {
61561
+ id: a.id,
61562
+ type: a.type,
61563
+ mode: a.mode,
61564
+ label: a.label,
61565
+ color: a.color,
61566
+ closed: a.closed,
61567
+ visible: a.visible,
61568
+ points: a.vertices.map(toPt),
61569
+ };
61570
+ // Geodesic contours also carry their control points so a round-tripped import stays editable.
61571
+ if (a.mode === "geodesic" && a.anchors && a.anchors.length) {
61572
+ out.anchors = a.anchors.map(toPt);
61573
+ }
61574
+ return out;
61575
+ }),
61447
61576
  };
61448
61577
  }
61449
61578
  }
@@ -61470,11 +61599,20 @@ void main() {
61470
61599
  this.managed = new Set();
61471
61600
  this.seq = 0;
61472
61601
  this.selectedId = null;
61473
- this.activeGeoMarkers = []; // visible anchors of the in-progress geodesic
61602
+ this.activeGeoMarkers = []; // visible anchor handles of the active geodesic
61474
61603
  this.geoRay = new Raycaster();
61475
61604
  this.geoNdc = new Vector2();
61476
61605
  this.hoveredGeoMarker = -1;
61606
+ this.draggingAnchor = -1; // anchor index currently being dragged (-1 = none)
61607
+ this.geoRedrawScheduled = false; // rAF coalescing flag for live drag redraw
61608
+ this.editingId = null; // committed geodesic re-opened for editing (Task 1.5)
61609
+ this.geoClosed = false; // whether the active/edited geodesic is a closed loop
61477
61610
  this._projV = new Vector3();
61611
+ /** Block the OS right-click menu when the pointer is over the WebGL canvas (not the HTML panels). */
61612
+ this.onContextMenu = (e) => {
61613
+ if (this.insideContainer(e))
61614
+ e.preventDefault();
61615
+ };
61478
61616
  /** Update the pixel resolution of all fat lines on window resize, otherwise line width distorts. */
61479
61617
  this.onResize = () => {
61480
61618
  const w = this.o.container.clientWidth;
@@ -61491,10 +61629,50 @@ void main() {
61491
61629
  (this.activeGeoLine.material.resolution.set(w, h));
61492
61630
  };
61493
61631
  this.onPointerDown = (e) => {
61494
- if (e.button !== 0)
61495
- return; // left button only
61496
61632
  if (!this.insideContainer(e))
61497
61633
  return;
61634
+ // Geodesic anchor editing (drag to move / right-click to delete) is available whenever an
61635
+ // editable geodesic exists — even outside draw-lock — because it requires precisely hitting an
61636
+ // existing anchor. This lets the user rotate the model freely and still grab a point. Handled
61637
+ // BEFORE the button/drawing gates below.
61638
+ if (this.armed === "geodesic" && this.activeGeo) {
61639
+ if (e.button === 2) {
61640
+ const pick = this.pickGeoMarker(e);
61641
+ if (pick >= 0) {
61642
+ e.preventDefault();
61643
+ this.activeGeo.removeAnchorAt(pick);
61644
+ this.afterGeoEdit();
61645
+ return;
61646
+ }
61647
+ }
61648
+ else if (e.button === 0) {
61649
+ const pick = this.pickGeoMarker(e);
61650
+ if (pick >= 0) {
61651
+ e.preventDefault();
61652
+ this.draggingAnchor = pick;
61653
+ this.pointerDown = true;
61654
+ this.o.controls.enabled = false; // suppress camera while dragging the anchor
61655
+ this.o.container.style.cursor = "grabbing";
61656
+ return;
61657
+ }
61658
+ // Missed every anchor → if the click lands on the line body, insert a new anchor there
61659
+ // (works in an edit session or an in-progress geodesic, regardless of draw-lock).
61660
+ if (this.activeGeo.anchorCount >= 2) {
61661
+ const h = this.hit(e);
61662
+ if (h) {
61663
+ const insertAt = this.activeGeo.nearestInsertIndex(h.point, this.o.mesh.matrixWorld, this.geoClosed, this.minGap * 4);
61664
+ if (insertAt >= 0) {
61665
+ e.preventDefault();
61666
+ this.activeGeo.insertAnchorAt(insertAt + 1, this.o.mesh.worldToLocal(h.point.clone()));
61667
+ this.afterGeoEdit();
61668
+ return;
61669
+ }
61670
+ }
61671
+ }
61672
+ }
61673
+ }
61674
+ if (e.button !== 0)
61675
+ return; // the remaining tools are left-button only
61498
61676
  this.pointerDown = true;
61499
61677
  // Only act with the armed tool when in drawing mode (drawLock or spaceHeld).
61500
61678
  // When not drawing, navigation is the default — pointer events go to camera controls.
@@ -61534,19 +61712,8 @@ void main() {
61534
61712
  return;
61535
61713
  }
61536
61714
  if (activeTool === "geodesic") {
61537
- // First check whether an existing anchor was clicked cancel that point (any point can be canceled).
61538
- const pick = this.pickGeoMarker(e);
61539
- if (pick >= 0 && this.activeGeo) {
61540
- this.activeGeo.removeAnchorAt(pick);
61541
- if (this.activeGeo.anchorCount === 0)
61542
- this.clearActiveGeo();
61543
- else {
61544
- this.rebuildGeoMarkers();
61545
- this.redrawGeoLine();
61546
- }
61547
- return;
61548
- }
61549
- // Otherwise drop a new anchor on the surface.
61715
+ // Anchor grab/delete was already handled above; here we only drop a NEW anchor on the surface
61716
+ // (clicks on an existing anchor never reach this point).
61550
61717
  const h = this.hit(e);
61551
61718
  if (!h)
61552
61719
  return;
@@ -61555,11 +61722,20 @@ void main() {
61555
61722
  }
61556
61723
  const local = this.o.mesh.worldToLocal(h.point.clone());
61557
61724
  this.activeGeo.addAnchor(local);
61558
- this.rebuildGeoMarkers();
61559
- this.redrawGeoLine();
61725
+ this.afterGeoEdit();
61560
61726
  }
61561
61727
  };
61562
61728
  this.onPointerMove = (e) => {
61729
+ // Live anchor drag: move the grabbed anchor to the surface point under the cursor and redraw
61730
+ // (both adjacent segments recompute inside moveAnchorTo). Takes priority over everything else.
61731
+ if (this.draggingAnchor >= 0) {
61732
+ const h = this.hit(e);
61733
+ if (h && this.activeGeo) {
61734
+ this.activeGeo.moveAnchorTo(this.draggingAnchor, this.o.mesh.worldToLocal(h.point.clone()));
61735
+ this.scheduleGeoRedraw();
61736
+ }
61737
+ return;
61738
+ }
61563
61739
  // Track if user dragged while space was held (so a hold-drag is not treated as a tap).
61564
61740
  if (this.spaceHeld && this.pointerDown) {
61565
61741
  this.spaceDragged = true;
@@ -61591,6 +61767,15 @@ void main() {
61591
61767
  }
61592
61768
  };
61593
61769
  this.onPointerUp = () => {
61770
+ // Finish an anchor drag: restore camera gating + cursor. (Re-commit of an edited committed
61771
+ // contour is wired in Task 1.5.)
61772
+ if (this.draggingAnchor >= 0) {
61773
+ this.draggingAnchor = -1;
61774
+ this.pointerDown = false;
61775
+ this.o.container.style.cursor = this.hoveredGeoMarker >= 0 ? "grab" : "";
61776
+ this.applyCameraGating();
61777
+ return;
61778
+ }
61594
61779
  if (!this.pointerDown)
61595
61780
  return;
61596
61781
  this.pointerDown = false;
@@ -61632,6 +61817,18 @@ void main() {
61632
61817
  return;
61633
61818
  }
61634
61819
  if (e.key === "Escape") {
61820
+ // First Esc clears an active selection / in-progress geodesic while staying in the current
61821
+ // tool; a second Esc (nothing selected) returns to Navigate.
61822
+ if (this.selectedId || this.activeGeo) {
61823
+ if (this.editingId)
61824
+ this.recommitGeodesic();
61825
+ else if (this.activeGeo)
61826
+ this.clearActiveGeo();
61827
+ this.setSelected(null);
61828
+ this.drawLock = false;
61829
+ this.applyCameraGating();
61830
+ return;
61831
+ }
61635
61832
  this.drawLock = false;
61636
61833
  this.setMode("navigate");
61637
61834
  return;
@@ -61647,8 +61844,11 @@ void main() {
61647
61844
  return;
61648
61845
  }
61649
61846
  if (e.key === "Enter") {
61650
- // Geodesic in progressfinish and close it; otherwise close the most recent freehand line.
61651
- if (this.activeGeo)
61847
+ // Editing a committed geodesic finalize the edit; a fresh geodesic in progress finish and
61848
+ // close it; otherwise close the most recent freehand line.
61849
+ if (this.editingId)
61850
+ this.recommitGeodesic();
61851
+ else if (this.activeGeo)
61652
61852
  this.finishGeodesic();
61653
61853
  else
61654
61854
  this.closeLastContour();
@@ -61701,6 +61901,10 @@ void main() {
61701
61901
  window.addEventListener("keydown", this.onKeyDown);
61702
61902
  window.addEventListener("keyup", this.onKeyUp);
61703
61903
  window.addEventListener("resize", this.onResize);
61904
+ // Suppress the browser's native context menu over the WebGL canvas in EVERY mode: right-click
61905
+ // there is used for camera pan (and, in geodesic mode, delete-anchor), so the OS menu must
61906
+ // never appear. Guarded by insideContainer → right-clicking the HTML panels keeps its menu.
61907
+ window.addEventListener("contextmenu", this.onContextMenu, true);
61704
61908
  this.applyCameraGating();
61705
61909
  }
61706
61910
  get freehandColor() {
@@ -61721,14 +61925,22 @@ void main() {
61721
61925
  }
61722
61926
  setMode(m) {
61723
61927
  var _a, _b;
61724
- // When leaving geodesic mode, discard the in-progress geodesic not yet committed with Enter (clear leftover anchors and line).
61725
- if (m !== "geodesic" && this.activeGeo)
61726
- this.clearActiveGeo();
61928
+ // When leaving geodesic mode: commit an in-progress EDIT of a committed contour, or discard a
61929
+ // not-yet-committed new geodesic (clear leftover anchors and line).
61930
+ if (m !== "geodesic") {
61931
+ if (this.editingId)
61932
+ this.recommitGeodesic();
61933
+ else if (this.activeGeo)
61934
+ this.clearActiveGeo();
61935
+ }
61727
61936
  this.mode = m;
61728
61937
  // Record armed tool when choosing a drawing mode (not navigate).
61729
61938
  // Camera gating is NOT changed here — the user must use Space to enter drawing.
61730
61939
  if (m !== "navigate")
61731
61940
  this.armed = m;
61941
+ // Returning to Navigate clears the selection too (put everything away).
61942
+ if (m === "navigate")
61943
+ this.setSelected(null);
61732
61944
  this.applyCameraGating();
61733
61945
  (_b = (_a = this.o).onModeChange) === null || _b === void 0 ? void 0 : _b.call(_a, m);
61734
61946
  }
@@ -61740,21 +61952,23 @@ void main() {
61740
61952
  return this.store.list();
61741
61953
  }
61742
61954
  undo() {
61743
- // Geodesic in progress → undo the most recent anchor edit (both adding and canceling a point can be rolled back);
61744
- // otherwise undo the most recently committed annotation.
61955
+ // A geodesic being drawn/edited → undo the most recent anchor edit. afterGeoEdit() handles both
61956
+ // the in-progress and committed-edit cases uniformly, including degenerate cleanup (so undo can't
61957
+ // dispose a committed line or leave a stale edit session).
61745
61958
  if (this.activeGeo && this.activeGeo.canUndo()) {
61746
61959
  this.activeGeo.undoEdit();
61747
- if (this.activeGeo.anchorCount === 0)
61748
- this.clearActiveGeo();
61749
- else {
61750
- this.rebuildGeoMarkers();
61751
- this.redrawGeoLine();
61752
- }
61960
+ this.afterGeoEdit();
61753
61961
  return;
61754
61962
  }
61755
61963
  this.store.undo();
61756
61964
  }
61757
61965
  clearAll() {
61966
+ // Drop any edit session first; the edited line is owned by the store and disposed by clear() below.
61967
+ if (this.editingId) {
61968
+ this.activeGeoLine = undefined;
61969
+ this.editingId = null;
61970
+ this.geoClosed = false;
61971
+ }
61758
61972
  const removed = this.store.clear();
61759
61973
  removed.forEach((a) => {
61760
61974
  if (a.object3D) {
@@ -61767,13 +61981,49 @@ void main() {
61767
61981
  this.clearActiveGeo();
61768
61982
  }
61769
61983
  deleteAnnotation(id) {
61984
+ // If we're deleting the geodesic currently being edited, tear the edit session down first so
61985
+ // its anchor handles aren't orphaned in the scene (the line itself is removed by reconcile).
61986
+ if (this.editingId === id) {
61987
+ this.removeGeoMarkers();
61988
+ this.activeGeo = undefined;
61989
+ this.activeGeoLine = undefined;
61990
+ this.editingId = null;
61991
+ this.geoClosed = false;
61992
+ }
61770
61993
  if (this.selectedId === id)
61771
- this.selectedId = null;
61994
+ this.setSelected(null);
61772
61995
  this.store.remove(id);
61996
+ this.emitInteraction();
61773
61997
  }
61774
61998
  selectAnnotation(id) {
61999
+ var _a;
62000
+ // Commit any open geodesic edit before the selection changes.
62001
+ if (this.editingId && this.editingId !== id)
62002
+ this.recommitGeodesic();
61775
62003
  this.selectedId = id;
61776
62004
  this.applySelection();
62005
+ if (!id)
62006
+ return;
62007
+ const a = this.store.get(id);
62008
+ if (!a)
62009
+ return;
62010
+ // Selecting an annotation switches to the tool that drew it, so you can carry on editing it
62011
+ // (and a geodesic opens its anchor handles). points → Place point; contour → its draw mode.
62012
+ const tool = a.type === "points" ? "point" : (_a = a.mode) !== null && _a !== void 0 ? _a : "freehand";
62013
+ if (this.mode !== tool)
62014
+ this.setMode(tool);
62015
+ // With Geodesic now active, re-open the contour's anchors for editing.
62016
+ if (this.mode === "geodesic")
62017
+ this.tryOpenGeoEdit(id);
62018
+ }
62019
+ /** Change the selection from inside the engine (delete / deselect-on-navigate) and mirror it to the UI. */
62020
+ setSelected(id) {
62021
+ var _a, _b;
62022
+ if (this.selectedId === id)
62023
+ return;
62024
+ this.selectedId = id;
62025
+ this.applySelection();
62026
+ (_b = (_a = this.o).onSelectionChange) === null || _b === void 0 ? void 0 : _b.call(_a, id);
61777
62027
  }
61778
62028
  /** Redraw the corresponding three object after a color change. */
61779
62029
  refreshAnnotation(id) {
@@ -61804,17 +62054,11 @@ void main() {
61804
62054
  let count = 0;
61805
62055
  let maxImported = this.seq;
61806
62056
  for (const a of (_a = payload.annotations) !== null && _a !== void 0 ? _a : []) {
61807
- const verts = a.points.map((p) => {
61808
- const [x, y, z] = p;
61809
- let nx = p[3], ny = p[4], nz = p[5];
61810
- if (nx === undefined || ny === undefined || nz === undefined) {
61811
- const nrm = this.graph.nearestNormalLocal(new Vector3(x, y, z));
61812
- nx = nrm.x;
61813
- ny = nrm.y;
61814
- nz = nrm.z;
61815
- }
61816
- return { x, y, z, nx, ny, nz, faceIndex: 0 };
61817
- });
62057
+ const verts = a.points.map((p) => this.rawToVertex(p));
62058
+ // Geodesic control points, if the payload carries them → keep the contour editable.
62059
+ const anchors = a.mode === "geodesic" && a.anchors && a.anchors.length
62060
+ ? a.anchors.map((p) => this.rawToVertex(p))
62061
+ : undefined;
61818
62062
  if (a.type === "points") {
61819
62063
  for (const v of verts) {
61820
62064
  // Only reuse the provided id for a single-point entry (the round-trip case);
@@ -61855,6 +62099,7 @@ void main() {
61855
62099
  closed: a.closed,
61856
62100
  visible: (_d = a.visible) !== null && _d !== void 0 ? _d : true,
61857
62101
  vertices: verts,
62102
+ anchors,
61858
62103
  object3D: line,
61859
62104
  });
61860
62105
  const m = id.match(/^a(\d+)$/);
@@ -61873,10 +62118,19 @@ void main() {
61873
62118
  return this.drawLock || this.spaceHeld;
61874
62119
  }
61875
62120
  applyCameraGating() {
61876
- var _a, _b;
61877
62121
  // Default is navigate (camera enabled). Drawing only when drawLock or spaceHeld.
61878
62122
  this.o.controls.enabled = !this.drawing;
61879
- (_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, { drawing: this.drawing, armed: this.armed, locked: this.drawLock });
62123
+ this.emitInteraction();
62124
+ }
62125
+ /** Emit the current interaction state (camera gating + whether a geodesic is editable). */
62126
+ emitInteraction() {
62127
+ var _a, _b;
62128
+ (_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, {
62129
+ drawing: this.drawing,
62130
+ armed: this.armed,
62131
+ locked: this.drawLock,
62132
+ editing: !!this.activeGeo,
62133
+ });
61880
62134
  }
61881
62135
  /** Reconcile the scene against store.list(): add missing objects, remove deleted ones (no dispose, kept for undo restore). */
61882
62136
  reconcile() {
@@ -61919,18 +62173,33 @@ void main() {
61919
62173
  }
61920
62174
  }
61921
62175
  disposeObject(o) {
61922
- var _a, _b, _c;
61923
- const any = o;
61924
- (_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
61925
- const mat = any.material;
61926
- if (Array.isArray(mat))
61927
- mat.forEach((m) => m.dispose());
61928
- else
61929
- (_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
62176
+ // Traverse so composite handles (Group of rim + core meshes) are fully freed, not just the root.
62177
+ o.traverse((c) => {
62178
+ var _a, _b, _c;
62179
+ const any = c;
62180
+ (_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
62181
+ const mat = any.material;
62182
+ if (Array.isArray(mat))
62183
+ mat.forEach((m) => m.dispose());
62184
+ else
62185
+ (_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
62186
+ });
61930
62187
  }
61931
62188
  nextId() {
61932
62189
  return "a" + ++this.seq;
61933
62190
  }
62191
+ /** 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. */
62192
+ rawToVertex(p) {
62193
+ const [x, y, z] = p;
62194
+ let nx = p[3], ny = p[4], nz = p[5];
62195
+ if (nx === undefined || ny === undefined || nz === undefined) {
62196
+ const nrm = this.graph.nearestNormalLocal(new Vector3(x, y, z));
62197
+ nx = nrm.x;
62198
+ ny = nrm.y;
62199
+ nz = nrm.z;
62200
+ }
62201
+ return { x, y, z, nx, ny, nz, faceIndex: 0 };
62202
+ }
61934
62203
  hit(e) {
61935
62204
  return raycastSurface(this.o.camera, this.o.container, this.o.mesh, e.clientX, e.clientY);
61936
62205
  }
@@ -61945,6 +62214,55 @@ void main() {
61945
62214
  const t = e.target;
61946
62215
  return !!t && t.tagName === "CANVAS" && this.o.container.contains(t);
61947
62216
  }
62217
+ /** After any anchor edit: refresh markers + line; drop/delete the contour when it becomes degenerate. */
62218
+ afterGeoEdit() {
62219
+ if (!this.activeGeo)
62220
+ return;
62221
+ if (this.editingId) {
62222
+ // Editing a committed contour: fewer than 2 anchors → delete the whole annotation.
62223
+ if (this.activeGeo.anchorCount < 2) {
62224
+ const id = this.editingId;
62225
+ this.removeGeoMarkers();
62226
+ this.activeGeo = undefined;
62227
+ this.activeGeoLine = undefined; // owned by the annotation; deleteAnnotation disposes it
62228
+ this.editingId = null;
62229
+ this.geoClosed = false;
62230
+ this.deleteAnnotation(id);
62231
+ this.emitInteraction();
62232
+ return;
62233
+ }
62234
+ this.rebuildGeoMarkers();
62235
+ this.redrawGeoLine();
62236
+ this.emitInteraction();
62237
+ return;
62238
+ }
62239
+ // In-progress new geodesic.
62240
+ if (this.activeGeo.anchorCount === 0) {
62241
+ this.clearActiveGeo();
62242
+ }
62243
+ else {
62244
+ this.rebuildGeoMarkers();
62245
+ this.redrawGeoLine();
62246
+ }
62247
+ this.emitInteraction();
62248
+ }
62249
+ /** Coalesce live drag redraws to one per animation frame (moveAnchorTo runs Dijkstra per move). */
62250
+ scheduleGeoRedraw() {
62251
+ if (this.geoRedrawScheduled)
62252
+ return;
62253
+ this.geoRedrawScheduled = true;
62254
+ requestAnimationFrame(() => {
62255
+ var _a;
62256
+ this.geoRedrawScheduled = false;
62257
+ this.rebuildGeoMarkers();
62258
+ this.redrawGeoLine();
62259
+ if (this.draggingAnchor >= 0) {
62260
+ this.o.container.style.cursor = "grabbing";
62261
+ // keep the grabbed handle emphasized through the live rebuilds
62262
+ (_a = this.activeGeoMarkers[this.draggingAnchor]) === null || _a === void 0 ? void 0 : _a.scale.setScalar(1.4);
62263
+ }
62264
+ });
62265
+ }
61948
62266
  /**
61949
62267
  * Pick an in-progress anchor: return the index of the nearest anchor (screen pixel distance <
61950
62268
  * tolerance), or -1 on a miss.
@@ -61981,71 +62299,30 @@ void main() {
61981
62299
  }
61982
62300
  return best;
61983
62301
  }
61984
- /** Set the currently hovered anchor (-1 = none): update highlight scale, cursor, and the "✕ (cancel)" floating badge. */
62302
+ /**
62303
+ * Set the currently hovered anchor (-1 = none): scale it up as a highlight and show a "grab"
62304
+ * cursor to signal it's draggable. Delete is now a right-click (no floating ✕ affordance).
62305
+ */
61985
62306
  setGeoHover(idx) {
61986
- if (idx === this.hoveredGeoMarker) {
61987
- if (idx >= 0)
61988
- this.positionGeoBadge(this.activeGeoMarkers[idx]);
62307
+ if (idx === this.hoveredGeoMarker)
61989
62308
  return;
61990
- }
61991
62309
  const prev = this.activeGeoMarkers[this.hoveredGeoMarker];
61992
62310
  if (prev)
61993
62311
  prev.scale.setScalar(1);
61994
62312
  this.hoveredGeoMarker = idx;
61995
62313
  const cur = this.activeGeoMarkers[idx];
62314
+ // Don't fight the "grabbing" cursor set during an active drag.
62315
+ if (this.draggingAnchor >= 0)
62316
+ return;
61996
62317
  if (cur) {
61997
- cur.scale.setScalar(1.3);
61998
- this.ensureGeoBadge().style.display = "flex";
61999
- this.positionGeoBadge(cur);
62000
- this.o.container.style.cursor = "pointer";
62318
+ cur.scale.setScalar(1.35);
62319
+ this.o.container.style.cursor = "grab";
62001
62320
  }
62002
62321
  else {
62003
- if (this.geoHoverBadge)
62004
- this.geoHoverBadge.style.display = "none";
62005
62322
  this.o.container.style.cursor = "";
62006
62323
  }
62007
62324
  }
62008
- /** Lazily create the floating badge (appended inside the container, pointer-events:none so it doesn't block clicks). */
62009
- ensureGeoBadge() {
62010
- if (this.geoHoverBadge)
62011
- return this.geoHoverBadge;
62012
- const b = document.createElement("div");
62013
- b.textContent = "✕";
62014
- Object.assign(b.style, {
62015
- position: "absolute",
62016
- width: "16px",
62017
- height: "16px",
62018
- borderRadius: "50%",
62019
- background: "#ff5d6c",
62020
- color: "#fff",
62021
- font: "700 10px/1 system-ui, sans-serif",
62022
- display: "none",
62023
- alignItems: "center",
62024
- justifyContent: "center",
62025
- pointerEvents: "none",
62026
- zIndex: "15",
62027
- boxShadow: "0 2px 6px rgba(0,0,0,.45)",
62028
- transform: "translate(-50%, -50%)",
62029
- left: "0px",
62030
- top: "0px",
62031
- });
62032
- this.o.container.appendChild(b);
62033
- this.geoHoverBadge = b;
62034
- return b;
62035
- }
62036
- /** Position the ✕ badge at the anchor's screen projection (dead center, i.e. where the cursor hovers, to avoid ambiguity). */
62037
- positionGeoBadge(marker) {
62038
- if (!marker)
62039
- return;
62040
- const b = this.ensureGeoBadge();
62041
- this._projV.copy(marker.position).project(this.o.camera);
62042
- const rect = this.o.container.getBoundingClientRect();
62043
- const x = (this._projV.x * 0.5 + 0.5) * rect.width;
62044
- const y = (-this._projV.y * 0.5 + 0.5) * rect.height;
62045
- b.style.left = `${x}px`;
62046
- b.style.top = `${y}px`;
62047
- }
62048
- /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and click to cancel). */
62325
+ /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and grab). */
62049
62326
  rebuildGeoMarkers() {
62050
62327
  this.setGeoHover(-1);
62051
62328
  for (const m of this.activeGeoMarkers) {
@@ -62056,18 +62333,20 @@ void main() {
62056
62333
  if (!this.activeGeo)
62057
62334
  return;
62058
62335
  for (const v of this.activeGeo.getAnchorLocals()) {
62059
- const marker = makePointMarker(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.15);
62336
+ const marker = makeAnchorHandle(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.7);
62060
62337
  this.o.scene.add(marker);
62061
62338
  this.activeGeoMarkers.push(marker);
62062
62339
  }
62063
62340
  }
62064
- /** Redraw the in-progress geodesic from the current anchors (not closed); remove the line when fewer than two points. */
62341
+ /** 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. */
62065
62342
  redrawGeoLine() {
62066
62343
  if (!this.activeGeo)
62067
62344
  return;
62068
- const verts = this.activeGeo.buildVertices(false);
62345
+ const closed = this.geoClosed;
62346
+ const verts = this.activeGeo.buildVertices(closed);
62069
62347
  if (verts.length < 2) {
62070
- if (this.activeGeoLine) {
62348
+ // Only dispose the line for an in-progress geodesic; when editing, the line belongs to the annotation.
62349
+ if (this.activeGeoLine && !this.editingId) {
62071
62350
  this.o.scene.remove(this.activeGeoLine);
62072
62351
  this.disposeObject(this.activeGeoLine);
62073
62352
  this.activeGeoLine = undefined;
@@ -62075,11 +62354,20 @@ void main() {
62075
62354
  return;
62076
62355
  }
62077
62356
  if (!this.activeGeoLine) {
62078
- this.activeGeoLine = makeContourLine(verts, this.geodesicColor, false, this.o.container, this.epsilon, this.o.mesh);
62357
+ this.activeGeoLine = makeContourLine(verts, this.geodesicColor, closed, this.o.container, this.epsilon, this.o.mesh);
62079
62358
  this.o.scene.add(this.activeGeoLine);
62080
62359
  }
62081
62360
  else {
62082
- updateContourLine(this.activeGeoLine, verts, false, this.epsilon, this.o.mesh);
62361
+ updateContourLine(this.activeGeoLine, verts, closed, this.epsilon, this.o.mesh);
62362
+ }
62363
+ // Live-sync the committed annotation's data while it is being edited.
62364
+ if (this.editingId) {
62365
+ const a = this.store.get(this.editingId);
62366
+ if (a) {
62367
+ a.vertices = verts;
62368
+ a.closed = closed;
62369
+ a.anchors = this.activeGeo.getAnchorLocals();
62370
+ }
62083
62371
  }
62084
62372
  }
62085
62373
  /** Discard the in-progress geodesic: remove and dispose the line and all anchors. */
@@ -62096,6 +62384,7 @@ void main() {
62096
62384
  }
62097
62385
  this.activeGeoMarkers = [];
62098
62386
  this.activeGeo = undefined;
62387
+ this.emitInteraction();
62099
62388
  }
62100
62389
  /** Remove all anchor spheres of the in-progress geodesic (called after committing: only the line remains). */
62101
62390
  removeGeoMarkers() {
@@ -62142,6 +62431,7 @@ void main() {
62142
62431
  closed,
62143
62432
  visible: true,
62144
62433
  vertices: verts,
62434
+ anchors: this.activeGeo.getAnchorLocals(),
62145
62435
  object3D: this.activeGeoLine,
62146
62436
  };
62147
62437
  this.store.add(ann);
@@ -62154,6 +62444,61 @@ void main() {
62154
62444
  this.removeGeoMarkers();
62155
62445
  this.activeGeo = undefined;
62156
62446
  this.activeGeoLine = undefined;
62447
+ this.geoClosed = false;
62448
+ this.emitInteraction();
62449
+ }
62450
+ /**
62451
+ * Re-open a committed geodesic contour for editing: rebuild its anchors (from the stored positions)
62452
+ * as draggable markers and reuse its line for live updates. No-op for non-geodesic / anchorless
62453
+ * annotations. The edit session ends on Enter / mode-switch / selecting another / Esc.
62454
+ */
62455
+ tryOpenGeoEdit(id) {
62456
+ var _a;
62457
+ if (this.editingId === id)
62458
+ return;
62459
+ const a = this.store.get(id);
62460
+ if (!a || a.type !== "contour" || a.mode !== "geodesic" || !a.anchors || a.anchors.length < 2) {
62461
+ return;
62462
+ }
62463
+ // Discard a half-drawn (uncommitted) geodesic before entering the edit session.
62464
+ if (this.activeGeo && !this.editingId)
62465
+ this.clearActiveGeo();
62466
+ const indices = a.anchors.map((v) => this.graph.nearestVertex(new Vector3(v.x, v.y, v.z)));
62467
+ this.activeGeo = GeodesicContour.fromAnchors(this.graph, this.o.mesh, indices);
62468
+ this.activeGeoLine = (_a = a.object3D) !== null && _a !== void 0 ? _a : undefined;
62469
+ this.editingId = id;
62470
+ this.geoClosed = a.closed;
62471
+ this.rebuildGeoMarkers();
62472
+ this.redrawGeoLine();
62473
+ this.emitInteraction();
62474
+ }
62475
+ /**
62476
+ * Finalize an edit session on a committed geodesic: the annotation data was kept in sync live
62477
+ * (redrawGeoLine), so here we just remove the edit markers, notify subscribers, and end the
62478
+ * session — or delete the annotation if it was reduced below 2 anchors.
62479
+ */
62480
+ recommitGeodesic() {
62481
+ const id = this.editingId;
62482
+ if (!id)
62483
+ return;
62484
+ if (!this.activeGeo || this.activeGeo.anchorCount < 2) {
62485
+ this.removeGeoMarkers();
62486
+ this.activeGeo = undefined;
62487
+ this.activeGeoLine = undefined; // owned by the annotation
62488
+ this.editingId = null;
62489
+ this.geoClosed = false;
62490
+ this.deleteAnnotation(id);
62491
+ this.emitInteraction();
62492
+ return;
62493
+ }
62494
+ this.redrawGeoLine(); // final data sync
62495
+ this.removeGeoMarkers();
62496
+ this.activeGeo = undefined;
62497
+ this.activeGeoLine = undefined; // owned by the annotation, do not dispose
62498
+ this.editingId = null;
62499
+ this.geoClosed = false;
62500
+ this.store.touch();
62501
+ this.emitInteraction();
62157
62502
  }
62158
62503
  isTypingTarget(e) {
62159
62504
  const t = e.target;
@@ -62163,16 +62508,14 @@ void main() {
62163
62508
  return tag === "INPUT" || tag === "TEXTAREA" || t.isContentEditable;
62164
62509
  }
62165
62510
  dispose() {
62166
- var _a;
62167
62511
  this.clearActiveGeo();
62168
- (_a = this.geoHoverBadge) === null || _a === void 0 ? void 0 : _a.remove();
62169
- this.geoHoverBadge = undefined;
62170
62512
  window.removeEventListener("pointerdown", this.onPointerDown, true);
62171
62513
  window.removeEventListener("pointermove", this.onPointerMove, true);
62172
62514
  window.removeEventListener("pointerup", this.onPointerUp, true);
62173
62515
  window.removeEventListener("keydown", this.onKeyDown);
62174
62516
  window.removeEventListener("keyup", this.onKeyUp);
62175
62517
  window.removeEventListener("resize", this.onResize);
62518
+ window.removeEventListener("contextmenu", this.onContextMenu, true);
62176
62519
  }
62177
62520
  }
62178
62521
  SurfaceAnnotator.TAP_MS = 250;
@@ -62236,7 +62579,7 @@ void main() {
62236
62579
  // console.log(error);
62237
62580
  });
62238
62581
  }
62239
- loadPureGLB(url, callback, opts) {
62582
+ loadPureGLB(url, callback, opts, onError) {
62240
62583
  const loader = copperGltfLoader(this.renderer);
62241
62584
  loader.load(url, (glb) => {
62242
62585
  const content = glb.scene;
@@ -62260,6 +62603,9 @@ void main() {
62260
62603
  !!callback && callback(content);
62261
62604
  }, (xhr) => { }, (error) => {
62262
62605
  console.log("An error happened: ", error);
62606
+ // Surface the failure to the caller so it can stop its loading state / report it,
62607
+ // instead of the load silently hanging forever on a corrupt/invalid GLB.
62608
+ !!onError && onError(error);
62263
62609
  });
62264
62610
  }
62265
62611
  loadVtk(url) {
@@ -83605,15 +83951,15 @@ void main() {
83605
83951
  }
83606
83952
 
83607
83953
  // import * as kiwrious from "copper3d_plugin_heart_k";
83608
- // "v3.6.9" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version.
83954
+ // "v3.6.12" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version.
83609
83955
  // When copper3d is consumed from local source (no rollup replace step), the identifier is undefined and
83610
83956
  // referencing it throws a ReferenceError — guard it so local loading still works.
83611
83957
  let _revision = "unknown";
83612
83958
  try {
83613
- _revision = "v3.6.9";
83959
+ _revision = "v3.6.12";
83614
83960
  }
83615
83961
  catch (_a) {
83616
- /* "v3.6.9" not injected (local source build) */
83962
+ /* "v3.6.12" not injected (local source build) */
83617
83963
  }
83618
83964
  const REVISION = _revision;
83619
83965
  // Expose on global so the version can be read in a production browser console via window.__COPPER3D_VERSION__