copper3d 3.6.2 → 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.
Files changed (63) hide show
  1. package/dist/Loader/copperNrrdLoader.js +1 -1
  2. package/dist/Loader/copperNrrdLoader.js.map +1 -1
  3. package/dist/Scene/copperScene.d.ts +4 -4
  4. package/dist/Scene/copperScene.js +4 -4
  5. package/dist/Scene/copperScene.js.map +1 -1
  6. package/dist/Utils/segmentation/NrrdTools.d.ts +41 -2
  7. package/dist/Utils/segmentation/NrrdTools.js +30 -2
  8. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  9. package/dist/Utils/segmentation/tools/AiAssistTool.d.ts +108 -5
  10. package/dist/Utils/segmentation/tools/AiAssistTool.js +408 -25
  11. package/dist/Utils/segmentation/tools/AiAssistTool.js.map +1 -1
  12. package/dist/Utils/surfaceAnnotation/MeshGraph.d.ts +12 -10
  13. package/dist/Utils/surfaceAnnotation/MeshGraph.js +14 -12
  14. package/dist/Utils/surfaceAnnotation/MeshGraph.js.map +1 -1
  15. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +48 -28
  16. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js +76 -50
  17. package/dist/Utils/surfaceAnnotation/SurfaceAnnotator.js.map +1 -1
  18. package/dist/Utils/surfaceAnnotation/annotationStore.d.ts +4 -4
  19. package/dist/Utils/surfaceAnnotation/annotationStore.js +4 -4
  20. package/dist/Utils/surfaceAnnotation/annotationStore.js.map +1 -1
  21. package/dist/Utils/surfaceAnnotation/contourRender.d.ts +1 -1
  22. package/dist/Utils/surfaceAnnotation/contourRender.js +16 -11
  23. package/dist/Utils/surfaceAnnotation/contourRender.js.map +1 -1
  24. package/dist/Utils/surfaceAnnotation/geodesicContour.d.ts +11 -10
  25. package/dist/Utils/surfaceAnnotation/geodesicContour.js +16 -15
  26. package/dist/Utils/surfaceAnnotation/geodesicContour.js.map +1 -1
  27. package/dist/Utils/surfaceAnnotation/pointMarkers.d.ts +3 -2
  28. package/dist/Utils/surfaceAnnotation/pointMarkers.js +6 -2
  29. package/dist/Utils/surfaceAnnotation/pointMarkers.js.map +1 -1
  30. package/dist/Utils/surfaceAnnotation/raycastSurface.d.ts +3 -2
  31. package/dist/Utils/surfaceAnnotation/raycastSurface.js +71 -5
  32. package/dist/Utils/surfaceAnnotation/raycastSurface.js.map +1 -1
  33. package/dist/Utils/surfaceAnnotation/strokeContour.d.ts +9 -6
  34. package/dist/Utils/surfaceAnnotation/strokeContour.js +15 -10
  35. package/dist/Utils/surfaceAnnotation/strokeContour.js.map +1 -1
  36. package/dist/Utils/surfaceAnnotation/types.d.ts +5 -5
  37. package/dist/Utils/surfaceAnnotation/types.js +2 -2
  38. package/dist/Utils/surfaceAnnotation/types.js.map +1 -1
  39. package/dist/Utils/texture2d.js +4 -2
  40. package/dist/Utils/texture2d.js.map +1 -1
  41. package/dist/Utils/workers/reformatSaveDataWorker.js +2 -2
  42. package/dist/Utils/workers/reformatSaveDataWorker.js.map +1 -1
  43. package/dist/bundle.esm.js +684 -157
  44. package/dist/bundle.umd.js +684 -157
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +9 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/three-vignette.js +8 -2
  49. package/dist/lib/three-vignette.js.map +1 -1
  50. package/dist/types/Scene/copperScene.d.ts +4 -4
  51. package/dist/types/Utils/segmentation/NrrdTools.d.ts +41 -2
  52. package/dist/types/Utils/segmentation/tools/AiAssistTool.d.ts +108 -5
  53. package/dist/types/Utils/surfaceAnnotation/MeshGraph.d.ts +12 -10
  54. package/dist/types/Utils/surfaceAnnotation/SurfaceAnnotator.d.ts +48 -28
  55. package/dist/types/Utils/surfaceAnnotation/annotationStore.d.ts +4 -4
  56. package/dist/types/Utils/surfaceAnnotation/contourRender.d.ts +1 -1
  57. package/dist/types/Utils/surfaceAnnotation/geodesicContour.d.ts +11 -10
  58. package/dist/types/Utils/surfaceAnnotation/pointMarkers.d.ts +3 -2
  59. package/dist/types/Utils/surfaceAnnotation/raycastSurface.d.ts +3 -2
  60. package/dist/types/Utils/surfaceAnnotation/strokeContour.d.ts +9 -6
  61. package/dist/types/Utils/surfaceAnnotation/types.d.ts +5 -5
  62. package/dist/types/index.d.ts +1 -1
  63. package/package.json +2 -1
@@ -40873,9 +40873,9 @@ void main() {
40873
40873
  }
40874
40874
  }
40875
40875
 
40876
- var vert = "#define GLSLIFY 1\nattribute vec3 position;uniform mat4 modelViewMatrix;uniform mat4 projectionMatrix;varying vec2 vUv;void main(){gl_Position=vec4(position,1.0);vUv=vec2(position.x,position.y)*0.5+0.5;}"; // eslint-disable-line
40876
+ var vert = "attribute vec3 position;\nuniform mat4 modelViewMatrix;\nuniform mat4 projectionMatrix;\nvarying vec2 vUv;\nvoid main() {\n gl_Position = vec4(position, 1.0);\n vUv = vec2(position.x, position.y) * 0.5 + 0.5;\n}\n";
40877
40877
 
40878
- var frag = "precision mediump float;\n#define GLSLIFY 1\nvec3 mod289_0(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}vec4 mod289_0(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}vec4 permute_0(vec4 x){return mod289_0(((x*34.0)+1.0)*x);}vec4 taylorInvSqrt_0(vec4 r){return 1.79284291400159-0.85373472095314*r;}vec3 fade(vec3 t){return t*t*t*(t*(t*6.0-15.0)+10.0);}float pnoise(vec3 P,vec3 rep){vec3 Pi0=mod(floor(P),rep);vec3 Pi1=mod(Pi0+vec3(1.0),rep);Pi0=mod289_0(Pi0);Pi1=mod289_0(Pi1);vec3 Pf0=fract(P);vec3 Pf1=Pf0-vec3(1.0);vec4 ix=vec4(Pi0.x,Pi1.x,Pi0.x,Pi1.x);vec4 iy=vec4(Pi0.yy,Pi1.yy);vec4 iz0=Pi0.zzzz;vec4 iz1=Pi1.zzzz;vec4 ixy=permute_0(permute_0(ix)+iy);vec4 ixy0=permute_0(ixy+iz0);vec4 ixy1=permute_0(ixy+iz1);vec4 gx0=ixy0*(1.0/7.0);vec4 gy0=fract(floor(gx0)*(1.0/7.0))-0.5;gx0=fract(gx0);vec4 gz0=vec4(0.5)-abs(gx0)-abs(gy0);vec4 sz0=step(gz0,vec4(0.0));gx0-=sz0*(step(0.0,gx0)-0.5);gy0-=sz0*(step(0.0,gy0)-0.5);vec4 gx1=ixy1*(1.0/7.0);vec4 gy1=fract(floor(gx1)*(1.0/7.0))-0.5;gx1=fract(gx1);vec4 gz1=vec4(0.5)-abs(gx1)-abs(gy1);vec4 sz1=step(gz1,vec4(0.0));gx1-=sz1*(step(0.0,gx1)-0.5);gy1-=sz1*(step(0.0,gy1)-0.5);vec3 g000=vec3(gx0.x,gy0.x,gz0.x);vec3 g100=vec3(gx0.y,gy0.y,gz0.y);vec3 g010=vec3(gx0.z,gy0.z,gz0.z);vec3 g110=vec3(gx0.w,gy0.w,gz0.w);vec3 g001=vec3(gx1.x,gy1.x,gz1.x);vec3 g101=vec3(gx1.y,gy1.y,gz1.y);vec3 g011=vec3(gx1.z,gy1.z,gz1.z);vec3 g111=vec3(gx1.w,gy1.w,gz1.w);vec4 norm0=taylorInvSqrt_0(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110)));g000*=norm0.x;g010*=norm0.y;g100*=norm0.z;g110*=norm0.w;vec4 norm1=taylorInvSqrt_0(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111)));g001*=norm1.x;g011*=norm1.y;g101*=norm1.z;g111*=norm1.w;float n000=dot(g000,Pf0);float n100=dot(g100,vec3(Pf1.x,Pf0.yz));float n010=dot(g010,vec3(Pf0.x,Pf1.y,Pf0.z));float n110=dot(g110,vec3(Pf1.xy,Pf0.z));float n001=dot(g001,vec3(Pf0.xy,Pf1.z));float n101=dot(g101,vec3(Pf1.x,Pf0.y,Pf1.z));float n011=dot(g011,vec3(Pf0.x,Pf1.yz));float n111=dot(g111,Pf1);vec3 fade_xyz=fade(Pf0);vec4 n_z=mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z);vec2 n_yz=mix(n_z.xy,n_z.zw,fade_xyz.y);float n_xyz=mix(n_yz.x,n_yz.y,fade_xyz.x);return 2.2*n_xyz;}vec3 mod289_1(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}vec4 mod289_1(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}vec4 permute_1(vec4 x){return mod289_1(((x*34.0)+1.0)*x);}vec4 taylorInvSqrt_1(vec4 r){return 1.79284291400159-0.85373472095314*r;}float snoise(vec3 v){const vec2 C=vec2(1.0/6.0,1.0/3.0);const vec4 D=vec4(0.0,0.5,1.0,2.0);vec3 i=floor(v+dot(v,C.yyy));vec3 x0=v-i+dot(i,C.xxx);vec3 g_0=step(x0.yzx,x0.xyz);vec3 l=1.0-g_0;vec3 i1=min(g_0.xyz,l.zxy);vec3 i2=max(g_0.xyz,l.zxy);vec3 x1=x0-i1+C.xxx;vec3 x2=x0-i2+C.yyy;vec3 x3=x0-D.yyy;i=mod289_1(i);vec4 p=permute_1(permute_1(permute_1(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));float n_=0.142857142857;vec3 ns=n_*D.wyz-D.xzx;vec4 j=p-49.0*floor(p*ns.z*ns.z);vec4 x_=floor(j*ns.z);vec4 y_=floor(j-7.0*x_);vec4 x=x_*ns.x+ns.yyyy;vec4 y=y_*ns.x+ns.yyyy;vec4 h=1.0-abs(x)-abs(y);vec4 b0=vec4(x.xy,y.xy);vec4 b1=vec4(x.zw,y.zw);vec4 s0=floor(b0)*2.0+1.0;vec4 s1=floor(b1)*2.0+1.0;vec4 sh=-step(h,vec4(0.0));vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;vec3 p0=vec3(a0.xy,h.x);vec3 p1=vec3(a0.zw,h.y);vec3 p2=vec3(a1.xy,h.z);vec3 p3=vec3(a1.zw,h.w);vec4 norm=taylorInvSqrt_1(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0);m=m*m;return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));}float grain(vec2 texCoord,vec2 resolution,float frame,float multiplier){vec2 mult=texCoord*resolution;float offset=snoise(vec3(mult/multiplier,frame));float n1=pnoise(vec3(mult,offset),vec3(1.0/texCoord*resolution,1.0));return n1/2.0+0.5;}float grain(vec2 texCoord,vec2 resolution,float frame){return grain(texCoord,resolution,frame,2.5);}float grain(vec2 texCoord,vec2 resolution){return grain(texCoord,resolution,0.0);}vec3 blendSoftLight(vec3 base,vec3 blend){return mix(sqrt(base)*(2.0*blend-1.0)+2.0*base*(1.0-blend),2.0*base*blend+base*base*(1.0-2.0*blend),step(base,vec3(0.5)));}uniform vec3 color1;uniform vec3 color2;uniform float aspect;uniform vec2 offset;uniform vec2 scale;uniform float noiseAlpha;uniform bool aspectCorrection;uniform float grainScale;uniform float grainTime;uniform vec2 smooth;varying vec2 vUv;void main(){vec2 q=vec2(vUv-0.5);if(aspectCorrection){q.x*=aspect;}q/=scale;q-=offset;float dst=length(q);dst=smoothstep(smooth.x,smooth.y,dst);vec3 color=mix(color1,color2,dst);if(noiseAlpha>0.0&&grainScale>0.0){float gSize=1.0/grainScale;float g=grain(vUv,vec2(gSize*aspect,gSize),grainTime);vec3 noiseColor=blendSoftLight(color,vec3(g));gl_FragColor.rgb=mix(color,noiseColor,noiseAlpha);}else{gl_FragColor.rgb=color;}gl_FragColor.a=1.0;}"; // eslint-disable-line
40878
+ var frag = "precision mediump float;\n#pragma glslify: grain = require('glsl-film-grain')\n#pragma glslify: blend = require('glsl-blend-soft-light')\n\nuniform vec3 color1;\nuniform vec3 color2;\nuniform float aspect;\nuniform vec2 offset;\nuniform vec2 scale;\nuniform float noiseAlpha;\nuniform bool aspectCorrection;\nuniform float grainScale;\nuniform float grainTime;\nuniform vec2 smooth;\n\nvarying vec2 vUv;\n\nvoid main() {\n vec2 q = vec2(vUv - 0.5);\n if (aspectCorrection) {\n q.x *= aspect;\n }\n q /= scale;\n q -= offset;\n float dst = length(q);\n dst = smoothstep(smooth.x, smooth.y, dst);\n vec3 color = mix(color1, color2, dst);\n\n if (noiseAlpha > 0.0 && grainScale > 0.0) {\n float gSize = 1.0 / grainScale;\n float g = grain(vUv, vec2(gSize * aspect, gSize), grainTime);\n vec3 noiseColor = blend(color, vec3(g));\n gl_FragColor.rgb = mix(color, noiseColor, noiseAlpha);\n } else {\n gl_FragColor.rgb = color;\n }\n gl_FragColor.a = 1.0;\n}\n";
40879
40879
 
40880
40880
  function createBackground(opt) {
40881
40881
  opt = opt || {};
@@ -41403,9 +41403,9 @@ void main() {
41403
41403
  };
41404
41404
  }
41405
41405
 
41406
- var vert_2d = "#define GLSLIFY 1\nout vec2 vUv;void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);vUv=vec2(uv.x,1.0-uv.y);}"; // eslint-disable-line
41406
+ var vert_2d = "out vec2 vUv;\n\nvoid main() {\n\n gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n // Use the geometry's own UVs; DICOM rows are top-down so flip v.\n vUv = vec2( uv.x, 1.0 - uv.y );\n\n}\n";
41407
41407
 
41408
- var frag_2d = "precision highp float;precision highp int;precision highp sampler2DArray;\n#define GLSLIFY 1\nuniform sampler2DArray diffuse;in vec2 vUv;uniform int depth;uniform float uOpacity;out vec4 outColor;void main(){vec4 color=texture(diffuse,vec3(vUv,depth));outColor=vec4(color.rrr*1.5,uOpacity);}"; // eslint-disable-line
41408
+ var frag_2d = "precision highp float;\nprecision highp int;\nprecision highp sampler2DArray;\n\nuniform sampler2DArray diffuse;\nin vec2 vUv;\nuniform int depth;\nuniform float uOpacity;\n\nout vec4 outColor;\n\nvoid main() {\n\n vec4 color = texture( diffuse, vec3( vUv, depth ) );\n\n // lighten a bit\n outColor = vec4( color.rrr * 1.5, uOpacity );\n\n}";
41409
41409
 
41410
41410
  let planeWidth = 80;
41411
41411
  let planeHeight = 80;
