copper3d 3.6.4 → 3.6.5

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.
@@ -79116,14 +79116,29 @@ class AiAssistTool extends BaseTool {
79116
79116
  this.committedVol = null;
79117
79117
  this.promptTool = "point";
79118
79118
  this.polarity = 1; // 1 = foreground (positive), 0 = background
79119
- /** Target channel/label (1-8) the AI paints into the "AI layer" channel. */
79120
- this.channel = 1;
79119
+ /** The label value (1-255) the AI currently paints into = the active Segmentation.
79120
+ * Each Segmentation is a label value in the single-channel scratch volume; its
79121
+ * colour lives in the volume's colorMap (set via setSegmentColor). */
79122
+ this.activeLabel = 1;
79121
79123
  /** Scribble brush radius (px), user-adjustable via slider. */
79122
79124
  this.scribbleSize = 5;
79123
79125
  /** Accumulated prompts for the CURRENT slice; reset on slice/axis change. */
79124
79126
  this.points = [];
79125
79127
  this.scribble = [];
79128
+ /** Lasso contour sent to the backend (densified along the curve at finish). */
79129
+ this.lasso = [];
79126
79130
  this.activeSlice = -1;
79131
+ // ── Lasso v2 editing state (discrete clicked vertices → smooth closed curve) ──
79132
+ /** Placed lasso vertices (voxel + screen + polarity). Empty = not editing. */
79133
+ this.lassoVerts = [];
79134
+ /** Undo / redo snapshot stacks of the vertex list (for Ctrl+Z / Ctrl+Y). */
79135
+ this.lassoUndo = [];
79136
+ this.lassoRedo = [];
79137
+ /** Index of the vertex currently hovered (shows a red ✕; click removes it). −1 = none. */
79138
+ this.lassoHoverIdx = -1;
79139
+ /** Double-click detection (a dbl-click finishes the lasso). */
79140
+ this.lastLassoDownTime = 0;
79141
+ this.lastLassoDownPos = { x: 0, y: 0 };
79127
79142
  // Drag state (box / scribble) — voxel coords for the prompt
79128
79143
  this.dragging = false;
79129
79144
  this.dragStart = null;
@@ -79134,8 +79149,14 @@ class AiAssistTool extends BaseTool {
79134
79149
  // Live cursor position (screen px) for the scribble brush-size preview ring —
79135
79150
  // updated on every hover move so the ring follows the mouse like the paint brush.
79136
79151
  this.hoverScreen = null;
79152
+ /** Hide the point seed markers once a prediction has painted (only the mask
79153
+ * should remain). A new point click re-shows them until its mask returns. */
79154
+ this.hidePointMarkers = false;
79137
79155
  /** Fired when a prompt gesture completes — app calls backend, then applyMask(). */
79138
79156
  this.onPrompt = null;
79157
+ /** Fired whenever the lasso vertex set changes (add/delete/undo/redo/finish/cancel),
79158
+ * so the panel can reactively show the Finish button + vertex count. */
79159
+ this.onLassoChange = null;
79139
79160
  this.host = host;
79140
79161
  }
79141
79162
  // ── Configuration (driven from the panel via NrrdTools) ────────────────────
@@ -79153,16 +79174,51 @@ class AiAssistTool extends BaseTool {
79153
79174
  setScribbleSize(size) {
79154
79175
  this.scribbleSize = Math.max(1, Math.min(40, Math.round(size)));
79155
79176
  }
79156
- setChannel(channel) {
79157
- const c = Math.max(1, Math.min(8, Math.round(channel)));
79158
- // Changing channel starts a NEW region in the new colour otherwise the
79159
- // accumulated points would be re-predicted and repainted in the new label,
79160
- // recolouring the previous channel's region.
79161
- if (c !== this.channel)
79177
+ /** Select the active Segmentation by its label value (1-255). Switching starts a
79178
+ * fresh prompt set otherwise accumulated prompts would be re-predicted and
79179
+ * repainted into the new label, recolouring the previous segmentation's region. */
79180
+ setActiveSegment(label) {
79181
+ const c = Math.max(1, Math.min(255, Math.round(label)));
79182
+ if (c !== this.activeLabel)
79162
79183
  this.resetPrompts();
79163
- this.channel = c;
79184
+ this.activeLabel = c;
79185
+ }
79186
+ getActiveLabel() { return this.activeLabel; }
79187
+ /** Set a Segmentation's colour (its label's entry in the scratch volume colorMap).
79188
+ * The 2D overlay repaints next frame (copper3d's render loop re-reads the map). */
79189
+ setSegmentColor(label, color) {
79190
+ var _a;
79191
+ (_a = this.scratchVol) === null || _a === void 0 ? void 0 : _a.setChannelColor(Math.round(label), color);
79192
+ }
79193
+ /** "New segmentation": freeze the current regions (so they persist) and switch to
79194
+ * a fresh label, exactly like the old "New region" but targeting a new label. */
79195
+ newSegment(label) {
79196
+ this.commitRegion();
79197
+ this.setActiveSegment(label);
79198
+ }
79199
+ /** Delete a Segmentation: erase every voxel painted with its label from BOTH the
79200
+ * live scratch and the frozen (committed) volume, so the region disappears from
79201
+ * the view (otherwise orphaned label voxels would render with the wrong colour).
79202
+ * Goes via setRawData so the volume's version counter bumps (overlay repaints). */
79203
+ clearSegment(label) {
79204
+ const lbl = Math.round(label);
79205
+ const zeroOut = (v) => {
79206
+ if (!v)
79207
+ return;
79208
+ const raw = v.getRawData().slice();
79209
+ let changed = false;
79210
+ for (let i = 0; i < raw.length; i++) {
79211
+ if (raw[i] === lbl) {
79212
+ raw[i] = 0;
79213
+ changed = true;
79214
+ }
79215
+ }
79216
+ if (changed)
79217
+ v.setRawData(raw);
79218
+ };
79219
+ zeroOut(this.scratchVol);
79220
+ zeroOut(this.committedVol);
79164
79221
  }
79165
- getChannel() { return this.channel; }
79166
79222
  getScratchVolume() { return this.scratchVol; }
79167
79223
  /** Serialize the scratch volume as per-z-slice RLE (only NON-empty slices),
79168
79224
  * for persisting to ai_generated_nii_LPS on the backend. Binary (any channel →
@@ -79204,6 +79260,50 @@ class AiAssistTool extends BaseTool {
79204
79260
  }
79205
79261
  return { axis: "z", width, height, slices: out };
79206
79262
  }
79263
+ /** Per-SEGMENTATION serialization for the 3D build: one binary RLE per non-empty
79264
+ * z-slice PER label (no binarize-merge), so the backend writes a multi-label
79265
+ * NIfTI and colours the GLB per segmentation. Includes both live and frozen
79266
+ * regions (both live in scratchVol). */
79267
+ getScratchSegments() {
79268
+ if (!this.scratchVol)
79269
+ return null;
79270
+ const { depth } = this.scratchVol.getDimensions();
79271
+ const byLabel = new Map();
79272
+ let width = 0;
79273
+ let height = 0;
79274
+ for (let z = 0; z < depth; z++) {
79275
+ const { data, width: w, height: h } = this.scratchVol.getSliceUint8(z, "z");
79276
+ width = w;
79277
+ height = h;
79278
+ const labels = new Set();
79279
+ for (let i = 0; i < data.length; i++) {
79280
+ if (data[i])
79281
+ labels.add(data[i]);
79282
+ }
79283
+ for (const label of labels) {
79284
+ const runs = [];
79285
+ let value = 0; // always begin with a 0-run (matches backend rle_decode)
79286
+ let count = 0;
79287
+ for (let i = 0; i < data.length; i++) {
79288
+ const v = data[i] === label ? 1 : 0;
79289
+ if (v === value) {
79290
+ count++;
79291
+ }
79292
+ else {
79293
+ runs.push(count);
79294
+ value = v;
79295
+ count = 1;
79296
+ }
79297
+ }
79298
+ runs.push(count);
79299
+ if (!byLabel.has(label))
79300
+ byLabel.set(label, []);
79301
+ byLabel.get(label).push({ sliceIndex: z, rle: runs });
79302
+ }
79303
+ }
79304
+ const segments = [...byLabel.entries()].map(([label, slices]) => ({ label, slices }));
79305
+ return { axis: "z", width, height, segments };
79306
+ }
79207
79307
  // ── Sandbox lifecycle ──────────────────────────────────────────────────────
79208
79308
  /** Create (or reuse) the scratch volume sized to the layer grid and register
79209
79309
  * it under maskData.volumes['aiScratch']. Snapshots the empty buffer. */
@@ -79224,14 +79324,10 @@ class AiAssistTool extends BaseTool {
79224
79324
  this.scratchVol.clear();
79225
79325
  }
79226
79326
  volumes[AI_SCRATCH_LAYER] = this.scratchVol;
79227
- // Paint the AI scratch with the AI-Assist palette (channel 1 = cyan) so the 2D
79228
- // overlay matches the cyan ai_generated GLB. Scoped to THIS volume only — the
79229
- // global palette / clinician mask colours are untouched.
79230
- for (let ch = 1; ch <= 8; ch++) {
79231
- const c = AI_MASK_CHANNEL_COLORS[ch];
79232
- if (c)
79233
- this.scratchVol.setChannelColor(ch, { r: c.r, g: c.g, b: c.b, a: c.a });
79234
- }
79327
+ // Segmentation colours are now driven per-label from the app (store useAiAssist
79328
+ // setSegmentColor) right after enter, so the scratch can carry an arbitrary
79329
+ // number of independently-coloured segmentations. No fixed palette is applied
79330
+ // here. Scoped to THIS volume only global/clinician palettes are untouched.
79235
79331
  this.snapshotData = this.scratchVol.getRawData().slice();
79236
79332
  this.committedVol = null; // nothing frozen at session start
79237
79333
  this.resetPrompts();
@@ -79277,10 +79373,26 @@ class AiAssistTool extends BaseTool {
79277
79373
  resetPrompts() {
79278
79374
  this.points = [];
79279
79375
  this.scribble = [];
79376
+ this.lasso = [];
79280
79377
  this.box = undefined;
79281
79378
  this.dragScreenStart = null;
79282
79379
  this.dragScreen = null;
79283
79380
  this.screenScribble = [];
79381
+ this.clearLassoEditing();
79382
+ }
79383
+ /** Drop the in-progress lasso vertices + undo/redo history (does not touch a
79384
+ * prediction already painted from a finished lasso). */
79385
+ clearLassoEditing() {
79386
+ this.lassoVerts = [];
79387
+ this.lassoUndo = [];
79388
+ this.lassoRedo = [];
79389
+ this.lassoHoverIdx = -1;
79390
+ this.notifyLasso();
79391
+ }
79392
+ /** Tell the app layer the lasso vertex set changed (drives the Finish button). */
79393
+ notifyLasso() {
79394
+ var _a;
79395
+ (_a = this.onLassoChange) === null || _a === void 0 ? void 0 : _a.call(this, this.lassoVerts.length, this.lassoVerts.length > 0);
79284
79396
  }
79285
79397
  // ── Coordinate mapping (all three views) ───────────────────────────────────
79286
79398
  //
@@ -79341,14 +79453,53 @@ class AiAssistTool extends BaseTool {
79341
79453
  return null;
79342
79454
  return { vx, vy, w: sd.w, h: sd.h };
79343
79455
  }
79456
+ /** Voxel-slice (vx,vy) → screen offset (px). Exact inverse of toVoxel, so
79457
+ * markers / lasso curves drawn from voxel coords track pan AND zoom each frame
79458
+ * (the freehand previews store screen px directly; persistent overlays don't). */
79459
+ voxelToScreen(vx, vy) {
79460
+ const sd = this.sliceDims();
79461
+ if (!sd)
79462
+ return null;
79463
+ const nrrd = this.ctx.nrrd_states;
79464
+ const img = nrrd.image;
79465
+ let hMM, vMM, flipV;
79466
+ switch (this.ctx.protectedData.axis) {
79467
+ case "z":
79468
+ hMM = img.nrrd_x_mm;
79469
+ vMM = img.nrrd_y_mm;
79470
+ flipV = false;
79471
+ break;
79472
+ case "y":
79473
+ hMM = img.nrrd_x_mm;
79474
+ vMM = img.nrrd_z_mm;
79475
+ flipV = true;
79476
+ break;
79477
+ case "x":
79478
+ hMM = img.nrrd_z_mm;
79479
+ vMM = img.nrrd_y_mm;
79480
+ flipV = false;
79481
+ break;
79482
+ default: return null;
79483
+ }
79484
+ const vyRaw = flipV ? sd.h - 1 - vy : vy;
79485
+ const x = (vx * hMM / sd.w) * nrrd.view.sizeFactor;
79486
+ const y = (vyRaw * vMM / sd.h) * nrrd.view.sizeFactor;
79487
+ return { x, y };
79488
+ }
79344
79489
  // ── Pointer handlers (left button; right stays pan in EventRouter) ──────────
79345
79490
  onPointerDown(e) {
79346
79491
  const hit = this.toVoxel(e);
79347
79492
  if (!hit)
79348
79493
  return;
79349
79494
  this.syncSlice();
79495
+ // Lasso is click-to-place vertices (NOT a drag) — own handler.
79496
+ if (this.promptTool === "lasso") {
79497
+ this.onLassoPointerDown(e, hit.vx, hit.vy);
79498
+ return;
79499
+ }
79350
79500
  if (this.promptTool === "point") {
79351
79501
  this.points.push({ x: hit.vx, y: hit.vy, label: this.polarity });
79502
+ this.hidePointMarkers = false; // show the new seed until its mask returns
79352
79503
  this.emit();
79353
79504
  }
79354
79505
  else {
@@ -79364,9 +79515,43 @@ class AiAssistTool extends BaseTool {
79364
79515
  }
79365
79516
  }
79366
79517
  }
79518
+ /** Lasso click: double-click finishes; click on a vertex deletes it; otherwise
79519
+ * add a vertex. Nothing is sent to the backend until finishLasso(). */
79520
+ onLassoPointerDown(e, vx, vy) {
79521
+ const now = Date.now();
79522
+ const dxl = e.offsetX - this.lastLassoDownPos.x;
79523
+ const dyl = e.offsetY - this.lastLassoDownPos.y;
79524
+ const isDbl = now - this.lastLassoDownTime < 300 &&
79525
+ dxl * dxl + dyl * dyl <= 36; // ~6px
79526
+ this.lastLassoDownTime = now;
79527
+ this.lastLassoDownPos = { x: e.offsetX, y: e.offsetY };
79528
+ // Double-click → finish (the 1st click of the pair already placed the vertex).
79529
+ if (isDbl && this.lassoVerts.length >= 3) {
79530
+ this.finishLasso();
79531
+ return;
79532
+ }
79533
+ // Click on an existing vertex → delete it (recompute the curve).
79534
+ const idx = this.lassoVertAt(e.offsetX, e.offsetY);
79535
+ if (idx >= 0) {
79536
+ this.pushLassoUndo();
79537
+ this.lassoVerts.splice(idx, 1);
79538
+ this.lassoHoverIdx = -1;
79539
+ this.notifyLasso();
79540
+ return;
79541
+ }
79542
+ // Otherwise add a new vertex.
79543
+ this.pushLassoUndo();
79544
+ this.lassoVerts.push({ vx, vy, sx: e.offsetX, sy: e.offsetY, label: this.polarity });
79545
+ this.notifyLasso();
79546
+ }
79367
79547
  onPointerMove(e) {
79368
- // Track hover for the scribble preview ring even when not drawing.
79548
+ // Track hover for the scribble preview ring / point crosshair even when idle.
79369
79549
  this.hoverScreen = { x: e.offsetX, y: e.offsetY };
79550
+ // Lasso: update which vertex (if any) is hovered, for the red ✕ delete affordance.
79551
+ if (this.promptTool === "lasso") {
79552
+ this.lassoHoverIdx = this.lassoVertAt(e.offsetX, e.offsetY);
79553
+ return; // lasso is click-based; no drag accumulation
79554
+ }
79370
79555
  if (!this.dragging)
79371
79556
  return;
79372
79557
  // Track screen pos first so the live preview follows even slightly out of bounds.
@@ -79419,12 +79604,116 @@ class AiAssistTool extends BaseTool {
79419
79604
  box: this.box,
79420
79605
  scribble: this.scribble.length ? [...this.scribble] : undefined,
79421
79606
  scribbleRadius: this.scribbleSize,
79607
+ lasso: this.lasso.length ? [...this.lasso] : undefined,
79422
79608
  });
79423
79609
  }
79610
+ // ── Lasso v2 (discrete vertices → smooth closed curve) ──────────────────────
79611
+ /** Index of the lasso vertex under (sx,sy) within the hit radius, else −1.
79612
+ * Uses live voxel→screen so it tracks pan/zoom. Topmost (latest) wins. */
79613
+ lassoVertAt(sx, sy) {
79614
+ var _a;
79615
+ const r2 = AiAssistTool.LASSO_HIT_R * AiAssistTool.LASSO_HIT_R;
79616
+ for (let i = this.lassoVerts.length - 1; i >= 0; i--) {
79617
+ const v = this.lassoVerts[i];
79618
+ const s = (_a = this.voxelToScreen(v.vx, v.vy)) !== null && _a !== void 0 ? _a : { x: v.sx, y: v.sy };
79619
+ const dx = sx - s.x, dy = sy - s.y;
79620
+ if (dx * dx + dy * dy <= r2)
79621
+ return i;
79622
+ }
79623
+ return -1;
79624
+ }
79625
+ /** Snapshot the vertex list for undo (clears redo). */
79626
+ pushLassoUndo() {
79627
+ this.lassoUndo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79628
+ this.lassoRedo = [];
79629
+ if (this.lassoUndo.length > 100)
79630
+ this.lassoUndo.shift();
79631
+ }
79632
+ /** Public: undo the last add/delete of a lasso vertex (Ctrl+Z while editing). */
79633
+ lassoUndoAction() {
79634
+ if (!this.lassoUndo.length)
79635
+ return;
79636
+ this.lassoRedo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79637
+ this.lassoVerts = this.lassoUndo.pop();
79638
+ this.lassoHoverIdx = -1;
79639
+ this.notifyLasso();
79640
+ }
79641
+ /** Public: redo (Ctrl+Y / Ctrl+Shift+Z while editing). */
79642
+ lassoRedoAction() {
79643
+ if (!this.lassoRedo.length)
79644
+ return;
79645
+ this.lassoUndo.push(this.lassoVerts.map((v) => (Object.assign({}, v))));
79646
+ this.lassoVerts = this.lassoRedo.pop();
79647
+ this.lassoHoverIdx = -1;
79648
+ this.notifyLasso();
79649
+ }
79650
+ /** Public: abandon the in-progress lasso (Esc). */
79651
+ cancelLasso() { this.clearLassoEditing(); }
79652
+ /** Public: true while the user is placing lasso vertices (≥1 vertex). */
79653
+ isLassoEditing() { return this.lassoVerts.length > 0; }
79654
+ /** Public: number of placed lasso vertices (drives the Finish button label/enable). */
79655
+ getLassoVertCount() { return this.lassoVerts.length; }
79656
+ /** Vertices ordered by polar angle around their centroid, so the closed curve is
79657
+ * a SIMPLE (non-self-intersecting) loop regardless of click order — otherwise
79658
+ * connecting points in click order + closing easily produces a bow-tie crossing.
79659
+ * Trade-off: deep concavities collapse to their star-shaped hull, which is fine
79660
+ * for a "rough loop around a blob". */
79661
+ orderedLassoVerts() {
79662
+ const n = this.lassoVerts.length;
79663
+ if (n < 3)
79664
+ return this.lassoVerts.slice();
79665
+ let cx = 0, cy = 0;
79666
+ for (const v of this.lassoVerts) {
79667
+ cx += v.vx;
79668
+ cy += v.vy;
79669
+ }
79670
+ cx /= n;
79671
+ cy /= n;
79672
+ return this.lassoVerts.slice().sort((a, b) => Math.atan2(a.vy - cy, a.vx - cx) - Math.atan2(b.vy - cy, b.vx - cx));
79673
+ }
79674
+ /** Public: close the curve, densify it, and send it to the backend as the lasso
79675
+ * contour. Needs ≥3 vertices (a real area). Then clears the editing state. */
79676
+ finishLasso() {
79677
+ if (this.lassoVerts.length < 3)
79678
+ return;
79679
+ // Order by angle (no self-crossing), then sample the SAME closed curve the user
79680
+ // saw → contour points (so the mask matches the curve, not a straight polygon).
79681
+ const contour = this.sampleClosedSpline(this.orderedLassoVerts().map((v) => ({ x: v.vx, y: v.vy })), 16);
79682
+ const label = this.lassoVerts[0].label; // a lasso carries one polarity (set at start)
79683
+ this.lasso = contour.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y), label }));
79684
+ this.clearLassoEditing();
79685
+ this.emit();
79686
+ }
79687
+ /** Sample a smooth CLOSED Catmull-Rom spline through `pts` → denser polyline.
79688
+ * `segs` samples per control-point span. <3 pts → returns a copy unchanged. */
79689
+ sampleClosedSpline(pts, segs) {
79690
+ const n = pts.length;
79691
+ if (n < 3)
79692
+ return pts.slice();
79693
+ const at = (i) => pts[((i % n) + n) % n];
79694
+ const out = [];
79695
+ for (let i = 0; i < n; i++) {
79696
+ const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
79697
+ for (let s = 0; s < segs; s++) {
79698
+ const t = s / segs, t2 = t * t, t3 = t2 * t;
79699
+ out.push({
79700
+ x: 0.5 * (2 * p1.x + (-p0.x + p2.x) * t +
79701
+ (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
79702
+ (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
79703
+ y: 0.5 * (2 * p1.y + (-p0.y + p2.y) * t +
79704
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
79705
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
79706
+ });
79707
+ }
79708
+ }
79709
+ return out;
79710
+ }
79424
79711
  // ── Apply backend result → scratch volume ──────────────────────────────────
79425
79712
  applyMask(result) {
79426
79713
  if (!this.scratchVol)
79427
79714
  return;
79715
+ // A prediction landed → hide the point seed markers; only the mask should show.
79716
+ this.hidePointMarkers = true;
79428
79717
  // 3D result (engine B): write each slice across the covered span.
79429
79718
  if (result.slices && result.sliceRange) {
79430
79719
  const [lo] = result.sliceRange;
@@ -79454,7 +79743,7 @@ class AiAssistTool extends BaseTool {
79454
79743
  pos += run;
79455
79744
  value ^= 1;
79456
79745
  }
79457
- const ch = this.channel;
79746
+ const ch = this.activeLabel;
79458
79747
  try {
79459
79748
  const out = this.scratchVol.getSliceUint8(sliceIndex, axis).data; // copy
79460
79749
  // Frozen same-channel pixels for this slice (if any region was committed).
@@ -79527,10 +79816,7 @@ class AiAssistTool extends BaseTool {
79527
79816
  }
79528
79817
  targetCtx.restore();
79529
79818
  }
79530
- // Scribble brush-size preview ring (when NOT dragging) — a circle centred on
79531
- // the cursor whose radius = scribbleSize, so the user sees the brush size and
79532
- // how the slider changes it (mirrors the paint brush's preview ring). Drawn
79533
- // every frame from the live hover position by copper3d's render loop.
79819
+ // Scribble brush-size preview ring (when NOT dragging).
79534
79820
  if (this.promptTool === "scribble" && !this.dragging && this.hoverScreen) {
79535
79821
  targetCtx.save();
79536
79822
  const fg = this.polarity === 1;
@@ -79543,8 +79829,105 @@ class AiAssistTool extends BaseTool {
79543
79829
  targetCtx.stroke();
79544
79830
  targetCtx.restore();
79545
79831
  }
79832
+ // Point markers + hover crosshair so the clinician sees exactly where each seed
79833
+ // lands (and where the next click will go) before/while the prediction returns.
79834
+ if (this.promptTool === "point") {
79835
+ if (!this.hidePointMarkers) {
79836
+ for (const p of this.points) {
79837
+ const s = this.voxelToScreen(p.x, p.y);
79838
+ if (s)
79839
+ this.drawSeedMarker(targetCtx, s.x, s.y, p.label === 1);
79840
+ }
79841
+ }
79842
+ if (!this.dragging && this.hoverScreen) {
79843
+ targetCtx.save();
79844
+ targetCtx.strokeStyle = this.polarity === 1
79845
+ ? "rgba(120,255,180,0.55)" : "rgba(255,120,120,0.55)";
79846
+ targetCtx.lineWidth = 1;
79847
+ const { x, y } = this.hoverScreen;
79848
+ targetCtx.beginPath();
79849
+ targetCtx.moveTo(x - 7, y);
79850
+ targetCtx.lineTo(x + 7, y);
79851
+ targetCtx.moveTo(x, y - 7);
79852
+ targetCtx.lineTo(x, y + 7);
79853
+ targetCtx.stroke();
79854
+ targetCtx.restore();
79855
+ }
79856
+ }
79857
+ // Lasso v2: smooth closed curve through the placed vertices (closed + filled
79858
+ // from ≥2 vertices), vertex dots, and a red ✕ on the hovered vertex.
79859
+ if (this.promptTool === "lasso" && this.lassoVerts.length > 0) {
79860
+ this.renderLassoOverlay(targetCtx);
79861
+ }
79546
79862
  }
79547
- }
79863
+ /** A seed marker (small filled dot + ring) for point prompts. */
79864
+ drawSeedMarker(ctx, x, y, fg) {
79865
+ ctx.save();
79866
+ const col = fg ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
79867
+ ctx.fillStyle = col;
79868
+ ctx.strokeStyle = "rgba(0,0,0,0.55)";
79869
+ ctx.lineWidth = 1.5;
79870
+ ctx.beginPath();
79871
+ ctx.arc(x, y, 3.5, 0, Math.PI * 2);
79872
+ ctx.fill();
79873
+ ctx.stroke();
79874
+ ctx.restore();
79875
+ }
79876
+ /** Render the lasso editing overlay (curve + fill + vertices + hover ✕). */
79877
+ renderLassoOverlay(ctx) {
79878
+ var _a;
79879
+ const fg = this.lassoVerts[0].label === 1;
79880
+ const stroke = fg ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
79881
+ const fill = fg ? "rgba(120,255,180,0.14)" : "rgba(255,120,120,0.14)";
79882
+ // PATH: angularly-ordered vertices (simple, non-crossing loop) in screen space.
79883
+ 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 }; });
79884
+ const path = ov.length >= 3 ? this.sampleClosedSpline(ov, 16) : ov;
79885
+ ctx.save();
79886
+ if (path.length >= 2) {
79887
+ ctx.beginPath();
79888
+ ctx.moveTo(path[0].x, path[0].y);
79889
+ for (let i = 1; i < path.length; i++)
79890
+ ctx.lineTo(path[i].x, path[i].y);
79891
+ ctx.closePath();
79892
+ ctx.fillStyle = fill;
79893
+ ctx.fill();
79894
+ ctx.lineCap = "round";
79895
+ ctx.lineJoin = "round";
79896
+ ctx.lineWidth = 1.75;
79897
+ ctx.strokeStyle = stroke;
79898
+ ctx.stroke();
79899
+ }
79900
+ // Vertex handles — drawn from the ORIGINAL vertex order so lassoHoverIdx (an
79901
+ // index into lassoVerts from the hit-test) marks the correct one for the ✕.
79902
+ for (let i = 0; i < this.lassoVerts.length; i++) {
79903
+ const v = this.lassoVerts[i];
79904
+ const p = (_a = this.voxelToScreen(v.vx, v.vy)) !== null && _a !== void 0 ? _a : { x: v.sx, y: v.sy };
79905
+ if (i === this.lassoHoverIdx) {
79906
+ // Red ✕ — click to delete this vertex.
79907
+ ctx.strokeStyle = "rgba(255,90,90,0.98)";
79908
+ ctx.lineWidth = 2;
79909
+ ctx.beginPath();
79910
+ ctx.moveTo(p.x - 5, p.y - 5);
79911
+ ctx.lineTo(p.x + 5, p.y + 5);
79912
+ ctx.moveTo(p.x + 5, p.y - 5);
79913
+ ctx.lineTo(p.x - 5, p.y + 5);
79914
+ ctx.stroke();
79915
+ }
79916
+ else {
79917
+ ctx.fillStyle = stroke;
79918
+ ctx.strokeStyle = "rgba(0,0,0,0.55)";
79919
+ ctx.lineWidth = 1.25;
79920
+ ctx.beginPath();
79921
+ ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
79922
+ ctx.fill();
79923
+ ctx.stroke();
79924
+ }
79925
+ }
79926
+ ctx.restore();
79927
+ }
79928
+ }
79929
+ /** Screen-pixel radius for hit-testing a hovered vertex. */
79930
+ AiAssistTool.LASSO_HIT_R = 8;
79548
79931
 
79549
79932
  /**
79550
79933
  * DrawToolCore — Tool orchestration and event routing.
@@ -82579,16 +82962,39 @@ class NrrdTools {
82579
82962
  // — Driver methods called by the app-layer composable —
82580
82963
  aiSetPromptTool(tool) { this.drawCore.aiAssistTool.setPromptTool(tool); }
82581
82964
  aiSetPolarity(label) { this.drawCore.aiAssistTool.setPolarity(label); }
82582
- /** Set the AI-layer channel (1-8) the predictions paint into. */
82583
- aiSetChannel(channel) { this.drawCore.aiAssistTool.setChannel(channel); }
82965
+ /** Select the active Segmentation by its label value (the AI paints into it). */
82966
+ aiSetActiveSegment(label) { this.drawCore.aiAssistTool.setActiveSegment(label); }
82967
+ /** Set a Segmentation's colour (its label's colorMap entry; 2D overlay repaints). */
82968
+ aiSetSegmentColor(label, color) {
82969
+ this.drawCore.aiAssistTool.setSegmentColor(label, color);
82970
+ }
82971
+ /** "New segmentation": freeze current regions + switch to a new label. */
82972
+ aiNewSegment(label) { this.drawCore.aiAssistTool.newSegment(label); }
82973
+ /** Delete a Segmentation: erase all its label's voxels from scratch + committed. */
82974
+ aiClearSegment(label) { this.drawCore.aiAssistTool.clearSegment(label); }
82584
82975
  /** Set the scribble brush radius (px). */
82585
82976
  aiSetScribbleSize(size) { this.drawCore.aiAssistTool.setScribbleSize(size); }
82586
82977
  /** Register the callback invoked when a prompt gesture completes (app → backend). */
82587
82978
  aiOnPrompt(cb) { this.drawCore.aiAssistTool.onPrompt = cb; }
82979
+ /** Register the callback fired when the lasso vertex set changes (drives the Finish button). */
82980
+ aiOnLassoChange(cb) { this.drawCore.aiAssistTool.onLassoChange = cb; }
82588
82981
  /** Apply a backend mask result into the scratch volume (overlay repaints next frame). */
82589
82982
  aiApplyMask(result) { this.drawCore.aiAssistTool.applyMask(result); }
82590
82983
  /** Clear the in-progress prompt set (e.g. slice change). */
82591
82984
  aiClearPrompts() { this.drawCore.aiAssistTool.resetPrompts(); }
82985
+ // — Lasso v2 (discrete-vertex closed-curve) controls —
82986
+ /** Close + send the in-progress lasso to the backend (needs ≥3 vertices). */
82987
+ aiFinishLasso() { this.drawCore.aiAssistTool.finishLasso(); }
82988
+ /** Undo the last lasso vertex add/delete. */
82989
+ aiLassoUndo() { this.drawCore.aiAssistTool.lassoUndoAction(); }
82990
+ /** Redo the last undone lasso vertex change. */
82991
+ aiLassoRedo() { this.drawCore.aiAssistTool.lassoRedoAction(); }
82992
+ /** Abandon the in-progress lasso (Esc). */
82993
+ aiCancelLasso() { this.drawCore.aiAssistTool.cancelLasso(); }
82994
+ /** True while the user is placing lasso vertices. */
82995
+ aiIsLassoEditing() { return this.drawCore.aiAssistTool.isLassoEditing(); }
82996
+ /** Number of placed lasso vertices (drives the Finish button). */
82997
+ aiLassoVertCount() { return this.drawCore.aiAssistTool.getLassoVertCount(); }
82592
82998
  /** "New region": freeze current regions (they persist) + start a fresh prompt set. */
82593
82999
  aiCommitRegion() { this.drawCore.aiAssistTool.commitRegion(); }
82594
83000
  /** Discard all AI scratch painting since enter (sandbox discard). */
@@ -82599,6 +83005,11 @@ class NrrdTools {
82599
83005
  aiGetScratchSlices() {
82600
83006
  return this.drawCore.aiAssistTool.getScratchSlices();
82601
83007
  }
83008
+ /** Per-segmentation serialization (one binary RLE per label per z-slice) for the
83009
+ * multi-label / per-colour 3D build. */
83010
+ aiGetScratchSegments() {
83011
+ return this.drawCore.aiAssistTool.getScratchSegments();
83012
+ }
82602
83013
  /**
82603
83014
  * Merge the AI scratch into a target layer as a single undoable group (sandbox
82604
83015
  * merge — best-practice: non-destructive + one Ctrl+Z). The scratch's channel
@@ -83001,7 +83412,14 @@ function evaluateElement(element, weights) {
83001
83412
  }
83002
83413
 
83003
83414
  // import * as kiwrious from "copper3d_plugin_heart_k";
83004
- const REVISION = "v3.6.4-beta";
83005
- console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83415
+ // "v3.6.5" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version
83416
+ const REVISION = "v3.6.5";
83417
+ // Expose on global so the version can be read in a production browser console via window.__COPPER3D_VERSION__
83418
+ if (typeof window !== "undefined") {
83419
+ window.__COPPER3D_VERSION__ = REVISION;
83420
+ }
83421
+ if (typeof console !== "undefined") {
83422
+ console.log(`%cCopper3D Visualisation %c${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83423
+ }
83006
83424
 
83007
83425
  export { AI_CHANNEL_HEX_COLORS, AI_MASK_CHANNEL_COLORS, CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, SurfaceAnnotator, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };