copper3d 3.6.9 → 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.
@@ -59646,6 +59646,25 @@ function makePointMarker(v, mesh, color, radius) {
59646
59646
  m.position.copy(p.addScaledVector(n, radius * 0.5));
59647
59647
  m.renderOrder = 999;
59648
59648
  return m;
59649
+ }
59650
+ /**
59651
+ * Create a draggable ANCHOR HANDLE (for the geodesic editor): a white outer rim around a
59652
+ * tool-colored core, so the handle stands out against both the (same-colored) contour line and the
59653
+ * grey surface. Returned as a Group; scale it to show hover/selection feedback. depthTest stays ON
59654
+ * (occluded by the model on the far side) and it's lifted a touch more than a plain marker so it
59655
+ * sits above the thickened line rather than sinking into it.
59656
+ */
59657
+ function makeAnchorHandle(v, mesh, coreColor, radius) {
59658
+ const g = new Group();
59659
+ const rim = new Mesh(new SphereGeometry(radius, 16, 16), new MeshBasicMaterial({ color: "#ffffff" }));
59660
+ rim.renderOrder = 1000;
59661
+ const core = new Mesh(new SphereGeometry(radius * 0.6, 16, 16), new MeshBasicMaterial({ color: coreColor }));
59662
+ core.renderOrder = 1001;
59663
+ g.add(rim);
59664
+ g.add(core);
59665
+ const { p, n } = localVertexToWorld(v, mesh);
59666
+ g.position.copy(p.addScaledVector(n, radius * 0.7));
59667
+ return g;
59649
59668
  }
59650
59669
 
59651
59670
  /**
@@ -61273,6 +61292,60 @@ class GeodesicContour {
61273
61292
  }
61274
61293
  this.anchors.push(v);
61275
61294
  }
61295
+ /** Move the anchor at `index` to the vertex nearest `localHitPoint`, rebuilding adjacent segments live. */
61296
+ moveAnchorTo(index, localHitPoint) {
61297
+ if (index < 0 || index >= this.anchors.length)
61298
+ return;
61299
+ this.snapshot();
61300
+ this.anchors[index] = this.graph.nearestVertex(localHitPoint);
61301
+ this.rebuildSegments();
61302
+ }
61303
+ /** Anchor vertex indices (persisted on a committed geodesic so it can be re-opened for editing). */
61304
+ getAnchorIndices() {
61305
+ return this.anchors.slice();
61306
+ }
61307
+ /** Rebuild an editable contour from stored anchor vertex indices (re-opening a committed geodesic). */
61308
+ static fromAnchors(graph, mesh, indices) {
61309
+ const gc = new GeodesicContour(graph, mesh);
61310
+ gc.anchors = indices.slice();
61311
+ gc.rebuildSegments();
61312
+ return gc;
61313
+ }
61314
+ /** Insert a new anchor (snapped to the nearest vertex) at position `index`, rebuilding segments. */
61315
+ insertAnchorAt(index, localHitPoint) {
61316
+ const v = this.graph.nearestVertex(localHitPoint);
61317
+ this.snapshot();
61318
+ const i = Math.max(0, Math.min(index, this.anchors.length));
61319
+ this.anchors.splice(i, 0, v);
61320
+ this.rebuildSegments();
61321
+ }
61322
+ /**
61323
+ * Which anchor interval's surface path passes closest to `worldPoint` — i.e. the edge the user
61324
+ * clicked on, so a new anchor can be inserted there. Returns the interval index (insert after that
61325
+ * anchor, i.e. at index+1), or -1 when there are fewer than 2 anchors or the click is farther than
61326
+ * `maxDist` (world units) from every edge. For a closed contour the trailing closing edge
61327
+ * (last→first) is considered and maps to appending at the end.
61328
+ */
61329
+ nearestInsertIndex(worldPoint, matrixWorld, closed, maxDist) {
61330
+ if (this.anchors.length < 2)
61331
+ return -1;
61332
+ const segs = this.segments.slice();
61333
+ if (closed && this.anchors.length > 2) {
61334
+ segs.push(this.graph.shortestPath(this.anchors[this.anchors.length - 1], this.anchors[0]));
61335
+ }
61336
+ let best = -1;
61337
+ let bestD = maxDist;
61338
+ for (let si = 0; si < segs.length; si++) {
61339
+ for (const vi of segs[si]) {
61340
+ const d = this.graph.vertexWorld(vi, matrixWorld).distanceTo(worldPoint);
61341
+ if (d < bestD) {
61342
+ bestD = d;
61343
+ best = si;
61344
+ }
61345
+ }
61346
+ }
61347
+ return best;
61348
+ }
61276
61349
  /** Remove the anchor at the given index and recompute adjacent segments (allows canceling any middle point). */
61277
61350
  removeAnchorAt(index) {
61278
61351
  if (index < 0 || index >= this.anchors.length)
@@ -61324,7 +61397,52 @@ class GeodesicContour {
61324
61397
  idxPath.push(this.anchors[0]);
61325
61398
  }
61326
61399
  // The graph geometry is already local space, so emit local vertices directly (world is derived at render time).
61327
- return idxPath.map((i) => this.graph.vertexLocal(i));
61400
+ return this.smoothPath(idxPath, closed);
61401
+ }
61402
+ /**
61403
+ * Relax the stitched shortest-path polyline so it reads as a smooth curve instead of the raw
61404
+ * voxel staircase, WITHOUT changing the point count (keeps editing/index mapping simple) and
61405
+ * WITHOUT moving the anchors (they stay exact control points). A few Laplacian passes pull each
61406
+ * interior point toward the midpoint of its neighbours; anchors and (for open paths) the two
61407
+ * endpoints are pinned. Normals are carried over from each point's original graph vertex — the
61408
+ * points only shift slightly and the line already floats above the surface by `epsilon`, so the
61409
+ * approximation is invisible and avoids an O(V) nearest-vertex scan per point on every redraw.
61410
+ */
61411
+ smoothPath(idxPath, closed) {
61412
+ const base = idxPath.map((i) => this.graph.vertexLocal(i));
61413
+ const n = base.length;
61414
+ if (n <= 2)
61415
+ return base;
61416
+ const anchorSet = new Set(this.anchors);
61417
+ const pinned = idxPath.map((i) => anchorSet.has(i));
61418
+ const P = base.map((v) => new Vector3(v.x, v.y, v.z));
61419
+ const ITER = 2;
61420
+ const LAMBDA = 0.5;
61421
+ const mid = new Vector3();
61422
+ for (let it = 0; it < ITER; it++) {
61423
+ const next = P.map((p) => p.clone());
61424
+ for (let k = 0; k < n; k++) {
61425
+ if (pinned[k])
61426
+ continue;
61427
+ if (!closed && (k === 0 || k === n - 1))
61428
+ continue;
61429
+ const a = P[(k - 1 + n) % n];
61430
+ const b = P[(k + 1) % n];
61431
+ mid.addVectors(a, b).multiplyScalar(0.5).sub(P[k]);
61432
+ next[k].copy(P[k]).addScaledVector(mid, LAMBDA);
61433
+ }
61434
+ for (let k = 0; k < n; k++)
61435
+ P[k].copy(next[k]);
61436
+ }
61437
+ return P.map((p, k) => ({
61438
+ x: p.x,
61439
+ y: p.y,
61440
+ z: p.z,
61441
+ nx: base[k].nx,
61442
+ ny: base[k].ny,
61443
+ nz: base[k].nz,
61444
+ faceIndex: -1,
61445
+ }));
61328
61446
  }
61329
61447
  }
61330
61448
 
@@ -61409,6 +61527,10 @@ class AnnotationStore {
61409
61527
  this.notify();
61410
61528
  }
61411
61529
  }
61530
+ /** Notify subscribers after an in-place mutation of an existing annotation (e.g. geodesic edit re-commit). */
61531
+ touch() {
61532
+ this.notify();
61533
+ }
61412
61534
  toJSON(modelName, mesh, opts = {}) {
61413
61535
  var _a;
61414
61536
  const space = (_a = opts.space) !== null && _a !== void 0 ? _a : "local";
@@ -61426,16 +61548,23 @@ class AnnotationStore {
61426
61548
  model: modelName,
61427
61549
  exportedAt: new Date().toISOString(),
61428
61550
  space,
61429
- annotations: this.items.map((a) => ({
61430
- id: a.id,
61431
- type: a.type,
61432
- mode: a.mode,
61433
- label: a.label,
61434
- color: a.color,
61435
- closed: a.closed,
61436
- visible: a.visible,
61437
- points: a.vertices.map(toPt),
61438
- })),
61551
+ annotations: this.items.map((a) => {
61552
+ const out = {
61553
+ id: a.id,
61554
+ type: a.type,
61555
+ mode: a.mode,
61556
+ label: a.label,
61557
+ color: a.color,
61558
+ closed: a.closed,
61559
+ visible: a.visible,
61560
+ points: a.vertices.map(toPt),
61561
+ };
61562
+ // Geodesic contours also carry their control points so a round-tripped import stays editable.
61563
+ if (a.mode === "geodesic" && a.anchors && a.anchors.length) {
61564
+ out.anchors = a.anchors.map(toPt);
61565
+ }
61566
+ return out;
61567
+ }),
61439
61568
  };
61440
61569
  }
61441
61570
  }
@@ -61462,11 +61591,20 @@ class SurfaceAnnotator {
61462
61591
  this.managed = new Set();
61463
61592
  this.seq = 0;
61464
61593
  this.selectedId = null;
61465
- this.activeGeoMarkers = []; // visible anchors of the in-progress geodesic
61594
+ this.activeGeoMarkers = []; // visible anchor handles of the active geodesic
61466
61595
  this.geoRay = new Raycaster();
61467
61596
  this.geoNdc = new Vector2();
61468
61597
  this.hoveredGeoMarker = -1;
61598
+ this.draggingAnchor = -1; // anchor index currently being dragged (-1 = none)
61599
+ this.geoRedrawScheduled = false; // rAF coalescing flag for live drag redraw
61600
+ this.editingId = null; // committed geodesic re-opened for editing (Task 1.5)
61601
+ this.geoClosed = false; // whether the active/edited geodesic is a closed loop
61469
61602
  this._projV = new Vector3();
61603
+ /** Block the OS right-click menu when the pointer is over the WebGL canvas (not the HTML panels). */
61604
+ this.onContextMenu = (e) => {
61605
+ if (this.insideContainer(e))
61606
+ e.preventDefault();
61607
+ };
61470
61608
  /** Update the pixel resolution of all fat lines on window resize, otherwise line width distorts. */
61471
61609
  this.onResize = () => {
61472
61610
  const w = this.o.container.clientWidth;
@@ -61483,10 +61621,50 @@ class SurfaceAnnotator {
61483
61621
  (this.activeGeoLine.material.resolution.set(w, h));
61484
61622
  };
61485
61623
  this.onPointerDown = (e) => {
61486
- if (e.button !== 0)
61487
- return; // left button only
61488
61624
  if (!this.insideContainer(e))
61489
61625
  return;
61626
+ // Geodesic anchor editing (drag to move / right-click to delete) is available whenever an
61627
+ // editable geodesic exists — even outside draw-lock — because it requires precisely hitting an
61628
+ // existing anchor. This lets the user rotate the model freely and still grab a point. Handled
61629
+ // BEFORE the button/drawing gates below.
61630
+ if (this.armed === "geodesic" && this.activeGeo) {
61631
+ if (e.button === 2) {
61632
+ const pick = this.pickGeoMarker(e);
61633
+ if (pick >= 0) {
61634
+ e.preventDefault();
61635
+ this.activeGeo.removeAnchorAt(pick);
61636
+ this.afterGeoEdit();
61637
+ return;
61638
+ }
61639
+ }
61640
+ else if (e.button === 0) {
61641
+ const pick = this.pickGeoMarker(e);
61642
+ if (pick >= 0) {
61643
+ e.preventDefault();
61644
+ this.draggingAnchor = pick;
61645
+ this.pointerDown = true;
61646
+ this.o.controls.enabled = false; // suppress camera while dragging the anchor
61647
+ this.o.container.style.cursor = "grabbing";
61648
+ return;
61649
+ }
61650
+ // Missed every anchor → if the click lands on the line body, insert a new anchor there
61651
+ // (works in an edit session or an in-progress geodesic, regardless of draw-lock).
61652
+ if (this.activeGeo.anchorCount >= 2) {
61653
+ const h = this.hit(e);
61654
+ if (h) {
61655
+ const insertAt = this.activeGeo.nearestInsertIndex(h.point, this.o.mesh.matrixWorld, this.geoClosed, this.minGap * 4);
61656
+ if (insertAt >= 0) {
61657
+ e.preventDefault();
61658
+ this.activeGeo.insertAnchorAt(insertAt + 1, this.o.mesh.worldToLocal(h.point.clone()));
61659
+ this.afterGeoEdit();
61660
+ return;
61661
+ }
61662
+ }
61663
+ }
61664
+ }
61665
+ }
61666
+ if (e.button !== 0)
61667
+ return; // the remaining tools are left-button only
61490
61668
  this.pointerDown = true;
61491
61669
  // Only act with the armed tool when in drawing mode (drawLock or spaceHeld).
61492
61670
  // When not drawing, navigation is the default — pointer events go to camera controls.
@@ -61526,19 +61704,8 @@ class SurfaceAnnotator {
61526
61704
  return;
61527
61705
  }
61528
61706
  if (activeTool === "geodesic") {
61529
- // First check whether an existing anchor was clicked cancel that point (any point can be canceled).
61530
- const pick = this.pickGeoMarker(e);
61531
- if (pick >= 0 && this.activeGeo) {
61532
- this.activeGeo.removeAnchorAt(pick);
61533
- if (this.activeGeo.anchorCount === 0)
61534
- this.clearActiveGeo();
61535
- else {
61536
- this.rebuildGeoMarkers();
61537
- this.redrawGeoLine();
61538
- }
61539
- return;
61540
- }
61541
- // Otherwise drop a new anchor on the surface.
61707
+ // Anchor grab/delete was already handled above; here we only drop a NEW anchor on the surface
61708
+ // (clicks on an existing anchor never reach this point).
61542
61709
  const h = this.hit(e);
61543
61710
  if (!h)
61544
61711
  return;
@@ -61547,11 +61714,20 @@ class SurfaceAnnotator {
61547
61714
  }
61548
61715
  const local = this.o.mesh.worldToLocal(h.point.clone());
61549
61716
  this.activeGeo.addAnchor(local);
61550
- this.rebuildGeoMarkers();
61551
- this.redrawGeoLine();
61717
+ this.afterGeoEdit();
61552
61718
  }
61553
61719
  };
61554
61720
  this.onPointerMove = (e) => {
61721
+ // Live anchor drag: move the grabbed anchor to the surface point under the cursor and redraw
61722
+ // (both adjacent segments recompute inside moveAnchorTo). Takes priority over everything else.
61723
+ if (this.draggingAnchor >= 0) {
61724
+ const h = this.hit(e);
61725
+ if (h && this.activeGeo) {
61726
+ this.activeGeo.moveAnchorTo(this.draggingAnchor, this.o.mesh.worldToLocal(h.point.clone()));
61727
+ this.scheduleGeoRedraw();
61728
+ }
61729
+ return;
61730
+ }
61555
61731
  // Track if user dragged while space was held (so a hold-drag is not treated as a tap).
61556
61732
  if (this.spaceHeld && this.pointerDown) {
61557
61733
  this.spaceDragged = true;
@@ -61583,6 +61759,15 @@ class SurfaceAnnotator {
61583
61759
  }
61584
61760
  };
61585
61761
  this.onPointerUp = () => {
61762
+ // Finish an anchor drag: restore camera gating + cursor. (Re-commit of an edited committed
61763
+ // contour is wired in Task 1.5.)
61764
+ if (this.draggingAnchor >= 0) {
61765
+ this.draggingAnchor = -1;
61766
+ this.pointerDown = false;
61767
+ this.o.container.style.cursor = this.hoveredGeoMarker >= 0 ? "grab" : "";
61768
+ this.applyCameraGating();
61769
+ return;
61770
+ }
61586
61771
  if (!this.pointerDown)
61587
61772
  return;
61588
61773
  this.pointerDown = false;
@@ -61624,6 +61809,18 @@ class SurfaceAnnotator {
61624
61809
  return;
61625
61810
  }
61626
61811
  if (e.key === "Escape") {
61812
+ // First Esc clears an active selection / in-progress geodesic while staying in the current
61813
+ // tool; a second Esc (nothing selected) returns to Navigate.
61814
+ if (this.selectedId || this.activeGeo) {
61815
+ if (this.editingId)
61816
+ this.recommitGeodesic();
61817
+ else if (this.activeGeo)
61818
+ this.clearActiveGeo();
61819
+ this.setSelected(null);
61820
+ this.drawLock = false;
61821
+ this.applyCameraGating();
61822
+ return;
61823
+ }
61627
61824
  this.drawLock = false;
61628
61825
  this.setMode("navigate");
61629
61826
  return;
@@ -61639,8 +61836,11 @@ class SurfaceAnnotator {
61639
61836
  return;
61640
61837
  }
61641
61838
  if (e.key === "Enter") {
61642
- // Geodesic in progressfinish and close it; otherwise close the most recent freehand line.
61643
- if (this.activeGeo)
61839
+ // Editing a committed geodesic finalize the edit; a fresh geodesic in progress finish and
61840
+ // close it; otherwise close the most recent freehand line.
61841
+ if (this.editingId)
61842
+ this.recommitGeodesic();
61843
+ else if (this.activeGeo)
61644
61844
  this.finishGeodesic();
61645
61845
  else
61646
61846
  this.closeLastContour();
@@ -61693,6 +61893,10 @@ class SurfaceAnnotator {
61693
61893
  window.addEventListener("keydown", this.onKeyDown);
61694
61894
  window.addEventListener("keyup", this.onKeyUp);
61695
61895
  window.addEventListener("resize", this.onResize);
61896
+ // Suppress the browser's native context menu over the WebGL canvas in EVERY mode: right-click
61897
+ // there is used for camera pan (and, in geodesic mode, delete-anchor), so the OS menu must
61898
+ // never appear. Guarded by insideContainer → right-clicking the HTML panels keeps its menu.
61899
+ window.addEventListener("contextmenu", this.onContextMenu, true);
61696
61900
  this.applyCameraGating();
61697
61901
  }
61698
61902
  get freehandColor() {
@@ -61713,14 +61917,22 @@ class SurfaceAnnotator {
61713
61917
  }
61714
61918
  setMode(m) {
61715
61919
  var _a, _b;
61716
- // When leaving geodesic mode, discard the in-progress geodesic not yet committed with Enter (clear leftover anchors and line).
61717
- if (m !== "geodesic" && this.activeGeo)
61718
- this.clearActiveGeo();
61920
+ // When leaving geodesic mode: commit an in-progress EDIT of a committed contour, or discard a
61921
+ // not-yet-committed new geodesic (clear leftover anchors and line).
61922
+ if (m !== "geodesic") {
61923
+ if (this.editingId)
61924
+ this.recommitGeodesic();
61925
+ else if (this.activeGeo)
61926
+ this.clearActiveGeo();
61927
+ }
61719
61928
  this.mode = m;
61720
61929
  // Record armed tool when choosing a drawing mode (not navigate).
61721
61930
  // Camera gating is NOT changed here — the user must use Space to enter drawing.
61722
61931
  if (m !== "navigate")
61723
61932
  this.armed = m;
61933
+ // Returning to Navigate clears the selection too (put everything away).
61934
+ if (m === "navigate")
61935
+ this.setSelected(null);
61724
61936
  this.applyCameraGating();
61725
61937
  (_b = (_a = this.o).onModeChange) === null || _b === void 0 ? void 0 : _b.call(_a, m);
61726
61938
  }
@@ -61732,21 +61944,23 @@ class SurfaceAnnotator {
61732
61944
  return this.store.list();
61733
61945
  }
61734
61946
  undo() {
61735
- // Geodesic in progress → undo the most recent anchor edit (both adding and canceling a point can be rolled back);
61736
- // otherwise undo the most recently committed annotation.
61947
+ // A geodesic being drawn/edited → undo the most recent anchor edit. afterGeoEdit() handles both
61948
+ // the in-progress and committed-edit cases uniformly, including degenerate cleanup (so undo can't
61949
+ // dispose a committed line or leave a stale edit session).
61737
61950
  if (this.activeGeo && this.activeGeo.canUndo()) {
61738
61951
  this.activeGeo.undoEdit();
61739
- if (this.activeGeo.anchorCount === 0)
61740
- this.clearActiveGeo();
61741
- else {
61742
- this.rebuildGeoMarkers();
61743
- this.redrawGeoLine();
61744
- }
61952
+ this.afterGeoEdit();
61745
61953
  return;
61746
61954
  }
61747
61955
  this.store.undo();
61748
61956
  }
61749
61957
  clearAll() {
61958
+ // Drop any edit session first; the edited line is owned by the store and disposed by clear() below.
61959
+ if (this.editingId) {
61960
+ this.activeGeoLine = undefined;
61961
+ this.editingId = null;
61962
+ this.geoClosed = false;
61963
+ }
61750
61964
  const removed = this.store.clear();
61751
61965
  removed.forEach((a) => {
61752
61966
  if (a.object3D) {
@@ -61759,13 +61973,49 @@ class SurfaceAnnotator {
61759
61973
  this.clearActiveGeo();
61760
61974
  }
61761
61975
  deleteAnnotation(id) {
61976
+ // If we're deleting the geodesic currently being edited, tear the edit session down first so
61977
+ // its anchor handles aren't orphaned in the scene (the line itself is removed by reconcile).
61978
+ if (this.editingId === id) {
61979
+ this.removeGeoMarkers();
61980
+ this.activeGeo = undefined;
61981
+ this.activeGeoLine = undefined;
61982
+ this.editingId = null;
61983
+ this.geoClosed = false;
61984
+ }
61762
61985
  if (this.selectedId === id)
61763
- this.selectedId = null;
61986
+ this.setSelected(null);
61764
61987
  this.store.remove(id);
61988
+ this.emitInteraction();
61765
61989
  }
61766
61990
  selectAnnotation(id) {
61991
+ var _a;
61992
+ // Commit any open geodesic edit before the selection changes.
61993
+ if (this.editingId && this.editingId !== id)
61994
+ this.recommitGeodesic();
61767
61995
  this.selectedId = id;
61768
61996
  this.applySelection();
61997
+ if (!id)
61998
+ return;
61999
+ const a = this.store.get(id);
62000
+ if (!a)
62001
+ return;
62002
+ // Selecting an annotation switches to the tool that drew it, so you can carry on editing it
62003
+ // (and a geodesic opens its anchor handles). points → Place point; contour → its draw mode.
62004
+ const tool = a.type === "points" ? "point" : (_a = a.mode) !== null && _a !== void 0 ? _a : "freehand";
62005
+ if (this.mode !== tool)
62006
+ this.setMode(tool);
62007
+ // With Geodesic now active, re-open the contour's anchors for editing.
62008
+ if (this.mode === "geodesic")
62009
+ this.tryOpenGeoEdit(id);
62010
+ }
62011
+ /** Change the selection from inside the engine (delete / deselect-on-navigate) and mirror it to the UI. */
62012
+ setSelected(id) {
62013
+ var _a, _b;
62014
+ if (this.selectedId === id)
62015
+ return;
62016
+ this.selectedId = id;
62017
+ this.applySelection();
62018
+ (_b = (_a = this.o).onSelectionChange) === null || _b === void 0 ? void 0 : _b.call(_a, id);
61769
62019
  }
61770
62020
  /** Redraw the corresponding three object after a color change. */
61771
62021
  refreshAnnotation(id) {
@@ -61796,17 +62046,11 @@ class SurfaceAnnotator {
61796
62046
  let count = 0;
61797
62047
  let maxImported = this.seq;
61798
62048
  for (const a of (_a = payload.annotations) !== null && _a !== void 0 ? _a : []) {
61799
- const verts = a.points.map((p) => {
61800
- const [x, y, z] = p;
61801
- let nx = p[3], ny = p[4], nz = p[5];
61802
- if (nx === undefined || ny === undefined || nz === undefined) {
61803
- const nrm = this.graph.nearestNormalLocal(new Vector3(x, y, z));
61804
- nx = nrm.x;
61805
- ny = nrm.y;
61806
- nz = nrm.z;
61807
- }
61808
- return { x, y, z, nx, ny, nz, faceIndex: 0 };
61809
- });
62049
+ const verts = a.points.map((p) => this.rawToVertex(p));
62050
+ // Geodesic control points, if the payload carries them → keep the contour editable.
62051
+ const anchors = a.mode === "geodesic" && a.anchors && a.anchors.length
62052
+ ? a.anchors.map((p) => this.rawToVertex(p))
62053
+ : undefined;
61810
62054
  if (a.type === "points") {
61811
62055
  for (const v of verts) {
61812
62056
  // Only reuse the provided id for a single-point entry (the round-trip case);
@@ -61847,6 +62091,7 @@ class SurfaceAnnotator {
61847
62091
  closed: a.closed,
61848
62092
  visible: (_d = a.visible) !== null && _d !== void 0 ? _d : true,
61849
62093
  vertices: verts,
62094
+ anchors,
61850
62095
  object3D: line,
61851
62096
  });
61852
62097
  const m = id.match(/^a(\d+)$/);
@@ -61865,10 +62110,19 @@ class SurfaceAnnotator {
61865
62110
  return this.drawLock || this.spaceHeld;
61866
62111
  }
61867
62112
  applyCameraGating() {
61868
- var _a, _b;
61869
62113
  // Default is navigate (camera enabled). Drawing only when drawLock or spaceHeld.
61870
62114
  this.o.controls.enabled = !this.drawing;
61871
- (_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, { drawing: this.drawing, armed: this.armed, locked: this.drawLock });
62115
+ this.emitInteraction();
62116
+ }
62117
+ /** Emit the current interaction state (camera gating + whether a geodesic is editable). */
62118
+ emitInteraction() {
62119
+ var _a, _b;
62120
+ (_b = (_a = this.o).onInteractionChange) === null || _b === void 0 ? void 0 : _b.call(_a, {
62121
+ drawing: this.drawing,
62122
+ armed: this.armed,
62123
+ locked: this.drawLock,
62124
+ editing: !!this.activeGeo,
62125
+ });
61872
62126
  }
61873
62127
  /** Reconcile the scene against store.list(): add missing objects, remove deleted ones (no dispose, kept for undo restore). */
61874
62128
  reconcile() {
@@ -61911,18 +62165,33 @@ class SurfaceAnnotator {
61911
62165
  }
61912
62166
  }
61913
62167
  disposeObject(o) {
61914
- var _a, _b, _c;
61915
- const any = o;
61916
- (_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
61917
- const mat = any.material;
61918
- if (Array.isArray(mat))
61919
- mat.forEach((m) => m.dispose());
61920
- else
61921
- (_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
62168
+ // Traverse so composite handles (Group of rim + core meshes) are fully freed, not just the root.
62169
+ o.traverse((c) => {
62170
+ var _a, _b, _c;
62171
+ const any = c;
62172
+ (_b = (_a = any.geometry) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
62173
+ const mat = any.material;
62174
+ if (Array.isArray(mat))
62175
+ mat.forEach((m) => m.dispose());
62176
+ else
62177
+ (_c = mat === null || mat === void 0 ? void 0 : mat.dispose) === null || _c === void 0 ? void 0 : _c.call(mat);
62178
+ });
61922
62179
  }
61923
62180
  nextId() {
61924
62181
  return "a" + ++this.seq;
61925
62182
  }
62183
+ /** 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. */
62184
+ rawToVertex(p) {
62185
+ const [x, y, z] = p;
62186
+ let nx = p[3], ny = p[4], nz = p[5];
62187
+ if (nx === undefined || ny === undefined || nz === undefined) {
62188
+ const nrm = this.graph.nearestNormalLocal(new Vector3(x, y, z));
62189
+ nx = nrm.x;
62190
+ ny = nrm.y;
62191
+ nz = nrm.z;
62192
+ }
62193
+ return { x, y, z, nx, ny, nz, faceIndex: 0 };
62194
+ }
61926
62195
  hit(e) {
61927
62196
  return raycastSurface(this.o.camera, this.o.container, this.o.mesh, e.clientX, e.clientY);
61928
62197
  }
@@ -61937,6 +62206,55 @@ class SurfaceAnnotator {
61937
62206
  const t = e.target;
61938
62207
  return !!t && t.tagName === "CANVAS" && this.o.container.contains(t);
61939
62208
  }
62209
+ /** After any anchor edit: refresh markers + line; drop/delete the contour when it becomes degenerate. */
62210
+ afterGeoEdit() {
62211
+ if (!this.activeGeo)
62212
+ return;
62213
+ if (this.editingId) {
62214
+ // Editing a committed contour: fewer than 2 anchors → delete the whole annotation.
62215
+ if (this.activeGeo.anchorCount < 2) {
62216
+ const id = this.editingId;
62217
+ this.removeGeoMarkers();
62218
+ this.activeGeo = undefined;
62219
+ this.activeGeoLine = undefined; // owned by the annotation; deleteAnnotation disposes it
62220
+ this.editingId = null;
62221
+ this.geoClosed = false;
62222
+ this.deleteAnnotation(id);
62223
+ this.emitInteraction();
62224
+ return;
62225
+ }
62226
+ this.rebuildGeoMarkers();
62227
+ this.redrawGeoLine();
62228
+ this.emitInteraction();
62229
+ return;
62230
+ }
62231
+ // In-progress new geodesic.
62232
+ if (this.activeGeo.anchorCount === 0) {
62233
+ this.clearActiveGeo();
62234
+ }
62235
+ else {
62236
+ this.rebuildGeoMarkers();
62237
+ this.redrawGeoLine();
62238
+ }
62239
+ this.emitInteraction();
62240
+ }
62241
+ /** Coalesce live drag redraws to one per animation frame (moveAnchorTo runs Dijkstra per move). */
62242
+ scheduleGeoRedraw() {
62243
+ if (this.geoRedrawScheduled)
62244
+ return;
62245
+ this.geoRedrawScheduled = true;
62246
+ requestAnimationFrame(() => {
62247
+ var _a;
62248
+ this.geoRedrawScheduled = false;
62249
+ this.rebuildGeoMarkers();
62250
+ this.redrawGeoLine();
62251
+ if (this.draggingAnchor >= 0) {
62252
+ this.o.container.style.cursor = "grabbing";
62253
+ // keep the grabbed handle emphasized through the live rebuilds
62254
+ (_a = this.activeGeoMarkers[this.draggingAnchor]) === null || _a === void 0 ? void 0 : _a.scale.setScalar(1.4);
62255
+ }
62256
+ });
62257
+ }
61940
62258
  /**
61941
62259
  * Pick an in-progress anchor: return the index of the nearest anchor (screen pixel distance <
61942
62260
  * tolerance), or -1 on a miss.
@@ -61973,71 +62291,30 @@ class SurfaceAnnotator {
61973
62291
  }
61974
62292
  return best;
61975
62293
  }
61976
- /** Set the currently hovered anchor (-1 = none): update highlight scale, cursor, and the "✕ (cancel)" floating badge. */
62294
+ /**
62295
+ * Set the currently hovered anchor (-1 = none): scale it up as a highlight and show a "grab"
62296
+ * cursor to signal it's draggable. Delete is now a right-click (no floating ✕ affordance).
62297
+ */
61977
62298
  setGeoHover(idx) {
61978
- if (idx === this.hoveredGeoMarker) {
61979
- if (idx >= 0)
61980
- this.positionGeoBadge(this.activeGeoMarkers[idx]);
62299
+ if (idx === this.hoveredGeoMarker)
61981
62300
  return;
61982
- }
61983
62301
  const prev = this.activeGeoMarkers[this.hoveredGeoMarker];
61984
62302
  if (prev)
61985
62303
  prev.scale.setScalar(1);
61986
62304
  this.hoveredGeoMarker = idx;
61987
62305
  const cur = this.activeGeoMarkers[idx];
62306
+ // Don't fight the "grabbing" cursor set during an active drag.
62307
+ if (this.draggingAnchor >= 0)
62308
+ return;
61988
62309
  if (cur) {
61989
- cur.scale.setScalar(1.3);
61990
- this.ensureGeoBadge().style.display = "flex";
61991
- this.positionGeoBadge(cur);
61992
- this.o.container.style.cursor = "pointer";
62310
+ cur.scale.setScalar(1.35);
62311
+ this.o.container.style.cursor = "grab";
61993
62312
  }
61994
62313
  else {
61995
- if (this.geoHoverBadge)
61996
- this.geoHoverBadge.style.display = "none";
61997
62314
  this.o.container.style.cursor = "";
61998
62315
  }
61999
62316
  }
62000
- /** Lazily create the floating badge (appended inside the container, pointer-events:none so it doesn't block clicks). */
62001
- ensureGeoBadge() {
62002
- if (this.geoHoverBadge)
62003
- return this.geoHoverBadge;
62004
- const b = document.createElement("div");
62005
- b.textContent = "✕";
62006
- Object.assign(b.style, {
62007
- position: "absolute",
62008
- width: "16px",
62009
- height: "16px",
62010
- borderRadius: "50%",
62011
- background: "#ff5d6c",
62012
- color: "#fff",
62013
- font: "700 10px/1 system-ui, sans-serif",
62014
- display: "none",
62015
- alignItems: "center",
62016
- justifyContent: "center",
62017
- pointerEvents: "none",
62018
- zIndex: "15",
62019
- boxShadow: "0 2px 6px rgba(0,0,0,.45)",
62020
- transform: "translate(-50%, -50%)",
62021
- left: "0px",
62022
- top: "0px",
62023
- });
62024
- this.o.container.appendChild(b);
62025
- this.geoHoverBadge = b;
62026
- return b;
62027
- }
62028
- /** Position the ✕ badge at the anchor's screen projection (dead center, i.e. where the cursor hovers, to avoid ambiguity). */
62029
- positionGeoBadge(marker) {
62030
- if (!marker)
62031
- return;
62032
- const b = this.ensureGeoBadge();
62033
- this._projV.copy(marker.position).project(this.o.camera);
62034
- const rect = this.o.container.getBoundingClientRect();
62035
- const x = (this._projV.x * 0.5 + 0.5) * rect.width;
62036
- const y = (-this._projV.y * 0.5 + 0.5) * rect.height;
62037
- b.style.left = `${x}px`;
62038
- b.style.top = `${y}px`;
62039
- }
62040
- /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and click to cancel). */
62317
+ /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and grab). */
62041
62318
  rebuildGeoMarkers() {
62042
62319
  this.setGeoHover(-1);
62043
62320
  for (const m of this.activeGeoMarkers) {
@@ -62048,18 +62325,20 @@ class SurfaceAnnotator {
62048
62325
  if (!this.activeGeo)
62049
62326
  return;
62050
62327
  for (const v of this.activeGeo.getAnchorLocals()) {
62051
- const marker = makePointMarker(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.15);
62328
+ const marker = makeAnchorHandle(v, this.o.mesh, this.geodesicColor, this.markerRadius * 1.7);
62052
62329
  this.o.scene.add(marker);
62053
62330
  this.activeGeoMarkers.push(marker);
62054
62331
  }
62055
62332
  }
62056
- /** Redraw the in-progress geodesic from the current anchors (not closed); remove the line when fewer than two points. */
62333
+ /** 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. */
62057
62334
  redrawGeoLine() {
62058
62335
  if (!this.activeGeo)
62059
62336
  return;
62060
- const verts = this.activeGeo.buildVertices(false);
62337
+ const closed = this.geoClosed;
62338
+ const verts = this.activeGeo.buildVertices(closed);
62061
62339
  if (verts.length < 2) {
62062
- if (this.activeGeoLine) {
62340
+ // Only dispose the line for an in-progress geodesic; when editing, the line belongs to the annotation.
62341
+ if (this.activeGeoLine && !this.editingId) {
62063
62342
  this.o.scene.remove(this.activeGeoLine);
62064
62343
  this.disposeObject(this.activeGeoLine);
62065
62344
  this.activeGeoLine = undefined;
@@ -62067,11 +62346,20 @@ class SurfaceAnnotator {
62067
62346
  return;
62068
62347
  }
62069
62348
  if (!this.activeGeoLine) {
62070
- this.activeGeoLine = makeContourLine(verts, this.geodesicColor, false, this.o.container, this.epsilon, this.o.mesh);
62349
+ this.activeGeoLine = makeContourLine(verts, this.geodesicColor, closed, this.o.container, this.epsilon, this.o.mesh);
62071
62350
  this.o.scene.add(this.activeGeoLine);
62072
62351
  }
62073
62352
  else {
62074
- updateContourLine(this.activeGeoLine, verts, false, this.epsilon, this.o.mesh);
62353
+ updateContourLine(this.activeGeoLine, verts, closed, this.epsilon, this.o.mesh);
62354
+ }
62355
+ // Live-sync the committed annotation's data while it is being edited.
62356
+ if (this.editingId) {
62357
+ const a = this.store.get(this.editingId);
62358
+ if (a) {
62359
+ a.vertices = verts;
62360
+ a.closed = closed;
62361
+ a.anchors = this.activeGeo.getAnchorLocals();
62362
+ }
62075
62363
  }
62076
62364
  }
62077
62365
  /** Discard the in-progress geodesic: remove and dispose the line and all anchors. */
@@ -62088,6 +62376,7 @@ class SurfaceAnnotator {
62088
62376
  }
62089
62377
  this.activeGeoMarkers = [];
62090
62378
  this.activeGeo = undefined;
62379
+ this.emitInteraction();
62091
62380
  }
62092
62381
  /** Remove all anchor spheres of the in-progress geodesic (called after committing: only the line remains). */
62093
62382
  removeGeoMarkers() {
@@ -62134,6 +62423,7 @@ class SurfaceAnnotator {
62134
62423
  closed,
62135
62424
  visible: true,
62136
62425
  vertices: verts,
62426
+ anchors: this.activeGeo.getAnchorLocals(),
62137
62427
  object3D: this.activeGeoLine,
62138
62428
  };
62139
62429
  this.store.add(ann);
@@ -62146,6 +62436,61 @@ class SurfaceAnnotator {
62146
62436
  this.removeGeoMarkers();
62147
62437
  this.activeGeo = undefined;
62148
62438
  this.activeGeoLine = undefined;
62439
+ this.geoClosed = false;
62440
+ this.emitInteraction();
62441
+ }
62442
+ /**
62443
+ * Re-open a committed geodesic contour for editing: rebuild its anchors (from the stored positions)
62444
+ * as draggable markers and reuse its line for live updates. No-op for non-geodesic / anchorless
62445
+ * annotations. The edit session ends on Enter / mode-switch / selecting another / Esc.
62446
+ */
62447
+ tryOpenGeoEdit(id) {
62448
+ var _a;
62449
+ if (this.editingId === id)
62450
+ return;
62451
+ const a = this.store.get(id);
62452
+ if (!a || a.type !== "contour" || a.mode !== "geodesic" || !a.anchors || a.anchors.length < 2) {
62453
+ return;
62454
+ }
62455
+ // Discard a half-drawn (uncommitted) geodesic before entering the edit session.
62456
+ if (this.activeGeo && !this.editingId)
62457
+ this.clearActiveGeo();
62458
+ const indices = a.anchors.map((v) => this.graph.nearestVertex(new Vector3(v.x, v.y, v.z)));
62459
+ this.activeGeo = GeodesicContour.fromAnchors(this.graph, this.o.mesh, indices);
62460
+ this.activeGeoLine = (_a = a.object3D) !== null && _a !== void 0 ? _a : undefined;
62461
+ this.editingId = id;
62462
+ this.geoClosed = a.closed;
62463
+ this.rebuildGeoMarkers();
62464
+ this.redrawGeoLine();
62465
+ this.emitInteraction();
62466
+ }
62467
+ /**
62468
+ * Finalize an edit session on a committed geodesic: the annotation data was kept in sync live
62469
+ * (redrawGeoLine), so here we just remove the edit markers, notify subscribers, and end the
62470
+ * session — or delete the annotation if it was reduced below 2 anchors.
62471
+ */
62472
+ recommitGeodesic() {
62473
+ const id = this.editingId;
62474
+ if (!id)
62475
+ return;
62476
+ if (!this.activeGeo || this.activeGeo.anchorCount < 2) {
62477
+ this.removeGeoMarkers();
62478
+ this.activeGeo = undefined;
62479
+ this.activeGeoLine = undefined; // owned by the annotation
62480
+ this.editingId = null;
62481
+ this.geoClosed = false;
62482
+ this.deleteAnnotation(id);
62483
+ this.emitInteraction();
62484
+ return;
62485
+ }
62486
+ this.redrawGeoLine(); // final data sync
62487
+ this.removeGeoMarkers();
62488
+ this.activeGeo = undefined;
62489
+ this.activeGeoLine = undefined; // owned by the annotation, do not dispose
62490
+ this.editingId = null;
62491
+ this.geoClosed = false;
62492
+ this.store.touch();
62493
+ this.emitInteraction();
62149
62494
  }
62150
62495
  isTypingTarget(e) {
62151
62496
  const t = e.target;
@@ -62155,16 +62500,14 @@ class SurfaceAnnotator {
62155
62500
  return tag === "INPUT" || tag === "TEXTAREA" || t.isContentEditable;
62156
62501
  }
62157
62502
  dispose() {
62158
- var _a;
62159
62503
  this.clearActiveGeo();
62160
- (_a = this.geoHoverBadge) === null || _a === void 0 ? void 0 : _a.remove();
62161
- this.geoHoverBadge = undefined;
62162
62504
  window.removeEventListener("pointerdown", this.onPointerDown, true);
62163
62505
  window.removeEventListener("pointermove", this.onPointerMove, true);
62164
62506
  window.removeEventListener("pointerup", this.onPointerUp, true);
62165
62507
  window.removeEventListener("keydown", this.onKeyDown);
62166
62508
  window.removeEventListener("keyup", this.onKeyUp);
62167
62509
  window.removeEventListener("resize", this.onResize);
62510
+ window.removeEventListener("contextmenu", this.onContextMenu, true);
62168
62511
  }
62169
62512
  }
62170
62513
  SurfaceAnnotator.TAP_MS = 250;
@@ -83597,15 +83940,15 @@ function evaluateElement(element, weights) {
83597
83940
  }
83598
83941
 
83599
83942
  // import * as kiwrious from "copper3d_plugin_heart_k";
83600
- // "v3.6.9" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version.
83943
+ // "v3.6.10" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version.
83601
83944
  // When copper3d is consumed from local source (no rollup replace step), the identifier is undefined and
83602
83945
  // referencing it throws a ReferenceError — guard it so local loading still works.
83603
83946
  let _revision = "unknown";
83604
83947
  try {
83605
- _revision = "v3.6.9";
83948
+ _revision = "v3.6.10";
83606
83949
  }
83607
83950
  catch (_a) {
83608
- /* "v3.6.9" not injected (local source build) */
83951
+ /* "v3.6.10" not injected (local source build) */
83609
83952
  }
83610
83953
  const REVISION = _revision;
83611
83954
  // Expose on global so the version can be read in a production browser console via window.__COPPER3D_VERSION__