@@ -46336,7 +46336,7 @@ void main() {
46336
46336
  mesh.position.y += mesh.position.y - center.y;
46337
46337
  mesh.position.z += mesh.position.z - center.z;
46338
46338
  const boundingBox = new Box3().setFromObject(mesh);
46339
- // 获取边界框的尺寸
46339
+ // Get the size of the bounding box
46340
46340
  const size_ = new Vector3();
46341
46341
  boundingBox.getSize(size_);
46342
46342
  scene.add(mesh);
@@ -58686,7 +58686,7 @@ void main() {
58686
58686
  const _EPS = 0.000001;
58687
58687
  const _STATE = { NONE: - 1, ROTATE: 0, ZOOM: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_ZOOM_PAN: 4 };
58688
58688
 
58689
- const _v2 = new Vector2();
58689
+ const _v2$1 = new Vector2();
58690
58690
  const _mouseChange = new Vector2();
58691
58691
  const _objectUp = new Vector3();
58692
58692
  const _pan = new Vector3();
@@ -59082,23 +59082,23 @@ void main() {
59082
59082
 
59083
59083
  _getMouseOnScreen( pageX, pageY ) {
59084
59084
 
59085
- _v2.set(
59085
+ _v2$1.set(
59086
59086
  ( pageX - this.screen.left ) / this.screen.width,
59087
59087
  ( pageY - this.screen.top ) / this.screen.height
59088
59088
  );
59089
59089
 
59090
- return _v2;
59090
+ return _v2$1;
59091
59091
 
59092
59092
  }
59093
59093
 
59094
59094
  _getMouseOnCircle( pageX, pageY ) {
59095
59095
 
59096
- _v2.set(
59096
+ _v2$1.set(
59097
59097
  ( ( pageX - this.screen.width * 0.5 - this.screen.left ) / ( this.screen.width * 0.5 ) ),
59098
59098
  ( ( this.screen.height + 2 * ( this.screen.top - pageY ) ) / this.screen.width ) // screen.width intentional
59099
59099
  );
59100
59100
 
59101
- return _v2;
59101
+ return _v2$1;
59102
59102
 
59103
59103
  }
59104
59104
 
@@ -59519,7 +59519,7 @@ void main() {
59519
59519
  }
59520
59520
 
59521
59521
  const _inv = new Matrix4();
59522
- /** 世界系命中点 → local 顶点(位置 worldToLocal,法线用逆变换) */
59522
+ /** World-space hit point → local vertex (position via worldToLocal, normal via inverse transform). */
59523
59523
  function worldHitToLocalVertex(h, mesh) {
59524
59524
  const lp = mesh.worldToLocal(h.point.clone());
59525
59525
  _inv.copy(mesh.matrixWorld).invert();
@@ -59534,20 +59534,80 @@ void main() {
59534
59534
  faceIndex: h.faceIndex,
59535
59535
  };
59536
59536
  }
59537
- const _nm = new Matrix3();
59538
- /** local 顶点 → world 位置 + world 法线(供渲染) */
59537
+ const _nm$1 = new Matrix3();
59538
+ /** Local vertex → world position + world normal (for rendering). */
59539
59539
  function localVertexToWorld(v, mesh) {
59540
59540
  const p = new Vector3(v.x, v.y, v.z).applyMatrix4(mesh.matrixWorld);
59541
- _nm.getNormalMatrix(mesh.matrixWorld);
59542
- const n = new Vector3(v.nx, v.ny, v.nz).applyMatrix3(_nm).normalize();
59541
+ _nm$1.getNormalMatrix(mesh.matrixWorld);
59542
+ const n = new Vector3(v.nx, v.ny, v.nz).applyMatrix3(_nm$1).normalize();
59543
59543
  return { p, n };
59544
59544
  }
59545
59545
 
59546
59546
  const ray = new Raycaster();
59547
59547
  const ndc = new Vector2();
59548
+ // scratch objects for the smooth-normal interpolation (avoid per-call allocation)
59549
+ const _nm = new Matrix3();
59550
+ const _lp = new Vector3();
59551
+ const _pa = new Vector3();
59552
+ const _pb = new Vector3();
59553
+ const _pc = new Vector3();
59554
+ const _na = new Vector3();
59555
+ const _nb = new Vector3();
59556
+ const _nc = new Vector3();
59557
+ const _v0 = new Vector3();
59558
+ const _v1 = new Vector3();
59559
+ const _v2 = new Vector3();
59560
+ /**
59561
+ * Interpolate a "smooth per-vertex normal" across the hit triangle using barycentric coordinates,
59562
+ * instead of using the per-face normal.
59563
+ *
59564
+ * Per-face normals on voxel / marching-cubes meshes are axis-aligned stair directions (±x/±y/±z);
59565
+ * using them to push the annotation line outward along the normal shoves points toward neighboring
59566
+ * stairs rather than cleanly "outward", making the line dip in and out on bumpy surfaces and get
59567
+ * occluded by the steps. A smooth normal varies continuously along the surface, so the outward
59568
+ * direction is stable and consistent and the line can float smoothly above the relief. Returns false
59569
+ * when interpolation isn't possible (caller falls back to the face normal).
59570
+ */
59571
+ function interpolateLocalNormal(geom, face, localPoint, out) {
59572
+ const posAttr = geom.getAttribute("position");
59573
+ const norAttr = geom.getAttribute("normal");
59574
+ if (!posAttr || !norAttr)
59575
+ return false;
59576
+ _pa.fromBufferAttribute(posAttr, face.a);
59577
+ _pb.fromBufferAttribute(posAttr, face.b);
59578
+ _pc.fromBufferAttribute(posAttr, face.c);
59579
+ // Barycentric coordinates (local space): u/v/w
59580
+ _v0.subVectors(_pb, _pa);
59581
+ _v1.subVectors(_pc, _pa);
59582
+ _v2.subVectors(localPoint, _pa);
59583
+ const d00 = _v0.dot(_v0);
59584
+ const d01 = _v0.dot(_v1);
59585
+ const d11 = _v1.dot(_v1);
59586
+ const d20 = _v2.dot(_v0);
59587
+ const d21 = _v2.dot(_v1);
59588
+ const denom = d00 * d11 - d01 * d01;
59589
+ if (Math.abs(denom) < 1e-12)
59590
+ return false;
59591
+ const v = (d11 * d20 - d01 * d21) / denom;
59592
+ const w = (d00 * d21 - d01 * d20) / denom;
59593
+ const u = 1 - v - w;
59594
+ _na.fromBufferAttribute(norAttr, face.a);
59595
+ _nb.fromBufferAttribute(norAttr, face.b);
59596
+ _nc.fromBufferAttribute(norAttr, face.c);
59597
+ out
59598
+ .set(0, 0, 0)
59599
+ .addScaledVector(_na, u)
59600
+ .addScaledVector(_nb, v)
59601
+ .addScaledVector(_nc, w);
59602
+ if (out.lengthSq() < 1e-12)
59603
+ return false;
59604
+ out.normalize();
59605
+ return true;
59606
+ }
59548
59607
  /**
59549
- * 把屏幕坐标(clientX/Y)投射到 mesh 表面,返回命中点、world-space 法线与 faceIndex。
59550
- * 未命中返回 null。本地实现,不依赖 Copper3D 未导出的 raycast 内部函数。
59608
+ * Project screen coordinates (clientX/Y) onto the mesh surface, returning the hit point,
59609
+ * world-space normal, and faceIndex. Returns null on a miss. Local implementation that does not
59610
+ * depend on Copper3D's unexported raycast internals.
59551
59611
  */
59552
59612
  function raycastSurface(camera, container, mesh, clientX, clientY) {
59553
59613
  var _a;
@@ -59562,19 +59622,29 @@ void main() {
59562
59622
  const point = h.point.clone();
59563
59623
  const normal = new Vector3(0, 0, 1);
59564
59624
  if (h.face) {
59565
- normal.copy(h.face.normal);
59566
- const nm = new Matrix3().getNormalMatrix(mesh.matrixWorld);
59567
- normal.applyMatrix3(nm).normalize();
59625
+ // Prefer a smooth interpolated normal (stable outward direction on bumpy/voxel meshes);
59626
+ // fall back to the flat face normal if the geometry has no normal attribute.
59627
+ _lp.copy(h.point);
59628
+ mesh.worldToLocal(_lp);
59629
+ if (!interpolateLocalNormal(mesh.geometry, h.face, _lp, normal)) {
59630
+ normal.copy(h.face.normal);
59631
+ }
59632
+ _nm.getNormalMatrix(mesh.matrixWorld);
59633
+ normal.applyMatrix3(_nm).normalize();
59568
59634
  }
59569
59635
  return { point, normal, faceIndex: (_a = h.faceIndex) !== null && _a !== void 0 ? _a : -1 };
59570
59636
  }
59571
59637
 
59572
59638
  /**
59573
- * local 顶点处生成一个标记小球(fiducial)local→world 后沿 world 法线略外移,
59574
- * 避免一半埋进表面。radius 由调用方按模型 bbox 缩放传入。
59639
+ * Create a marker sphere (fiducial) at a local vertex. After local→world, nudge it
59640
+ * outward along the world normal so it doesn't sink halfway into the surface.
59641
+ * radius is passed in by the caller, scaled to the model bbox.
59575
59642
  */
59576
59643
  function makePointMarker(v, mesh, color, radius) {
59577
59644
  const geo = new SphereGeometry(radius, 16, 16);
59645
+ // depthTest ON so markers are occluded by the model when they are on the far side (no
59646
+ // see-through), consistent with the contour lines. Markers are volumetric spheres lifted off
59647
+ // the surface, so they still show on the visible side without z-fighting.
59578
59648
  const mat = new MeshBasicMaterial({ color });
59579
59649
  const m = new Mesh(geo, mat);
59580
59650
  const { p, n } = localVertexToWorld(v, mesh);
@@ -59584,15 +59654,18 @@ void main() {
59584
59654
  }
59585
59655
 
59586
59656
  /**
59587
- * 模式 A(自由手绘)一笔的状态机。
59588
- * pointermove 时不断 addSample;与上一采样点世界距离 < minGap 的样本丢弃,避免过密。
59589
- * 顶点以 local 存(由 mesh 把世界命中点转 local)。
59657
+ * State machine for one stroke in mode A (freehand).
59658
+ * On pointermove it keeps calling addSample; samples whose world distance from the previous
59659
+ * sample is < minGap are dropped to avoid over-density.
59660
+ * Vertices are stored in local space (the mesh converts the world hit point to local).
59590
59661
  */
59591
59662
  class StrokeContour {
59592
59663
  /**
59593
- * @param minGap 采样最小间距(世界系),去抖
59594
- * @param maxJump 跳变阈值(世界系):距离 > maxJump 且法线大幅翻转时丢弃该样本,
59595
- * 避免笔触掠过沟缝/轮廓边时相邻点落在不同深度、直线段横穿模型。
59664
+ * @param minGap minimum sample spacing (world space), debounce
59665
+ * @param maxJump jump threshold (world space): drop the sample when distance > maxJump and the
59666
+ * normal flips sharply, to prevent adjacent points landing at different depths
59667
+ * (and the straight segment cutting through the model) when the stroke grazes a
59668
+ * groove or contour edge.
59596
59669
  */
59597
59670
  constructor(minGap, maxJump, mesh) {
59598
59671
  this.minGap = minGap;
@@ -59611,10 +59684,12 @@ void main() {
59611
59684
  if (this.has) {
59612
59685
  const d = hit.point.distanceTo(this.last);
59613
59686
  if (d < this.minGap)
59614
- return; // 太近,去抖
59615
- // 跳变剔除:距离突然很大「且」法线大幅翻转(>~75°)→ 多半跳到了背面/远面,丢弃,
59616
- // 否则相邻两点之间的直线段会横穿模型内部。两个条件同时满足才剔除:
59617
- // 快速画(距离大、法线相近)不误删;画过尖锐棱边(法线变、距离近)也不误删。
59687
+ return; // too close, debounce
59688
+ // Jump rejection: distance suddenly large AND normal flips sharply (>~75°) likely jumped to
59689
+ // the back/far side, so drop it, otherwise the straight segment between adjacent points cuts
59690
+ // through the model interior. Only reject when both conditions hold:
59691
+ // fast drawing (large distance, similar normals) isn't falsely dropped, and drawing over a
59692
+ // sharp edge (normal changes, distance small) isn't falsely dropped either.
59618
59693
  if (d > this.maxJump && hit.normal.dot(this.lastNormal) < 0.25)
59619
59694
  return;
59620
59695
  }
@@ -60912,9 +60987,10 @@ void main() {
60912
60987
  }
60913
60988
 
60914
60989
  /**
60915
- * 把标注顶点(local)序列展平成 Line2 需要的 world [x,y,z, ...]
60916
- * 每个点 local→world,再沿 world 法线外移 epsilon,避免与模型表面 z-fighting。
60917
- * closed 时把首点追加到末尾形成环。
60990
+ * Flatten a sequence of annotation vertices (local) into the world [x,y,z, ...] that Line2 needs.
60991
+ * Each point goes local→world, then is pushed outward along the world normal by epsilon to avoid
60992
+ * z-fighting with the model surface.
60993
+ * When closed, the first point is appended to the end to form a ring.
60918
60994
  */
60919
60995
  function flatten(verts, closed, epsilon, mesh) {
60920
60996
  const pts = [];
@@ -60930,27 +61006,31 @@ void main() {
60930
61006
  function makeContourLine(verts, color, closed, container, epsilon, mesh) {
60931
61007
  const geo = new LineGeometry();
60932
61008
  const pos = flatten(verts, closed, epsilon, mesh);
60933
- // Line2 至少需要 2 个点;不足时给一个退化占位,后续 update 会补上。
61009
+ // Line2 needs at least 2 points; when there aren't enough, give a degenerate placeholder that a later update fills in.
60934
61010
  geo.setPositions(pos.length >= 6 ? pos : [0, 0, 0, 0, 0, 0]);
60935
61011
  const mat = new LineMaterial({
60936
61012
  color: new Color$1(color).getHex(),
60937
- linewidth: 3, // 像素宽( resolution 配合)
61013
+ linewidth: 3, // width in pixels (requires resolution to be set)
61014
+ // depthTest stays ON so the model correctly occludes the line when it is on the far side
61015
+ // (no see-through). The host applies polygonOffset to the SURFACE material so the line still
61016
+ // wins where it lies coplanar with the surface (no z-fighting / sinking on bumpy meshes).
60938
61017
  });
60939
61018
  mat.resolution.set(container.clientWidth, container.clientHeight);
60940
61019
  const line = new Line2(geo, mat);
60941
61020
  line.computeLineDistances();
60942
61021
  line.renderOrder = 998;
60943
- // 关键:Line2 的包围球停留在初始点,拖拽生长后真实几何会被错误剔除。
60944
- // 关闭视锥剔除,保证线始终渲染。
61022
+ // Important: Line2's bounding sphere stays at the initial point, so once the geometry grows by
61023
+ // dragging, the real geometry gets wrongly culled. Disable frustum culling so the line always renders.
60945
61024
  line.frustumCulled = false;
60946
61025
  return line;
60947
61026
  }
60948
61027
  function updateContourLine(line, verts, closed, epsilon, mesh) {
60949
61028
  const pos = flatten(verts, closed, epsilon, mesh);
60950
61029
  if (pos.length < 6)
60951
- return; // Line2 需要 >=2
60952
- // 关键:Line2 在原 geometry setPositions 扩容后只渲染首段(已知坑)。
60953
- // 改为每次重建一个新 LineGeometry 并替换,保证实例数正确、全段渲染。
61030
+ return; // Line2 needs >=2 points
61031
+ // Important: calling setPositions on the existing geometry to grow it only renders the first
61032
+ // segment (a known pitfall). Instead, rebuild and swap in a new LineGeometry each time so the
61033
+ // instance count is correct and every segment renders.
60954
61034
  const geo = new LineGeometry();
60955
61035
  geo.setPositions(pos);
60956
61036
  const old = line.geometry;
@@ -60958,20 +61038,22 @@ void main() {
60958
61038
  old.dispose();
60959
61039
  line.computeLineDistances();
60960
61040
  }
60961
- /** 改变已有 contour 线的颜色。 */
61041
+ /** Change the color of an existing contour line. */
60962
61042
  function setContourColor(line, color) {
60963
61043
  line.material.color.set(color);
60964
61044
  }
60965
61045
 
60966
61046
  /**
60967
- * BufferGeometry 的索引构建顶点邻接图,用于模式 B(测地线)的最短路径。
60968
- * 顶点坐标按 local 存;传入的查询点需先转为 local(mesh.worldToLocal)。
61047
+ * Build a vertex adjacency graph from a BufferGeometry's index, used for the shortest path in
61048
+ * mode B (geodesic). Vertex coordinates are stored in local space; query points must be converted
61049
+ * to local first (mesh.worldToLocal).
60969
61050
  *
60970
- * 说明:几何须为已索引(indexed)。modelLoader mergeVertices 焊接重复顶点,
60971
- * 保证同一表面位置共享顶点,邻接图才连通。
61051
+ * Note: the geometry must be indexed. modelLoader welds duplicate vertices with mergeVertices so
61052
+ * that vertices at the same surface position are shared, otherwise the adjacency graph won't be connected.
60972
61053
  *
60973
- * 性能:O(V²) Dijkstra + O(V) 最近顶点查找。对几万顶点单次点击可接受;
60974
- * 若过大可后续换二叉堆 + 空间网格加速(留作升级)。
61054
+ * Performance: O(V²) Dijkstra + O(V) nearest-vertex lookup. Acceptable for a single click on tens
61055
+ * of thousands of vertices; if too large it can later be sped up with a binary heap + spatial grid
61056
+ * (left as a future upgrade).
60975
61057
  */
60976
61058
  class MeshGraph {
60977
61059
  constructor(geometry) {
@@ -60999,7 +61081,7 @@ void main() {
60999
61081
  }
61000
61082
  }
61001
61083
  else {
61002
- // 非索引兜底(连通性差,仅避免崩溃)
61084
+ // Non-indexed fallback (poor connectivity, only to avoid crashing).
61003
61085
  for (let i = 0; i + 2 < this.vertexCount; i += 3) {
61004
61086
  addEdge(i, i + 1);
61005
61087
  addEdge(i + 1, i + 2);
@@ -61016,7 +61098,7 @@ void main() {
61016
61098
  pz(i) {
61017
61099
  return this.positions[i * 3 + 2];
61018
61100
  }
61019
- /** 最近顶点(传入 local 坐标) */
61101
+ /** Nearest vertex (pass in local coordinates). */
61020
61102
  nearestVertex(localPoint) {
61021
61103
  let best = -1;
61022
61104
  let bestD = Infinity;
@@ -61033,8 +61115,8 @@ void main() {
61033
61115
  return best;
61034
61116
  }
61035
61117
  /**
61036
- * 两顶点间最短路径(含端点的顶点索引序列)。二叉堆 Dijkstra,O(E log V)
61037
- * 不连通时返回 [start,end] 兜底。
61118
+ * Shortest path between two vertices (sequence of vertex indices including endpoints).
61119
+ * Binary-heap Dijkstra, O(E log V). Falls back to [start,end] when disconnected.
61038
61120
  */
61039
61121
  shortestPath(startV, endV) {
61040
61122
  if (startV === endV)
@@ -61091,7 +61173,7 @@ void main() {
61091
61173
  const nm = new Matrix3().getNormalMatrix(mesh.matrixWorld);
61092
61174
  return n.applyMatrix3(nm).normalize();
61093
61175
  }
61094
- /** 顶点的 local 坐标 + local 法线(图几何即 local 空间) */
61176
+ /** Vertex local coordinates + local normal (the graph geometry is already local space). */
61095
61177
  vertexLocal(i) {
61096
61178
  return {
61097
61179
  x: this.px(i),
@@ -61104,7 +61186,7 @@ void main() {
61104
61186
  };
61105
61187
  }
61106
61188
  }
61107
- /** 极简二叉最小堆( priority 排序,存顶点索引)。用于 Dijkstra */
61189
+ /** Minimal binary min-heap (ordered by priority, stores vertex indices). Used for Dijkstra. */
61108
61190
  class MinHeap {
61109
61191
  constructor() {
61110
61192
  this.ids = [];
@@ -61161,22 +61243,23 @@ void main() {
61161
61243
  }
61162
61244
 
61163
61245
  /**
61164
- * 模式 B(测地线)状态机:逐次点击锚点,相邻锚点之间用 MeshGraph 求最短路径,
61165
- * 拼成一条贴合表面的折线。Enter 结束并可闭合(首尾再求一段)。
61246
+ * State machine for mode B (geodesic): click anchors one by one, find the shortest path between
61247
+ * adjacent anchors with MeshGraph, and stitch them into a polyline that hugs the surface.
61248
+ * Enter finishes and can close the loop (computing one more segment between first and last).
61166
61249
  */
61167
61250
  class GeodesicContour {
61168
61251
  constructor(graph, mesh) {
61169
61252
  this.graph = graph;
61170
61253
  this.mesh = mesh;
61171
- this.anchors = []; // 顶点索引
61172
- this.segments = []; // 相邻锚点间路径(含端点)
61173
- this.history = []; // 每次增删前的锚点快照,用于撤销
61254
+ this.anchors = []; // vertex indices
61255
+ this.segments = []; // path between adjacent anchors (endpoints included)
61256
+ this.history = []; // anchor snapshot before each add/remove, for undo
61174
61257
  }
61175
- /** 任何改动锚点前先存一份快照(顶点索引数组很小,快照成本可忽略) */
61258
+ /** Save a snapshot before any anchor change (the vertex-index array is tiny, so snapshot cost is negligible). */
61176
61259
  snapshot() {
61177
61260
  this.history.push(this.anchors.slice());
61178
61261
  }
61179
- /** 传入 local 命中点,snap 到最近顶点并对上一锚点求路径。 */
61262
+ /** Given a local hit point, snap to the nearest vertex and compute the path to the previous anchor. */
61180
61263
  addAnchor(localHitPoint) {
61181
61264
  const v = this.graph.nearestVertex(localHitPoint);
61182
61265
  this.snapshot();
@@ -61186,7 +61269,7 @@ void main() {
61186
61269
  }
61187
61270
  this.anchors.push(v);
61188
61271
  }
61189
- /** 删除指定下标的锚点,重算相邻段(支持取消中间任意一点) */
61272
+ /** Remove the anchor at the given index and recompute adjacent segments (allows canceling any middle point). */
61190
61273
  removeAnchorAt(index) {
61191
61274
  if (index < 0 || index >= this.anchors.length)
61192
61275
  return;
@@ -61194,11 +61277,11 @@ void main() {
61194
61277
  this.anchors.splice(index, 1);
61195
61278
  this.rebuildSegments();
61196
61279
  }
61197
- /** 是否还有可撤销的编辑(加点/删点) */
61280
+ /** Whether there is still an editable change to undo (add/remove point). */
61198
61281
  canUndo() {
61199
61282
  return this.history.length > 0;
61200
61283
  }
61201
- /** 撤销上一次锚点编辑(加点删回去;删点恢复)。无历史返回 false */
61284
+ /** Undo the last anchor edit (addremove it; remove restore it). Returns false when no history. */
61202
61285
  undoEdit() {
61203
61286
  const prev = this.history.pop();
61204
61287
  if (!prev)
@@ -61207,21 +61290,21 @@ void main() {
61207
61290
  this.rebuildSegments();
61208
61291
  return true;
61209
61292
  }
61210
- /** 据当前锚点序列整体重算所有相邻段路径。 */
61293
+ /** Recompute all adjacent-segment paths from the current anchor sequence. */
61211
61294
  rebuildSegments() {
61212
61295
  this.segments = [];
61213
61296
  for (let i = 1; i < this.anchors.length; i++) {
61214
61297
  this.segments.push(this.graph.shortestPath(this.anchors[i - 1], this.anchors[i]));
61215
61298
  }
61216
61299
  }
61217
- /** 各锚点的 local 顶点(用于绘制可见的锚点标记) */
61300
+ /** Local vertex of each anchor (used to draw visible anchor markers). */
61218
61301
  getAnchorLocals() {
61219
61302
  return this.anchors.map((i) => this.graph.vertexLocal(i));
61220
61303
  }
61221
61304
  get anchorCount() {
61222
61305
  return this.anchors.length;
61223
61306
  }
61224
- /** 把所有路径顶点(world)+ 法线 拼成折线;closed 时补一段首尾路径。 */
61307
+ /** Stitch all path vertices (world) + normals into a polyline; when closed, add one first-to-last segment. */
61225
61308
  buildVertices(closed) {
61226
61309
  const segs = this.segments.slice();
61227
61310
  if (closed && this.anchors.length > 2) {
@@ -61229,22 +61312,22 @@ void main() {
61229
61312
  }
61230
61313
  const idxPath = [];
61231
61314
  segs.forEach((s, si) => {
61232
- const start = si === 0 ? 0 : 1; // 去重相邻段共享端点
61315
+ const start = si === 0 ? 0 : 1; // dedupe the endpoint shared between adjacent segments
61233
61316
  for (let k = start; k < s.length; k++)
61234
61317
  idxPath.push(s[k]);
61235
61318
  });
61236
61319
  if (idxPath.length === 0 && this.anchors.length === 1) {
61237
61320
  idxPath.push(this.anchors[0]);
61238
61321
  }
61239
- // 图几何即 local 空间,直接产出 local 顶点(渲染时再派生 world)
61322
+ // The graph geometry is already local space, so emit local vertices directly (world is derived at render time).
61240
61323
  return idxPath.map((i) => this.graph.vertexLocal(i));
61241
61324
  }
61242
61325
  }
61243
61326
 
61244
61327
  /**
61245
- * 标注数据模型 + 撤销栈 + 导出。纯数据,无 three / DOM 依赖。
61246
- * 撤销只记录 add / remove 两类原子操作(满足"撤销最近添加 / 恢复删除")
61247
- * 通过 subscribe 通知 UI 与渲染层(后者据 list() 做场景对账)
61328
+ * Annotation data model + undo stack + export. Pure data, no three / DOM dependencies.
61329
+ * Undo only records two atomic operations, add / remove (enough for "undo last add / restore delete").
61330
+ * Notifies the UI and render layer via subscribe (the latter reconciles the scene against list()).
61248
61331
  */
61249
61332
  class AnnotationStore {
61250
61333
  constructor() {
@@ -61287,7 +61370,7 @@ void main() {
61287
61370
  }
61288
61371
  this.notify();
61289
61372
  }
61290
- /** 清空,返回被清掉的项(供渲染层 dispose three 对象) */
61373
+ /** Clear all, returning the removed items (so the render layer can dispose three objects). */
61291
61374
  clear() {
61292
61375
  const old = this.items;
61293
61376
  this.items = [];
@@ -61348,13 +61431,14 @@ void main() {
61348
61431
  const LINE_W = 3;
61349
61432
  const LINE_W_SEL = 6;
61350
61433
  /**
61351
- * 表面标注主控制器(Phase 4):navigate / freehand / geodesic / point 四模式,
61352
- * Enter 闭合,数据交给 AnnotationStore 管理(多条带颜色标签、撤销/删除/清空、导出)。
61353
- * 渲染层据 store.list() 做场景对账(reconcile),撤销/删除自动加/移 three 对象。
61434
+ * Main surface-annotation controller (Phase 4): four modes navigate / freehand / geodesic / point,
61435
+ * Enter closes the loop, and data is managed by AnnotationStore (multiple color-labeled strips,
61436
+ * undo/delete/clear, export).
61437
+ * The render layer reconciles the scene against store.list(); undo/delete automatically add/remove three objects.
61354
61438
  */
61355
61439
  class SurfaceAnnotator {
61356
61440
  constructor(opts) {
61357
- var _a, _b;
61441
+ var _a, _b, _c;
61358
61442
  this.mode = "navigate";
61359
61443
  this.spaceHeld = false;
61360
61444
  this.pointerDown = false;
@@ -61362,12 +61446,12 @@ void main() {
61362
61446
  this.managed = new Set();
61363
61447
  this.seq = 0;
61364
61448
  this.selectedId = null;
61365
- this.activeGeoMarkers = []; // 进行中测地线的可见锚点
61449
+ this.activeGeoMarkers = []; // visible anchors of the in-progress geodesic
61366
61450
  this.geoRay = new Raycaster();
61367
61451
  this.geoNdc = new Vector2();
61368
61452
  this.hoveredGeoMarker = -1;
61369
61453
  this._projV = new Vector3();
61370
- /** 窗口尺寸变化时更新所有 fat line 的像素分辨率,否则线宽会失真。 */
61454
+ /** Update the pixel resolution of all fat lines on window resize, otherwise line width distorts. */
61371
61455
  this.onResize = () => {
61372
61456
  const w = this.o.container.clientWidth;
61373
61457
  const h = this.o.container.clientHeight;
@@ -61386,7 +61470,7 @@ void main() {
61386
61470
  if (this.spaceHeld)
61387
61471
  return;
61388
61472
  if (e.button !== 0)
61389
- return; // 仅左键
61473
+ return; // left button only
61390
61474
  if (!this.insideContainer(e))
61391
61475
  return;
61392
61476
  this.pointerDown = true;
@@ -61421,7 +61505,7 @@ void main() {
61421
61505
  return;
61422
61506
  }
61423
61507
  if (this.mode === "geodesic") {
61424
- // 先判断是否点中了一个已有锚点取消该点(支持取消其中任意一点)
61508
+ // First check whether an existing anchor was clicked cancel that point (any point can be canceled).
61425
61509
  const pick = this.pickGeoMarker(e);
61426
61510
  if (pick >= 0 && this.activeGeo) {
61427
61511
  this.activeGeo.removeAnchorAt(pick);
@@ -61433,7 +61517,7 @@ void main() {
61433
61517
  }
61434
61518
  return;
61435
61519
  }
61436
- // 否则在表面落一个新锚点。
61520
+ // Otherwise drop a new anchor on the surface.
61437
61521
  const h = this.hit(e);
61438
61522
  if (!h)
61439
61523
  return;
@@ -61451,7 +61535,7 @@ void main() {
61451
61535
  this.setGeoHover(-1);
61452
61536
  return;
61453
61537
  }
61454
- // 测地线模式下,悬停到锚点小球时显示"✕"提示(该点可被点击取消)
61538
+ // In geodesic mode, show the "✕" hint when hovering an anchor sphere (that point can be clicked to cancel).
61455
61539
  if (this.mode === "geodesic" && !this.pointerDown) {
61456
61540
  this.setGeoHover(this.insideContainer(e) ? this.pickGeoMarker(e) : -1);
61457
61541
  }
@@ -61516,7 +61600,7 @@ void main() {
61516
61600
  return;
61517
61601
  }
61518
61602
  if (e.key === "Enter") {
61519
- // 测地线进行中结束并闭合;否则闭合最近一条自由手绘线。
61603
+ // Geodesic in progress finish and close it; otherwise close the most recent freehand line.
61520
61604
  if (this.activeGeo)
61521
61605
  this.finishGeodesic();
61522
61606
  else
@@ -61541,24 +61625,25 @@ void main() {
61541
61625
  this.o = opts;
61542
61626
  const meshGeo = opts.mesh.geometry;
61543
61627
  if (!meshGeo.getAttribute("normal"))
61544
- meshGeo.computeVertexNormals(); // 渲染需要法线
61628
+ meshGeo.computeVertexNormals(); // rendering needs normals
61545
61629
  meshGeo.computeBoundingBox();
61546
61630
  const diag = (_a = opts.bboxDiagonal) !== null && _a !== void 0 ? _a : meshGeo.boundingBox.getSize(new Vector3()).length();
61547
61631
  this.markerRadius = (_b = opts.markerRadius) !== null && _b !== void 0 ? _b : diag * 0.0032;
61548
- this.epsilon = diag * 0.002;
61632
+ this.epsilon = (_c = opts.epsilon) !== null && _c !== void 0 ? _c : diag * 0.002;
61549
61633
  this.minGap = diag * 0.004;
61550
61634
  this.maxJump = diag * 0.05;
61551
- // 测地线连通性:单独焊接一份"仅位置"的几何给 MeshGraph 用——
61552
- // 不动被渲染的 mesh(保留其法线/UV/纹理)。仅位置可保证同表面位置的顶点
61553
- // mergeVertices 合并(否则逐面法线/UV 会阻止合并,图断裂 → 闭合穿模)。
61635
+ // Geodesic connectivity: weld a separate "position-only" geometry for MeshGraph to use —
61636
+ // leaving the rendered mesh untouched (keeping its normals/UV/texture). Position-only ensures
61637
+ // vertices at the same surface position get merged by mergeVertices (otherwise per-face
61638
+ // normals/UV would block the merge, breaking the graph → the closed loop cuts through the model).
61554
61639
  const posOnly = new BufferGeometry();
61555
61640
  posOnly.setAttribute("position", meshGeo.getAttribute("position").clone());
61556
61641
  const graphGeo = mergeVertices(posOnly);
61557
61642
  graphGeo.computeVertexNormals();
61558
61643
  this.graph = new MeshGraph(graphGeo);
61559
61644
  this.store.subscribe(() => this.reconcile());
61560
- // 监听挂到 window 的捕获阶段(capture=true):在 copper TrackballControls
61561
- // 之前拿到每一个指针事件,避免它的 setPointerCapture / 事件路由导致拖拽中途丢 move。
61645
+ // Listen on window in the capture phase (capture=true): receive every pointer event before
61646
+ // copper's TrackballControls, so its setPointerCapture / event routing doesn't drop moves mid-drag.
61562
61647
  window.addEventListener("pointerdown", this.onPointerDown, true);
61563
61648
  window.addEventListener("pointermove", this.onPointerMove, true);
61564
61649
  window.addEventListener("pointerup", this.onPointerUp, true);
@@ -61579,13 +61664,13 @@ void main() {
61579
61664
  var _a;
61580
61665
  return (_a = this.o.pointColor) !== null && _a !== void 0 ? _a : "#ffd166";
61581
61666
  }
61582
- // ---- 对外 API( Vue 调用) ----
61667
+ // ---- Public API (called from Vue) ----
61583
61668
  getMode() {
61584
61669
  return this.mode;
61585
61670
  }
61586
61671
  setMode(m) {
61587
61672
  var _a, _b;
61588
- // 离开测地线模式时丢弃尚未 Enter 落定的进行中测地线(清掉残留的锚点与线)
61673
+ // When leaving geodesic mode, discard the in-progress geodesic not yet committed with Enter (clear leftover anchors and line).
61589
61674
  if (m !== "geodesic" && this.activeGeo)
61590
61675
  this.clearActiveGeo();
61591
61676
  this.mode = m;
@@ -61595,13 +61680,13 @@ void main() {
61595
61680
  getStore() {
61596
61681
  return this.store;
61597
61682
  }
61598
- /** 当前标注列表快照。 */
61683
+ /** Snapshot of the current annotation list. */
61599
61684
  getAnnotations() {
61600
61685
  return this.store.list();
61601
61686
  }
61602
61687
  undo() {
61603
- // 测地线进行中撤销最近一次锚点编辑(加点 / 取消点都能回退);
61604
- // 否则撤销最近一条已落定的标注。
61688
+ // Geodesic in progress undo the most recent anchor edit (both adding and canceling a point can be rolled back);
61689
+ // otherwise undo the most recently committed annotation.
61605
61690
  if (this.activeGeo && this.activeGeo.canUndo()) {
61606
61691
  this.activeGeo.undoEdit();
61607
61692
  if (this.activeGeo.anchorCount === 0)
@@ -61635,7 +61720,7 @@ void main() {
61635
61720
  this.selectedId = id;
61636
61721
  this.applySelection();
61637
61722
  }
61638
- /** 颜色变更后重画对应 three 对象。 */
61723
+ /** Redraw the corresponding three object after a color change. */
61639
61724
  refreshAnnotation(id) {
61640
61725
  const a = this.store.get(id);
61641
61726
  if (!a || !a.object3D)
@@ -61651,12 +61736,12 @@ void main() {
61651
61736
  exportJSON(modelName, opts) {
61652
61737
  return this.store.toJSON(modelName, this.o.mesh, opts);
61653
61738
  }
61654
- // ---- 内部 ----
61739
+ // ---- Internal ----
61655
61740
  applyCameraGating() {
61656
- // 按住 Space 临时旋转优先;否则仅 navigate 模式开相机。
61741
+ // Holding Space for temporary rotation takes priority; otherwise enable the camera only in navigate mode.
61657
61742
  this.o.controls.enabled = this.spaceHeld || this.mode === "navigate";
61658
61743
  }
61659
- /** store.list() 对账场景:补齐缺失对象、移除已删对象( dispose,留给撤销恢复) */
61744
+ /** Reconcile the scene against store.list(): add missing objects, remove deleted ones (no dispose, kept for undo restore). */
61660
61745
  reconcile() {
61661
61746
  var _a, _b;
61662
61747
  const present = new Set();
@@ -61706,29 +61791,53 @@ void main() {
61706
61791
  return raycastSurface(this.o.camera, this.o.container, this.o.mesh, e.clientX, e.clientY);
61707
61792
  }
61708
61793
  /**
61709
- * 事件目标是否是容器内的 WebGL canvas
61710
- * 仅认 canvas:面板(GUIDE / 控制面板 / 标注列表的按钮)都是 container 的子元素,
61711
- * 若只判断 contains 会把面板上的点击也当成在模型上作画 —— 这会"穿透"删除按钮、
61712
- * 难以点中。只响应 canvas 上的指针事件即可彻底隔离 UI 与画布。
61794
+ * Whether the event target is the WebGL canvas inside the container.
61795
+ * Only accept the canvas: panels (GUIDE / control panel / thebuttons in the annotation list)
61796
+ * are all children of the container, so checking contains alone would treat clicks on the panels
61797
+ * as drawing on the model which "leaks through" the delete buttons and makes ✕ hard to click.
61798
+ * Responding only to pointer events on the canvas fully isolates the UI from the drawing surface.
61713
61799
  */
61714
61800
  insideContainer(e) {
61715
61801
  const t = e.target;
61716
61802
  return !!t && t.tagName === "CANVAS" && this.o.container.contains(t);
61717
61803
  }
61718
- /** 射线拾取进行中的锚点小球,返回锚点下标;未命中返回 -1。 */
61804
+ /**
61805
+ * Pick an in-progress anchor: return the index of the nearest anchor (screen pixel distance <
61806
+ * tolerance), or -1 on a miss.
61807
+ *
61808
+ * Uses "screen-space pixel distance" instead of ray/sphere intersection: the anchor spheres are
61809
+ * tiny, so strict ray/sphere hits require pixel-perfect aiming and most clicks miss — and a miss
61810
+ * falls through to the "add anchor" branch, so trying to delete a point ends up adding one. That
61811
+ * was the root cause of the old "deleting a point takes several clicks, must hit the sphere dead
61812
+ * center" problem. Projecting each anchor to the screen and taking the nearest within tolerance
61813
+ * makes deletion stable and reliable. The tolerance is enlarged relative to the sphere's screen
61814
+ * radius to make it easy to hit.
61815
+ */
61719
61816
  pickGeoMarker(e) {
61720
61817
  if (!this.activeGeoMarkers.length)
61721
61818
  return -1;
61722
61819
  const rect = this.o.container.getBoundingClientRect();
61723
- this.geoNdc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
61724
- this.geoNdc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
61725
- this.geoRay.setFromCamera(this.geoNdc, this.o.camera);
61726
- const hits = this.geoRay.intersectObjects(this.activeGeoMarkers, false);
61727
- if (!hits.length)
61728
- return -1;
61729
- return this.activeGeoMarkers.indexOf(hits[0].object);
61820
+ const px = e.clientX - rect.left;
61821
+ const py = e.clientY - rect.top;
61822
+ const TOL = 16; // hit tolerance (pixels), larger than the sphere itself for forgiving clicks
61823
+ let best = -1;
61824
+ let bestDist = TOL;
61825
+ const v = this._projV;
61826
+ for (let i = 0; i < this.activeGeoMarkers.length; i++) {
61827
+ v.copy(this.activeGeoMarkers[i].position).project(this.o.camera);
61828
+ if (v.z > 1)
61829
+ continue; // projects behind the camera, skip
61830
+ const sx = (v.x * 0.5 + 0.5) * rect.width;
61831
+ const sy = (-v.y * 0.5 + 0.5) * rect.height;
61832
+ const d = Math.hypot(sx - px, sy - py);
61833
+ if (d < bestDist) {
61834
+ bestDist = d;
61835
+ best = i;
61836
+ }
61837
+ }
61838
+ return best;
61730
61839
  }
61731
- /** 设置当前悬停的锚点(-1 = ):更新高亮缩放、光标与"✕(可取消)"悬浮标。 */
61840
+ /** Set the currently hovered anchor (-1 = none): update highlight scale, cursor, and the "✕ (cancel)" floating badge. */
61732
61841
  setGeoHover(idx) {
61733
61842
  if (idx === this.hoveredGeoMarker) {
61734
61843
  if (idx >= 0)
@@ -61752,7 +61861,7 @@ void main() {
61752
61861
  this.o.container.style.cursor = "";
61753
61862
  }
61754
61863
  }
61755
- /** 懒创建悬浮(挂在 container 内,pointer-events:none 不挡点击) */
61864
+ /** Lazily create the floating badge (appended inside the container, pointer-events:none so it doesn't block clicks). */
61756
61865
  ensureGeoBadge() {
61757
61866
  if (this.geoHoverBadge)
61758
61867
  return this.geoHoverBadge;
@@ -61780,7 +61889,7 @@ void main() {
61780
61889
  this.geoHoverBadge = b;
61781
61890
  return b;
61782
61891
  }
61783
- /** 标定位到锚点投影到屏幕的位置(正中心,即光标悬停处,避免歧义) */
61892
+ /** Position the badge at the anchor's screen projection (dead center, i.e. where the cursor hovers, to avoid ambiguity). */
61784
61893
  positionGeoBadge(marker) {
61785
61894
  if (!marker)
61786
61895
  return;
@@ -61792,7 +61901,7 @@ void main() {
61792
61901
  b.style.left = `${x}px`;
61793
61902
  b.style.top = `${y}px`;
61794
61903
  }
61795
- /** 据当前锚点重建可见的锚点小球(锚点比放点稍大,便于看见与点中取消) */
61904
+ /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and click to cancel). */
61796
61905
  rebuildGeoMarkers() {
61797
61906
  this.setGeoHover(-1);
61798
61907
  for (const m of this.activeGeoMarkers) {
@@ -61808,7 +61917,7 @@ void main() {
61808
61917
  this.activeGeoMarkers.push(marker);
61809
61918
  }
61810
61919
  }
61811
- /** 据当前锚点重画进行中的测地线(不闭合);不足两点时移除线。 */
61920
+ /** Redraw the in-progress geodesic from the current anchors (not closed); remove the line when fewer than two points. */
61812
61921
  redrawGeoLine() {
61813
61922
  if (!this.activeGeo)
61814
61923
  return;
@@ -61829,7 +61938,7 @@ void main() {
61829
61938
  updateContourLine(this.activeGeoLine, verts, false, this.epsilon, this.o.mesh);
61830
61939
  }
61831
61940
  }
61832
- /** 丢弃进行中的测地线:移除并 dispose 线与全部锚点。 */
61941
+ /** Discard the in-progress geodesic: remove and dispose the line and all anchors. */
61833
61942
  clearActiveGeo() {
61834
61943
  this.setGeoHover(-1);
61835
61944
  if (this.activeGeoLine) {
@@ -61844,7 +61953,7 @@ void main() {
61844
61953
  this.activeGeoMarkers = [];
61845
61954
  this.activeGeo = undefined;
61846
61955
  }
61847
- /** 移除进行中测地线的全部锚点小球(落定后调用:只留下线) */
61956
+ /** Remove all anchor spheres of the in-progress geodesic (called after committing: only the line remains). */
61848
61957
  removeGeoMarkers() {
61849
61958
  this.setGeoHover(-1);
61850
61959
  for (const m of this.activeGeoMarkers) {
@@ -61857,7 +61966,7 @@ void main() {
61857
61966
  const last = this.lastFreehand;
61858
61967
  if (!last || last.closed || last.vertices.length < 3 || !last.object3D)
61859
61968
  return;
61860
- // 用测地线沿表面把末点连回首点,避免直线弦从模型内部"抄近路"被遮挡。
61969
+ // Use a geodesic along the surface to connect the last point back to the first, so a straight chord doesn't "shortcut" through the model interior and get occluded.
61861
61970
  const tail = last.vertices[last.vertices.length - 1];
61862
61971
  const head = last.vertices[0];
61863
61972
  const closing = this.surfacePathBetween(tail, head);
@@ -61865,14 +61974,14 @@ void main() {
61865
61974
  last.closed = true;
61866
61975
  updateContourLine(last.object3D, last.vertices, true, this.epsilon, this.o.mesh);
61867
61976
  }
61868
- /** 沿网格表面求 a→b 的测地路径(local 顶点),去掉与 a 重合的首点。a/b 已是 local */
61977
+ /** Compute the geodesic path a→b along the mesh surface (local vertices), dropping the first point that coincides with a. a/b are already local. */
61869
61978
  surfacePathBetween(a, b) {
61870
61979
  const va = this.graph.nearestVertex(new Vector3(a.x, a.y, a.z));
61871
61980
  const vb = this.graph.nearestVertex(new Vector3(b.x, b.y, b.z));
61872
61981
  const path = this.graph.shortestPath(va, vb);
61873
61982
  return path.slice(1).map((i) => this.graph.vertexLocal(i));
61874
61983
  }
61875
- /** 结束当前测地线:闭合成环并落定为一条 contour */
61984
+ /** Finish the current geodesic: close it into a ring and commit it as one contour. */
61876
61985
  finishGeodesic() {
61877
61986
  if (!this.activeGeo || !this.activeGeoLine)
61878
61987
  return;
@@ -61896,7 +62005,7 @@ void main() {
61896
62005
  this.o.scene.remove(this.activeGeoLine);
61897
62006
  this.disposeObject(this.activeGeoLine);
61898
62007
  }
61899
- // 落定后清掉可见锚点,只留下贴合表面的线。
62008
+ // After committing, clear the visible anchors and leave only the surface-hugging line.
61900
62009
  this.removeGeoMarkers();
61901
62010
  this.activeGeo = undefined;
61902
62011
  this.activeGeoLine = undefined;
@@ -62011,9 +62120,9 @@ void main() {
62011
62120
  copperVtkLoader(url, this.scene, this.content);
62012
62121
  }
62013
62122
  /**
62014
- * 在给定模型表面创建标注器( contour / 放点 / 导出坐标)
62015
- * target 可为单个 Mesh,或 Group/Object3D(自动选顶点数最多的 mesh)
62016
- * 复用本 scene camera / container / controls;几何非索引时内部自动焊接索引化。
62123
+ * Create an annotator on the surface of a given model (draw contours / place points / export coordinates).
62124
+ * target can be a single Mesh, or a Group/Object3D (the mesh with the most vertices is chosen automatically).
62125
+ * Reuses this scene's camera / container / controls; non-indexed geometry is welded and indexed automatically.
62017
62126
  */
62018
62127
  createSurfaceAnnotator(target, opts) {
62019
62128
  const mesh = this.pickAnnotatableMesh(target);
@@ -62041,7 +62150,7 @@ void main() {
62041
62150
  });
62042
62151
  return best;
62043
62152
  }
62044
- /** 释放本 scene 创建的所有标注器(移除事件监听与标注对象) */
62153
+ /** Dispose all annotators created on this scene (removes event listeners and annotation objects). */
62045
62154
  disposeSurfaceAnnotators() {
62046
62155
  this.surfaceAnnotators.forEach((a) => a.dispose());
62047
62156
  this.surfaceAnnotators = [];
@@ -79015,14 +79124,29 @@ void main() {
79015
79124
  this.committedVol = null;
79016
79125
  this.promptTool = "point";
79017
79126
  this.polarity = 1; // 1 = foreground (positive), 0 = background
79018
- /** Target channel/label (1-8) the AI paints into the "AI layer" channel. */
79019
- 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;
79020
79131
  /** Scribble brush radius (px), user-adjustable via slider. */
79021
79132
  this.scribbleSize = 5;
79022
79133
  /** Accumulated prompts for the CURRENT slice; reset on slice/axis change. */
79023
79134
  this.points = [];
79024
79135
  this.scribble = [];
79136
+ /** Lasso contour sent to the backend (densified along the curve at finish). */
79137
+ this.lasso = [];
79025
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 };
79026
79150
  // Drag state (box / scribble) — voxel coords for the prompt
79027
79151
  this.dragging = false;
79028
79152
  this.dragStart = null;
@@ -79033,8 +79157,14 @@ void main() {
79033
79157
  // Live cursor position (screen px) for the scribble brush-size preview ring —
79034
79158
  // updated on every hover move so the ring follows the mouse like the paint brush.
79035
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;
79036
79163
  /** Fired when a prompt gesture completes — app calls backend, then applyMask(). */
79037
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;
79038
79168
  this.host = host;
79039
79169
  }
79040
79170
  // ── Configuration (driven from the panel via NrrdTools) ────────────────────
@@ -79052,16 +79182,51 @@ void main() {
79052
79182
  setScribbleSize(size) {
79053
79183
  this.scribbleSize = Math.max(1, Math.min(40, Math.round(size)));
79054
79184
  }
79055
- setChannel(channel) {
79056
- const c = Math.max(1, Math.min(8, Math.round(channel)));
79057
- // Changing channel starts a NEW region in the new colour otherwise the
79058
- // accumulated points would be re-predicted and repainted in the new label,
79059
- // recolouring the previous channel's region.
79060
- 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)
79061
79191
  this.resetPrompts();
79062
- 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);
79063
79229
  }
79064
- getChannel() { return this.channel; }
79065
79230
  getScratchVolume() { return this.scratchVol; }
79066
79231
  /** Serialize the scratch volume as per-z-slice RLE (only NON-empty slices),
79067
79232
  * for persisting to ai_generated_nii_LPS on the backend. Binary (any channel →
@@ -79103,6 +79268,50 @@ void main() {
79103
79268
  }
79104
79269
  return { axis: "z", width, height, slices: out };
79105
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
+ }
79106
79315
  // ── Sandbox lifecycle ──────────────────────────────────────────────────────
79107
79316
  /** Create (or reuse) the scratch volume sized to the layer grid and register
79108
79317
  * it under maskData.volumes['aiScratch']. Snapshots the empty buffer. */
@@ -79123,14 +79332,10 @@ void main() {
79123
79332
  this.scratchVol.clear();
79124
79333
  }
79125
79334
  volumes[AI_SCRATCH_LAYER] = this.scratchVol;
79126
- // Paint the AI scratch with the AI-Assist palette (channel 1 = cyan) so the 2D
79127
- // overlay matches the cyan ai_generated GLB. Scoped to THIS volume only — the
79128
- // global palette / clinician mask colours are untouched.
79129
- for (let ch = 1; ch <= 8; ch++) {
79130
- const c = AI_MASK_CHANNEL_COLORS[ch];
79131
- if (c)
79132
- this.scratchVol.setChannelColor(ch, { r: c.r, g: c.g, b: c.b, a: c.a });
79133
- }
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.
79134
79339
  this.snapshotData = this.scratchVol.getRawData().slice();
79135
79340
  this.committedVol = null; // nothing frozen at session start
79136
79341
  this.resetPrompts();
@@ -79176,10 +79381,26 @@ void main() {
79176
79381
  resetPrompts() {
79177
79382
  this.points = [];
79178
79383
  this.scribble = [];
79384
+ this.lasso = [];
79179
79385
  this.box = undefined;
79180
79386
  this.dragScreenStart = null;
79181
79387
  this.dragScreen = null;
79182
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);
79183
79404
  }
79184
79405
  // ── Coordinate mapping (all three views) ───────────────────────────────────
79185
79406
  //
@@ -79240,14 +79461,53 @@ void main() {
79240
79461
  return null;
79241
79462
  return { vx, vy, w: sd.w, h: sd.h };
79242
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
+ }
79243
79497
  // ── Pointer handlers (left button; right stays pan in EventRouter) ──────────
79244
79498
  onPointerDown(e) {
79245
79499
  const hit = this.toVoxel(e);
79246
79500
  if (!hit)
79247
79501
  return;
79248
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
+ }
79249
79508
  if (this.promptTool === "point") {
79250
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
79251
79511
  this.emit();
79252
79512
  }
79253
79513
  else {
@@ -79263,9 +79523,43 @@ void main() {
79263
79523
  }
79264
79524
  }
79265
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
+ }
79266
79555
  onPointerMove(e) {
79267
- // Track hover for the scribble preview ring even when not drawing.
79556
+ // Track hover for the scribble preview ring / point crosshair even when idle.
79268
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
+ }
79269
79563
  if (!this.dragging)
79270
79564
  return;
79271
79565
  // Track screen pos first so the live preview follows even slightly out of bounds.
@@ -79318,12 +79612,116 @@ void main() {
79318
79612
  box: this.box,
79319
79613
  scribble: this.scribble.length ? [...this.scribble] : undefined,
79320
79614
  scribbleRadius: this.scribbleSize,
79615
+ lasso: this.lasso.length ? [...this.lasso] : undefined,
79321
79616
  });
79322
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
+ }
79323
79719
  // ── Apply backend result → scratch volume ──────────────────────────────────
79324
79720
  applyMask(result) {
79325
79721
  if (!this.scratchVol)
79326
79722
  return;
79723
+ // A prediction landed → hide the point seed markers; only the mask should show.
79724
+ this.hidePointMarkers = true;
79327
79725
  // 3D result (engine B): write each slice across the covered span.
79328
79726
  if (result.slices && result.sliceRange) {
79329
79727
  const [lo] = result.sliceRange;
@@ -79353,7 +79751,7 @@ void main() {
79353
79751
  pos += run;
79354
79752
  value ^= 1;
79355
79753
  }
79356
- const ch = this.channel;
79754
+ const ch = this.activeLabel;
79357
79755
  try {
79358
79756
  const out = this.scratchVol.getSliceUint8(sliceIndex, axis).data; // copy
79359
79757
  // Frozen same-channel pixels for this slice (if any region was committed).
@@ -79426,10 +79824,7 @@ void main() {
79426
79824
  }
79427
79825
  targetCtx.restore();
79428
79826
  }
79429
- // Scribble brush-size preview ring (when NOT dragging) — a circle centred on
79430
- // the cursor whose radius = scribbleSize, so the user sees the brush size and
79431
- // how the slider changes it (mirrors the paint brush's preview ring). Drawn
79432
- // every frame from the live hover position by copper3d's render loop.
79827
+ // Scribble brush-size preview ring (when NOT dragging).
79433
79828
  if (this.promptTool === "scribble" && !this.dragging && this.hoverScreen) {
79434
79829
  targetCtx.save();
79435
79830
  const fg = this.polarity === 1;
@@ -79442,8 +79837,105 @@ void main() {
79442
79837
  targetCtx.stroke();
79443
79838
  targetCtx.restore();
79444
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
+ }
79445
79870
  }
79446
- }
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;
79447
79939
 
79448
79940
  /**
79449
79941
  * DrawToolCore — Tool orchestration and event routing.
@@ -82478,16 +82970,39 @@ void main() {
82478
82970
  // — Driver methods called by the app-layer composable —
82479
82971
  aiSetPromptTool(tool) { this.drawCore.aiAssistTool.setPromptTool(tool); }
82480
82972
  aiSetPolarity(label) { this.drawCore.aiAssistTool.setPolarity(label); }
82481
- /** Set the AI-layer channel (1-8) the predictions paint into. */
82482
- 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); }
82483
82983
  /** Set the scribble brush radius (px). */
82484
82984
  aiSetScribbleSize(size) { this.drawCore.aiAssistTool.setScribbleSize(size); }
82485
82985
  /** Register the callback invoked when a prompt gesture completes (app → backend). */
82486
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; }
82487
82989
  /** Apply a backend mask result into the scratch volume (overlay repaints next frame). */
82488
82990
  aiApplyMask(result) { this.drawCore.aiAssistTool.applyMask(result); }
82489
82991
  /** Clear the in-progress prompt set (e.g. slice change). */
82490
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(); }
82491
83006
  /** "New region": freeze current regions (they persist) + start a fresh prompt set. */
82492
83007
  aiCommitRegion() { this.drawCore.aiAssistTool.commitRegion(); }
82493
83008
  /** Discard all AI scratch painting since enter (sandbox discard). */
@@ -82498,6 +83013,11 @@ void main() {
82498
83013
  aiGetScratchSlices() {
82499
83014
  return this.drawCore.aiAssistTool.getScratchSlices();
82500
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
+ }
82501
83021
  /**
82502
83022
  * Merge the AI scratch into a target layer as a single undoable group (sandbox
82503
83023
  * merge — best-practice: non-destructive + one Ctrl+Z). The scratch's channel
@@ -82900,8 +83420,15 @@ void main() {
82900
83420
  }
82901
83421
 
82902
83422
  // import * as kiwrious from "copper3d_plugin_heart_k";
82903
- const REVISION = "v3.6.2-beta";
82904
- console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83423
+ // "v3.6.5" is injected at build time by rollup @rollup/plugin-replace, sourced from package.json version
83424
+ const REVISION = "v3.6.5";
83425
+ // Expose on global so the version can be read in a production browser console via window.__COPPER3D_VERSION__
83426
+ if (typeof window !== "undefined") {
83427
+ window.__COPPER3D_VERSION__ = REVISION;
83428
+ }
83429
+ if (typeof console !== "undefined") {
83430
+ console.log(`%cCopper3D Visualisation %c${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
83431
+ }
82905
83432
 
82906
83433
  exports.AI_CHANNEL_HEX_COLORS = AI_CHANNEL_HEX_COLORS;
82907
83434
  exports.AI_MASK_CHANNEL_COLORS = AI_MASK_CHANNEL_COLORS;