copper3d 3.6.4 → 3.6.6

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.
@@ -79124,14 +79124,29 @@ void main() {
79124
79124
  this.committedVol = null;
79125
79125
  this.promptTool = "point";
79126
79126
  this.polarity = 1; // 1 = foreground (positive), 0 = background
79127
- /** Target channel/label (1-8) the AI paints into the "AI layer" channel. */
79128
- this.channel = 1;
79127
+ /** The label value (1-255) the AI currently paints into = the active Segmentation.
79128
+ * Each Segmentation is a label value in the single-channel scratch volume; its
79129
+ * colour lives in the volume's colorMap (set via setSegmentColor). */
79130
+ this.activeLabel = 1;
79129
79131
  /** Scribble brush radius (px), user-adjustable via slider. */
79130
79132
  this.scribbleSize = 5;
79131
79133
  /** Accumulated prompts for the CURRENT slice; reset on slice/axis change. */
79132
79134
  this.points = [];
79133
79135
  this.scribble = [];
79136
+ /** Lasso contour sent to the backend (densified along the curve at finish). */
79137
+ this.lasso = [];
79134
79138
  this.activeSlice = -1;
79139
+ // ── Lasso v2 editing state (discrete clicked vertices → smooth closed curve) ──
79140
+ /** Placed lasso vertices (voxel + screen + polarity). Empty = not editing. */
79141
+ this.lassoVerts = [];
79142
+ /** Undo / redo snapshot stacks of the vertex list (for Ctrl+Z / Ctrl+Y). */
79143
+ this.lassoUndo = [];
79144
+ this.lassoRedo = [];
79145
+ /** Index of the vertex currently hovered (shows a red ✕; click removes it). −1 = none. */
79146
+ this.lassoHoverIdx = -1;
79147
+ /** Double-click detection (a dbl-click finishes the lasso). */
79148
+ this.lastLassoDownTime = 0;
79149
+ this.lastLassoDownPos = { x: 0, y: 0 };
79135
79150
  // Drag state (box / scribble) — voxel coords for the prompt
79136
79151
  this.dragging = false;
79137
79152
  this.dragStart = null;
@@ -79142,8 +79157,14 @@ void main() {
79142
79157
  // Live cursor position (screen px) for the scribble brush-size preview ring —
79143
79158
  // updated on every hover move so the ring follows the mouse like the paint brush.
79144
79159
  this.hoverScreen = null;
79160
+ /** Hide the point seed markers once a prediction has painted (only the mask
79161
+ * should remain). A new point click re-shows them until its mask returns. */
79162
+ this.hidePointMarkers = false;
79145
79163
  /** Fired when a prompt gesture completes — app calls backend, then applyMask(). */
79146
79164
  this.onPrompt = null;
79165
+ /** Fired whenever the lasso vertex set changes (add/delete/undo/redo/finish/cancel),
79166
+ * so the panel can reactively show the Finish button + vertex count. */
79167
+ this.onLassoChange = null;
79147
79168
  this.host = host;
79148
79169
  }
79149
79170
  // ── Configuration (driven from the panel via NrrdTools) ────────────────────
@@ -79161,16 +79182,51 @@ void main() {
79161
79182
  setScribbleSize(size) {
79162
79183
  this.scribbleSize = Math.max(1, Math.min(40, Math.round(size)));
79163
79184
  }
79164
- setChannel(channel) {
79165
- const c = Math.max(1, Math.min(8, Math.round(channel)));
79166
- // Changing channel starts a NEW region in the new colour otherwise the
79167
- // accumulated points would be re-predicted and repainted in the new label,
79168
- // recolouring the previous channel's region.
79169
- if (c !== this.channel)
79185
+ /** Select the active Segmentation by its label value (1-255). Switching starts a
79186
+ * fresh prompt set otherwise accumulated prompts would be re-predicted and
79187
+ * repainted into the new label, recolouring the previous segmentation's region. */
79188
+ setActiveSegment(label) {
79189
+ const c = Math.max(1, Math.min(255, Math.round(label)));
79190
+ if (c !== this.activeLabel)
79170
79191
  this.resetPrompts();
79171
- this.channel = c;
79192
+ this.activeLabel = c;
79193
+ }
79194
+ getActiveLabel() { return this.activeLabel; }
79195
+ /** Set a Segmentation's colour (its label's entry in the scratch volume colorMap).
79196
+ * The 2D overlay repaints next frame (copper3d's render loop re-reads the map). */
79197
+ setSegmentColor(label, color) {
79198
+ var _a;
79199
+ (_a = this.scratchVol) === null || _a === void 0 ? void 0 : _a.setChannelColor(Math.round(label), color);
79200
+ }
79201
+ /** "New segmentation": freeze the current regions (so they persist) and switch to
79202
+ * a fresh label, exactly like the old "New region" but targeting a new label. */
79203
+ newSegment(label) {
79204
+ this.commitRegion();
79205
+ this.setActiveSegment(label);
79206
+ }
79207
+ /** Delete a Segmentation: erase every voxel painted with its label from BOTH the
79208
+ * live scratch and the frozen (committed) volume, so the region disappears from
79209
+ * the view (otherwise orphaned label voxels would render with the wrong colour).
79210
+ * Goes via setRawData so the volume's version counter bumps (overlay repaints). */
79211
+ clearSegment(label) {
79212
+ const lbl = Math.round(label);
79213
+ const zeroOut = (v) => {
79214
+ if (!v)
79215
+ return;
79216
+ const raw = v.getRawData().slice();
79217
+ let changed = false;
79218
+ for (let i = 0; i < raw.length; i++) {
79219
+ if (raw[i] === lbl) {
79220
+ raw[i] = 0;
79221
+ changed = true;
79222
+ }
79223
+ }
79224
+ if (changed)
79225
+ v.setRawData(raw);
79226
+ };
79227
+ zeroOut(this.scratchVol);
79228
+ zeroOut(this.committedVol);
79172
79229
  }
79173
- getChannel() { return this.channel; }
79174
79230
  getScratchVolume() { return this.scratchVol; }
79175
79231
  /** Serialize the scratch volume as per-z-slice RLE (only NON-empty slices),
79176
79232
  * for persisting to ai_generated_nii_LPS on the backend. Binary (any channel →
@@ -79212,6 +79268,50 @@ void main() {
79212
79268
  }
79213
79269
  return { axis: "z", width, height, slices: out };
79214
79270
  }
79271
+ /** Per-SEGMENTATION serialization for the 3D build: one binary RLE per non-empty
79272
+ * z-slice PER label (no binarize-merge), so the backend writes a multi-label
79273
+ * NIfTI and colours the GLB per segmentation. Includes both live and frozen
79274
+ * regions (both live in scratchVol). */
79275
+ getScratchSegments() {
79276
+ if (!this.scratchVol)
79277
+ return null;
79278
+ const { depth } = this.scratchVol.getDimensions();
79279
+ const byLabel = new Map();
79280
+ let width = 0;
79281
+ let height = 0;
79282
+ for (let z = 0; z < depth; z++) {
79283
+ const { data, width: w, height: h } = this.scratchVol.getSliceUint8(z, "z");
79284
+ width = w;
79285
+ height = h;
79286
+ const labels = new Set();
79287
+ for (let i = 0; i < data.length; i++) {
79288
+ if (data[i])
79289
+ labels.add(data[i]);
79290
+ }
79291
+ for (const label of labels) {
79292
+ const runs = [];
79293
+ let value = 0; // always begin with a 0-run (matches backend rle_decode)
79294
+ let count = 0;
79295
+ for (let i = 0; i < data.length; i++) {
79296
+ const v = data[i] === label ? 1 : 0;
79297
+ if (v === value) {
79298
+ count++;
79299
+ }
79300
+ else {
79301
+ runs.push(count);
79302
+ value = v;
79303
+ count = 1;
79304
+ }
79305
+ }
79306
+ runs.push(count);
79307
+ if (!byLabel.has(label))
79308
+ byLabel.set(label, []);
79309
+ byLabel.get(label).push({ sliceIndex: z, rle: runs });
79310
+ }
79311
+ }
79312
+ const segments = [...byLabel.entries()].map(([label, slices]) => ({ label, slices }));
79313
+ return { axis: "z", width, height, segments };
79314
+ }
79215
79315
  // ── Sandbox lifecycle ──────────────────────────────────────────────────────
79216
79316
  /** Create (or reuse) the scratch volume sized to the layer grid and register
79217
79317
  * it under maskData.volumes['aiScratch']. Snapshots the empty buffer. */
@@ -79232,14 +79332,10 @@ void main() {
79232
79332
  this.scratchVol.clear();
79233
79333
  }
79234
79334
  volumes[AI_SCRATCH_LAYER] = this.scratchVol;
79235
- // Paint the AI scratch with the AI-Assist palette (channel 1 = cyan) so the 2D
79236
- // overlay matches the cyan ai_generated GLB. Scoped to THIS volume only — the
79237
- // global palette / clinician mask colours are untouched.
79238
- for (let ch = 1; ch <= 8; ch++) {
79239
- const c = AI_MASK_CHANNEL_COLORS[ch];
79240
- if (c)
79241
- this.scratchVol.setChannelColor(ch, { r: c.r, g: c.g, b: c.b, a: c.a });
79242
- }
79335
+ // Segmentation colours are now driven per-label from the app (store useAiAssist
79336
+ // setSegmentColor) right after enter, so the scratch can carry an arbitrary
79337
+ // number of independently-coloured segmentations. No fixed palette is applied
79338
+ // here. Scoped to THIS volume only global/clinician palettes are untouched.
79243
79339
  this.snapshotData = this.scratchVol.getRawData().slice();
79244
79340
  this.committedVol = null; // nothing frozen at session start
79245
79341
  this.resetPrompts();
@@ -79285,10 +79381,26 @@ void main() {
79285
79381
  resetPrompts() {
79286
79382
  this.points = [];
79287
79383
  this.scribble = [];
79384
+ this.lasso = [];
79288
79385
  this.box = undefined;
79289
79386
  this.dragScreenStart = null;
79290
79387
  this.dragScreen = null;
79291
79388
  this.screenScribble = [];
79389
+ this.clearLassoEditing();
79390
+ }
79391
+ /** Drop the in-progress lasso vertices + undo/redo history (does not touch a
79392
+ * prediction already painted from a finished lasso). */
79393
+ clearLassoEditing() {
79394
+ this.lassoVerts = [];
79395
+ this.lassoUndo = [];
79396
+ this.lassoRedo = [];
79397
+ this.lassoHoverIdx = -1;
79398
+ this.notifyLasso();
79399
+ }
79400
+ /** Tell the app layer the lasso vertex set changed (drives the Finish button). */
79401
+ notifyLasso() {
79402
+ var _a;
79403
+ (_a = this.onLassoChange) === null || _a === void 0 ? void 0 : _a.call(this, this.lassoVerts.length, this.lassoVerts.length > 0);
79292
79404
  }
79293
79405
  // ── Coordinate mapping (all three views) ───────────────────────────────────
79294
79406
  //
@@ -79349,14 +79461,53 @@ void main() {
79349
79461
  return null;
79350
79462
  return { vx, vy, w: sd.w, h: sd.h };
79351
79463
  }
79464
+ /** Voxel-slice (vx,vy) → screen offset (px). Exact inverse of toVoxel, so
79465
+ * markers / lasso curves drawn from voxel coords track pan AND zoom each frame
79466
+ * (the freehand previews store screen px directly; persistent overlays don't). */
79467
+ voxelToScreen(vx, vy) {
79468
+ const sd = this.sliceDims();
79469
+ if (!sd)
79470
+ return null;
79471
+ const nrrd = this.ctx.nrrd_states;
79472
+ const img = nrrd.image;
79473
+ let hMM, vMM, flipV;
79474
+ switch (this.ctx.protectedData.axis) {
79475
+ case "z":
79476
+ hMM = img.nrrd_x_mm;
79477
+ vMM = img.nrrd_y_mm;
79478
+ flipV = false;
79479
+ break;
79480
+ case "y":
79481
+ hMM = img.nrrd_x_mm;
79482
+ vMM = img.nrrd_z_mm;
79483
+ flipV = true;
79484
+ break;
79485
+ case "x":
79486
+ hMM = img.nrrd_z_mm;
79487
+ vMM = img.nrrd_y_mm;
79488
+ flipV = false;
79489
+ break;
79490
+ default: return null;
79491
+ }
79492
+ const vyRaw = flipV ? sd.h - 1 - vy : vy;
79493
+ const x = (vx * hMM / sd.w) * nrrd.view.sizeFactor;
79494
+ const y = (vyRaw * vMM / sd.h) * nrrd.view.sizeFactor;
79495
+ return { x, y };
79496
+ }
79352
79497
  // ── Pointer handlers (left button; right stays pan in EventRouter) ──────────
79353
79498
  onPointerDown(e) {
79354
79499
  const hit = this.toVoxel(e);
79355
79500
  if (!hit)
79356
79501
  return;
79357
79502
  this.syncSlice();
79503
+ // Lasso is click-to-place vertices (NOT a drag) — own handler.
79504
+ if (this.promptTool === "lasso") {
79505
+ this.onLassoPointerDown(e, hit.vx, hit.vy);
79506
+ return;
79507
+ }
79358
79508
  if (this.promptTool === "point") {
79359
79509
  this.points.push({ x: hit.vx, y: hit.vy, label: this.polarity });
79510
+ this.hidePointMarkers = false; // show the new seed until its mask returns
79360
79511
  this.emit();
79361
79512
  }
79362
79513
  else {
@@ -79372,9 +79523,43 @@ void main() {
79372
79523
  }
79373
79524
  }
79374
79525
  }
79526
+ /** Lasso click: double-click finishes; click on a vertex deletes it; otherwise
79527
+ * add a vertex. Nothing is sent to the backend until finishLasso(). */
79528
+ onLassoPointerDown(e, vx, vy) {
79529
+ const now = Date.now();
79530
+ const dxl = e.offsetX - this.lastLassoDownPos.x;
79531
+ const dyl = e.offsetY - this.lastLassoDownPos.y;
79532
+ const isDbl = now - this.lastLassoDownTime < 300 &&
79533
+ dxl * dxl + dyl * dyl <= 36; // ~6px
79534
+ this.lastLassoDownTime = now;
79535
+ this.lastLassoDownPos = { x: e.offsetX, y: e.offsetY };
79536
+ // Double-click → finish (the 1st click of the pair already placed the vertex).
79537
+ if (isDbl && this.lassoVerts.length >= 3) {
79538
+ this.finishLasso();
79539
+ return;
79540
+ }
79541
+ // Click on an existing vertex → delete it (recompute the curve).
79542
+ const idx = this.lassoVertAt(e.offsetX, e.offsetY);
79543
+ if (idx >= 0) {
79544
+ this.pushLassoUndo();
79545
+ this.lassoVerts.splice(idx, 1);
79546
+ this.lassoHoverIdx = -1;
79547
+ this.notifyLasso();
79548
+ return;
79549
+ }
79550
+ // Otherwise add a new vertex.
79551
+ this.pushLassoUndo();
79552
+ this.lassoVerts.push({ vx, vy, sx: e.offsetX, sy: e.offsetY, label: this.polarity });
79553
+ this.notifyLasso();
79554
+ }
79375
79555
  onPointerMove(e) {
79376
- // Track hover for the scribble preview ring even when not drawing.
79556
+ // Track hover for the scribble preview ring / point crosshair even when idle.
79377
79557
  this.hoverScreen = { x: e.offsetX, y: e.offsetY };
79558
+ // Lasso: update which vertex (if any) is hovered, for the red ✕ delete affordance.
79559
+ if (this.promptTool === "lasso") {
79560
+ this.lassoHoverIdx = this.lassoVertAt(e.offsetX, e.offsetY);
79561
+ return; // lasso is click-based; no drag accumulation
79562
+ }
79378
79563
  if (!this.dragging)
79379
79564
  return;
79380
79565
  // Track screen pos first so the live preview follows even slightly out of bounds.
@@ -79427,12 +79612,116 @@ void main() {
79427
79612
  box: this.box,
79428
79613
  scribble: this.scribble.length ? [...this.scribble] : undefined,
79429
79614
  scribbleRadius: this.scribbleSize,
79615
+ lasso: this.lasso.length ? [...this.lasso] : undefined,
79430
79616
  });
79431
79617
  }
79618
+ // ── Lasso v2 (discrete vertices → smooth closed curve) ──────────────────────
79619
+ /** Index of the lasso vertex under (sx,sy) within the hit radius, else −1.
79620
+ * Uses live voxel→screen so it tracks pan/zoom. Topmost (latest) wins. */
79621
+ lassoVertAt(sx, sy) {
79622
+ var _a;
79623
+ const r2 = AiAssistTool.LASSO_HIT_R * AiAssistTool.LASSO_HIT_R;
79624
+ for (let i = this.lassoVerts.length - 1; i >= 0; i--) {
79625
+ const v = this.lassoVerts[i];
79626
+ const s = (_a = this.voxelToScreen(v.vx, v.vy)) !== null && _a !== void 0 ? _a : { x: v.sx, y: v.sy };
79627
+ const dx = sx - s.x, dy = sy - s.y;
79628
+ if (dx * dx + dy * dy <= r2)
79629
+ return i;
79630
+ }
79631
+ return -1;
79632
+ }
79633
+ /** Snapshot the vertex list for undo (clears redo). */
79634
+ pushLassoUndo() {
79635
+ this.lassoUndo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79636
+ this.lassoRedo = [];
79637
+ if (this.lassoUndo.length > 100)
79638
+ this.lassoUndo.shift();
79639
+ }
79640
+ /** Public: undo the last add/delete of a lasso vertex (Ctrl+Z while editing). */
79641
+ lassoUndoAction() {
79642
+ if (!this.lassoUndo.length)
79643
+ return;
79644
+ this.lassoRedo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79645
+ this.lassoVerts = this.lassoUndo.pop();
79646
+ this.lassoHoverIdx = -1;
79647
+ this.notifyLasso();
79648
+ }
79649
+ /** Public: redo (Ctrl+Y / Ctrl+Shift+Z while editing). */
79650
+ lassoRedoAction() {
79651
+ if (!this.lassoRedo.length)
79652
+ return;
79653
+ this.lassoUndo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79654
+ this.lassoVerts = this.lassoRedo.pop();
79655
+ this.lassoHoverIdx = -1;
79656
+ this.notifyLasso();
79657
+ }
79658
+ /** Public: abandon the in-progress lasso (Esc). */
79659
+ cancelLasso() { this.clearLassoEditing(); }
79660
+ /** Public: true while the user is placing lasso vertices (≥1 vertex). */
79661
+ isLassoEditing() { return this.lassoVerts.length > 0; }
79662
+ /** Public: number of placed lasso vertices (drives the Finish button label/enable). */
79663
+ getLassoVertCount() { return this.lassoVerts.length; }
79664
+ /** Vertices ordered by polar angle around their centroid, so the closed curve is
79665
+ * a SIMPLE (non-self-intersecting) loop regardless of click order — otherwise
79666
+ * connecting points in click order + closing easily produces a bow-tie crossing.
79667
+ * Trade-off: deep concavities collapse to their star-shaped hull, which is fine
79668
+ * for a "rough loop around a blob". */
79669
+ orderedLassoVerts() {
79670
+ const n = this.lassoVerts.length;
79671
+ if (n < 3)
79672
+ return this.lassoVerts.slice();
79673
+ let cx = 0, cy = 0;
79674
+ for (const v of this.lassoVerts) {
79675
+ cx += v.vx;
79676
+ cy += v.vy;
79677
+ }
79678
+ cx /= n;
79679
+ cy /= n;
79680
+ return this.lassoVerts.slice().sort((a, b) => Math.atan2(a.vy - cy, a.vx - cx) - Math.atan2(b.vy - cy, b.vx - cx));
79681
+ }
79682
+ /** Public: close the curve, densify it, and send it to the backend as the lasso
79683
+ * contour. Needs ≥3 vertices (a real area). Then clears the editing state. */
79684
+ finishLasso() {
79685
+ if (this.lassoVerts.length < 3)
79686
+ return;
79687
+ // Order by angle (no self-crossing), then sample the SAME closed curve the user
79688
+ // saw → contour points (so the mask matches the curve, not a straight polygon).
79689
+ const contour = this.sampleClosedSpline(this.orderedLassoVerts().map((v) => ({ x: v.vx, y: v.vy })), 16);
79690
+ const label = this.lassoVerts[0].label; // a lasso carries one polarity (set at start)
79691
+ this.lasso = contour.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y), label }));
79692
+ this.clearLassoEditing();
79693
+ this.emit();
79694
+ }
79695
+ /** Sample a smooth CLOSED Catmull-Rom spline through `pts` → denser polyline.
79696
+ * `segs` samples per control-point span. <3 pts → returns a copy unchanged. */
79697
+ sampleClosedSpline(pts, segs) {
79698
+ const n = pts.length;
79699
+ if (n < 3)
79700
+ return pts.slice();
79701
+ const at = (i) => pts[((i % n) + n) % n];
79702
+ const out = [];
79703
+ for (let i = 0; i < n; i++) {
79704
+ const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
79705
+ for (let s = 0; s < segs; s++) {
79706
+ const t = s / segs, t2 = t * t, t3 = t2 * t;
79707
+ out.push({
79708
+ x: 0.5 * (2 * p1.x + (-p0.x + p2.x) * t +
79709
+ (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
79710
+ (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
79711
+ y: 0.5 * (2 * p1.y + (-p0.y + p2.y) * t +
79712
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
79713
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
79714
+ });
79715
+ }
79716
+ }
79717
+ return out;
79718
+ }
79432
79719
  // ── Apply backend result → scratch volume ──────────────────────────────────
79433
79720
  applyMask(result) {
79434
79721
  if (!this.scratchVol)
79435
79722
  return;
79723
+ // A prediction landed → hide the point seed markers; only the mask should show.
79724
+ this.hidePointMarkers = true;
79436
79725
  // 3D result (engine B): write each slice across the covered span.
79437
79726
  if (result.slices && result.sliceRange) {
79438
79727
  const [lo] = result.sliceRange;
@@ -79462,7 +79751,7 @@ void main() {
79462
79751
  pos += run;
79463
79752
  value ^= 1;
79464
79753
  }
79465
- const ch = this.channel;
79754
+ const ch = this.activeLabel;
79466
79755
  try {
79467
79756
  const out = this.scratchVol.getSliceUint8(sliceIndex, axis).data; // copy
79468
79757
  // Frozen same-channel pixels for this slice (if any region was committed).
@@ -79535,10 +79824,7 @@ void main() {
79535
79824
  }
79536
79825
  targetCtx.restore();
79537
79826
  }
79538
- // Scribble brush-size preview ring (when NOT dragging) — a circle centred on
79539
- // the cursor whose radius = scribbleSize, so the user sees the brush size and
79540
- // how the slider changes it (mirrors the paint brush's preview ring). Drawn
79541
- // every frame from the live hover position by copper3d's render loop.
79827
+ // Scribble brush-size preview ring (when NOT dragging).
79542
79828
  if (this.promptTool === "scribble" && !this.dragging && this.hoverScreen) {
79543
79829
  targetCtx.save();
79544
79830
  const fg = this.polarity === 1;
@@ -79551,8 +79837,105 @@ void main() {
79551
79837
  targetCtx.stroke();
79552
79838
  targetCtx.restore();
79553
79839
  }
79840
+ // Point markers + hover crosshair so the clinician sees exactly where each seed
79841
+ // lands (and where the next click will go) before/while the prediction returns.
79842
+ if (this.promptTool === "point") {
79843
+ if (!this.hidePointMarkers) {
79844
+ for (const p of this.points) {
79845
+ const s = this.voxelToScreen(p.x, p.y);
79846
+ if (s)
79847
+ this.drawSeedMarker(targetCtx, s.x, s.y, p.label === 1);
79848
+ }
79849
+ }
79850
+ if (!this.dragging && this.hoverScreen) {
79851
+ targetCtx.save();
79852
+ targetCtx.strokeStyle = this.polarity === 1
79853
+ ? "rgba(120,255,180,0.55)" : "rgba(255,120,120,0.55)";
79854
+ targetCtx.lineWidth = 1;
79855
+ const { x, y } = this.hoverScreen;
79856
+ targetCtx.beginPath();
79857
+ targetCtx.moveTo(x - 7, y);
79858
+ targetCtx.lineTo(x + 7, y);
79859
+ targetCtx.moveTo(x, y - 7);
79860
+ targetCtx.lineTo(x, y + 7);
79861
+ targetCtx.stroke();
79862
+ targetCtx.restore();
79863
+ }
79864
+ }
79865
+ // Lasso v2: smooth closed curve through the placed vertices (closed + filled
79866
+ // from ≥2 vertices), vertex dots, and a red ✕ on the hovered vertex.
79867
+ if (this.promptTool === "lasso" && this.lassoVerts.length > 0) {
79868
+ this.renderLassoOverlay(targetCtx);
79869
+ }
79554
79870
  }
79555
- }
79871
+ /** A seed marker (small filled dot + ring) for point prompts. */
79872
+ drawSeedMarker(ctx, x, y, fg) {
79873
+ ctx.save();
79874
+ const col = fg ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
79875
+ ctx.fillStyle = col;
79876
+ ctx.strokeStyle = "rgba(0,0,0,0.55)";
79877
+ ctx.lineWidth = 1.5;
79878
+ ctx.beginPath();
79879
+ ctx.arc(x, y, 3.5, 0, Math.PI * 2);
79880
+ ctx.fill();
79881
+ ctx.stroke();
79882
+ ctx.restore();
79883
+ }
79884
+ /** Render the lasso editing overlay (curve + fill + vertices + hover ✕). */
79885
+ renderLassoOverlay(ctx) {
79886
+ var _a;
79887
+ const fg = this.lassoVerts[0].label === 1;
79888
+ const stroke = fg ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
79889
+ const fill = fg ? "rgba(120,255,180,0.14)" : "rgba(255,120,120,0.14)";
79890
+ // PATH: angularly-ordered vertices (simple, non-crossing loop) in screen space.
79891
+ const ov = this.orderedLassoVerts().map((v) => { var _a; return (_a = this.voxelToScreen(v.vx, v.vy)) !== null && _a !== void 0 ? _a : { x: v.sx, y: v.sy }; });
79892
+ const path = ov.length >= 3 ? this.sampleClosedSpline(ov, 16) : ov;
79893
+ ctx.save();
79894
+ if (path.length >= 2) {
79895
+ ctx.beginPath();
79896
+ ctx.moveTo(path[0].x, path[0].y);
79897
+ for (let i = 1; i < path.length; i++)
79898
+ ctx.lineTo(path[i].x, path[i].y);
79899
+ ctx.closePath();
79900
+ ctx.fillStyle = fill;
79901
+ ctx.fill();
79902
+ ctx.lineCap = "round";
79903
+ ctx.lineJoin = "round";
79904
+ ctx.lineWidth = 1.75;
79905
+ ctx.strokeStyle = stroke;
79906
+ ctx.stroke();
79907
+ }
79908
+ // Vertex handles — drawn from the ORIGINAL vertex order so lassoHoverIdx (an
79909
+ // index into lassoVerts from the hit-test) marks the correct one for the ✕.
79910
+ for (let i = 0; i < this.lassoVerts.length; i++) {
79911
+ const v = this.lassoVerts[i];
79912
+ const p = (_a = this.voxelToScreen(v.vx, v.vy)) !== null && _a !== void 0 ? _a : { x: v.sx, y: v.sy };
79913
+ if (i === this.lassoHoverIdx) {
79914
+ // Red ✕ — click to delete this vertex.
79915
+ ctx.strokeStyle = "rgba(255,90,90,0.98)";
79916
+ ctx.lineWidth = 2;
79917
+ ctx.beginPath();
79918
+ ctx.moveTo(p.x - 5, p.y - 5);
79919
+ ctx.lineTo(p.x + 5, p.y + 5);
79920
+ ctx.moveTo(p.x + 5, p.y - 5);
79921
+ ctx.lineTo(p.x - 5, p.y + 5);
79922
+ ctx.stroke();
79923
+ }
79924
+ else {
79925
+ ctx.fillStyle = stroke;
79926
+ ctx.strokeStyle = "rgba(0,0,0,0.55)";
79927
+ ctx.lineWidth = 1.25;
79928
+ ctx.beginPath();
79929
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
79930
+ ctx.fill();
79931
+ ctx.stroke();
79932
+ }
79933
+ }
79934
+ ctx.restore();
79935
+ }
79936
+ }
79937
+ /** Screen-pixel radius for hit-testing a hovered vertex. */
79938
+ AiAssistTool.LASSO_HIT_R = 8;
79556
79939
 
79557
79940
  /**
79558
79941
  * DrawToolCore — Tool orchestration and event routing.
@@ -82587,16 +82970,39 @@ void main() {
82587
82970
  // — Driver methods called by the app-layer composable —
82588
82971
  aiSetPromptTool(tool) { this.drawCore.aiAssistTool.setPromptTool(tool); }
82589
82972
  aiSetPolarity(label) { this.drawCore.aiAssistTool.setPolarity(label); }
82590
- /** Set the AI-layer channel (1-8) the predictions paint into. */
82591
- aiSetChannel(channel) { this.drawCore.aiAssistTool.setChannel(channel); }
82973
+ /** Select the active Segmentation by its label value (the AI paints into it). */
82974
+ aiSetActiveSegment(label) { this.drawCore.aiAssistTool.setActiveSegment(label); }
82975
+ /** Set a Segmentation's colour (its label's colorMap entry; 2D overlay repaints). */
82976
+ aiSetSegmentColor(label, color) {
82977
+ this.drawCore.aiAssistTool.setSegmentColor(label, color);
82978
+ }
82979
+ /** "New segmentation": freeze current regions + switch to a new label. */
82980
+ aiNewSegment(label) { this.drawCore.aiAssistTool.newSegment(label); }
82981
+ /** Delete a Segmentation: erase all its label's voxels from scratch + committed. */
82982
+ aiClearSegment(label) { this.drawCore.aiAssistTool.clearSegment(label); }
82592
82983
  /** Set the scribble brush radius (px). */
82593
82984
  aiSetScribbleSize(size) { this.drawCore.aiAssistTool.setScribbleSize(size); }
82594
82985
  /** Register the callback invoked when a prompt gesture completes (app → backend). */
82595
82986
  aiOnPrompt(cb) { this.drawCore.aiAssistTool.onPrompt = cb; }
82987
+ /** Register the callback fired when the lasso vertex set changes (drives the Finish button). */
82988
+ aiOnLassoChange(cb) { this.drawCore.aiAssistTool.onLassoChange = cb; }
82596
82989
  /** Apply a backend mask result into the scratch volume (overlay repaints next frame). */
82597
82990
  aiApplyMask(result) { this.drawCore.aiAssistTool.applyMask(result); }
82598
82991
  /** Clear the in-progress prompt set (e.g. slice change). */
82599
82992
  aiClearPrompts() { this.drawCore.aiAssistTool.resetPrompts(); }
82993
+ // — Lasso v2 (discrete-vertex closed-curve) controls —
82994
+ /** Close + send the in-progress lasso to the backend (needs ≥3 vertices). */
82995
+ aiFinishLasso() { this.drawCore.aiAssistTool.finishLasso(); }
82996
+ /** Undo the last lasso vertex add/delete. */
82997
+ aiLassoUndo() { this.drawCore.aiAssistTool.lassoUndoAction(); }
82998
+ /** Redo the last undone lasso vertex change. */
82999
+ aiLassoRedo() { this.drawCore.aiAssistTool.lassoRedoAction(); }
83000
+ /** Abandon the in-progress lasso (Esc). */
83001
+ aiCancelLasso() { this.drawCore.aiAssistTool.cancelLasso(); }
83002
+ /** True while the user is placing lasso vertices. */
83003
+ aiIsLassoEditing() { return this.drawCore.aiAssistTool.isLassoEditing(); }
83004
+ /** Number of placed lasso vertices (drives the Finish button). */
83005
+ aiLassoVertCount() { return this.drawCore.aiAssistTool.getLassoVertCount(); }
82600
83006
  /** "New region": freeze current regions (they persist) + start a fresh prompt set. */
82601
83007
  aiCommitRegion() { this.drawCore.aiAssistTool.commitRegion(); }
82602
83008
  /** Discard all AI scratch painting since enter (sandbox discard). */
@@ -82607,13 +83013,26 @@ void main() {
82607
83013
  aiGetScratchSlices() {
82608
83014
  return this.drawCore.aiAssistTool.getScratchSlices();
82609
83015
  }
83016
+ /** Per-segmentation serialization (one binary RLE per label per z-slice) for the
83017
+ * multi-label / per-colour 3D build. */
83018
+ aiGetScratchSegments() {
83019
+ return this.drawCore.aiAssistTool.getScratchSegments();
83020
+ }
82610
83021
  /**
82611
- * Merge the AI scratch into a target layer as a single undoable group (sandbox
82612
- * merge — best-practice: non-destructive + one Ctrl+Z). The scratch's channel
82613
- * labels are PRESERVED (each AI channel maps onto the same channel of the
82614
- * target layer). Scans all z-slices, so voxels painted from any view are caught.
83022
+ * Merge the AI scratch into a target layer with an explicit per-segmentation
83023
+ * mapping, as a single undoable group (sandbox merge — non-destructive + one
83024
+ * Ctrl+Z). `mapping` is `{ AI scratch label -> target channel(1-8) }`; any label
83025
+ * absent from the mapping (or mapped to 0) is DISCARDED (not merged). Several AI
83026
+ * labels MAY map to the SAME channel — after merge the channel is the identity
83027
+ * (CLAUDE.md decision M2), so the merged voxels take that channel's fixed colour.
83028
+ *
83029
+ * UNION semantics (M4): an AI voxel only fills a target voxel that is currently
83030
+ * EMPTY (0) — existing manual annotation is NEVER erased. Where two AI labels map
83031
+ * to different channels and overlap the same voxel, the first one written wins
83032
+ * (iteration order), an accepted edge case for a single-value-per-voxel mask.
83033
+ * Scans all z-slices, so voxels painted from any view are caught.
82615
83034
  */
82616
- aiCommitToLayer(targetLayer = "layer1") {
83035
+ aiCommitToLayerMapped(targetLayer, mapping) {
82617
83036
  const scratch = this.drawCore.aiAssistTool.getScratchVolume();
82618
83037
  if (!scratch)
82619
83038
  return;
@@ -82635,10 +83054,21 @@ void main() {
82635
83054
  continue;
82636
83055
  const oldSlice = target.getSliceUint8(z, "z").data; // copy
82637
83056
  const newSlice = oldSlice.slice();
83057
+ let changed = false;
82638
83058
  for (let i = 0; i < newSlice.length; i++) {
82639
- if (sc[i] !== 0)
82640
- newSlice[i] = sc[i]; // preserve the AI channel label
83059
+ const label = sc[i];
83060
+ if (label === 0)
83061
+ continue;
83062
+ const ch = mapping[label];
83063
+ if (!ch)
83064
+ continue; // unmapped / discarded segmentation
83065
+ if (newSlice[i] === 0) { // union: fill only EMPTY voxels (M4)
83066
+ newSlice[i] = ch;
83067
+ changed = true;
83068
+ }
82641
83069
  }
83070
+ if (!changed)
83071
+ continue; // nothing landed on this slice → no delta
82642
83072
  target.setSliceUint8(z, newSlice, "z");
82643
83073
  deltas.push({ layerId: targetLayer, axis: "z", sliceIndex: z, oldSlice, newSlice: newSlice.slice() });
82644
83074
  }
@@ -82646,6 +83076,19 @@ void main() {
82646
83076
  this.drawCore.undoManager.pushGroup(deltas);
82647
83077
  this.reloadMasksFromVolume();
82648
83078
  }
83079
+ /**
83080
+ * Back-compat shim: merge with an IDENTITY mapping (each AI label → the same
83081
+ * channel number) via the mapped path above. Newer callers should use
83082
+ * `aiCommitToLayerMapped` with an explicit per-segmentation mapping.
83083
+ */
83084
+ aiCommitToLayer(targetLayer = "layer1") {
83085
+ var _a;
83086
+ const segs = this.drawCore.aiAssistTool.getScratchSegments();
83087
+ const mapping = {};
83088
+ for (const s of (_a = segs === null || segs === void 0 ? void 0 : segs.segments) !== null && _a !== void 0 ? _a : [])
83089
+ mapping[s.label] = s.label;
83090
+ this.aiCommitToLayerMapped(targetLayer, mapping);
83091
+ }
82649
83092
  // ═══════════════════════════════════════════════════════════════════════════
82650
83093
  // 11. Clear / Reset
82651
83094
  // ═══════════════════════════════════════════════════════════════════════════
@@ -83009,8 +83452,24 @@ void main() {
83009
83452
  }
83010
83453
 
83011
83454
  // import * as kiwrious from "copper3d_plugin_heart_k";
83012
- const REVISION = "v3.6.4-beta";
83013
- console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83455
+ // "v3.6.6" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version.
83456
+ // When copper3d is consumed from local source (no rollup replace step), the identifier is undefined and
83457
+ // referencing it throws a ReferenceError — guard it so local loading still works.
83458
+ let _revision = "unknown";
83459
+ try {
83460
+ _revision = "v3.6.6";
83461
+ }
83462
+ catch (_a) {
83463
+ /* "v3.6.6" not injected (local source build) */
83464
+ }
83465
+ const REVISION = _revision;
83466
+ // Expose on global so the version can be read in a production browser console via window.__COPPER3D_VERSION__
83467
+ if (typeof window !== "undefined") {
83468
+ window.__COPPER3D_VERSION__ = REVISION;
83469
+ }
83470
+ if (typeof console !== "undefined") {
83471
+ console.log(`%cCopper3D Visualisation %c${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83472
+ }
83014
83473
 
83015
83474
  exports.AI_CHANNEL_HEX_COLORS = AI_CHANNEL_HEX_COLORS;
83016
83475
  exports.AI_MASK_CHANNEL_COLORS = AI_MASK_CHANNEL_COLORS;