/** * SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume. * * Unlike SphereTool (which uses its own isolated sphereMaskVolume), * this tool writes/erases directly in the active layer's shared MaskVolume — * the same volume used by pencil/brush/eraser tools. * * Two operation modes handled by one class: * - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label. * - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel). * * Interaction: * 1. Pointer-down records sphere center, switches wheel to radius mode. * 2. Scroll wheel adjusts sphereBrushRadius [1, 50]. * 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas. */ import { BaseTool } from "./BaseTool"; import type { ToolContext } from "./BaseTool"; import type { SphereBrushHostDeps } from "./ToolHost"; export declare class SphereBrushTool extends BaseTool { private callbacks; /** * Recorded sphere center in display (zoomed) coordinates. * Used for both preview redraw and voxel calculation — the voxel conversion * uses `changedWidth/Height` as the display scale, so no mm detour is needed. */ private centerX; private centerY; private centerSlice; /** Whether a sphere placement is in progress */ private active; /** Current operation mode for the active placement */ private mode; /** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */ private dragBeforeSnapshots; /** Whether a drag has actually moved (vs. simple click-release) */ private dragMoved; /** * Latest cursor position in display (zoomed) coords. Used by the wheel * handler to redraw the preview at the cursor, not the original mousedown * point, during mid-drag radius changes. */ private lastDisplayX; private lastDisplayY; private lastSliceIndex; /** * All sphere centers written during the current stroke. The wheel handler * retroactively re-renders the entire stroke at the new radius by restoring * `dragBeforeSnapshots` and replaying every entry here. */ private strokeCenters; constructor(ctx: ToolContext, callbacks: SphereBrushHostDeps); setCallbacks(callbacks: SphereBrushHostDeps): void; /** * Convert display (zoomed) coordinates to 3D voxel coordinates. * * Uses `changedWidth/Height` as the display scale directly (instead of * going through mm via `/sizeFactor`). This keeps the write pixel-exact * with the preview circle, which is drawn on a `changedWidth x changedHeight` * sized sphere canvas — avoiding floating-point drift at high zoom levels. */ private canvasToVoxelCenter; /** * Convert mm radius to per-axis voxel radii. * * For the two axes visible in the current view, scale via * `changedWidth/Height` so the rendered ellipsoid cross-section matches * the preview pixel radius exactly. The third (slice-direction) axis is * not visible, so it uses the plain mm-based ratio. */ private getVoxelRadii; /** * Compute clamped bounding box for an ellipsoid in voxel space. */ private getBoundingBox; /** * Write a 3D sphere of the active channel label into the layer's MaskVolume. */ private write3DSphereToBrush; /** * Erase a 3D sphere from the layer's MaskVolume. * Only clears voxels whose current value equals the active channel label. */ private erase3DSphereFromVolume; /** * Capture slice snapshots for all Z-slices in the bounding box. * Used before and after the sphere operation to build MaskDelta groups. * * We always capture along the Z axis since the 3D sphere spans Z slices. */ private captureSliceSnapshots; /** * Build MaskDelta group from before/after snapshots. */ private buildUndoGroup; /** * Draw a preview circle on the sphere canvas during sphere placement. */ drawPreview(mouseX: number, mouseY: number, radius: number, isEraser: boolean): void; /** * Clear the sphere preview canvas. */ clearPreview(): void; /** * Get the hex color for the active channel from the layer's MaskVolume. */ private getActiveChannelColor; /** * Returns a wheel event handler for adjusting sphereBrushRadius. * Used in both sphereBrush and sphereEraser modes. */ configSphereBrushWheel(): (e: WheelEvent) => void; /** * Retroactively re-render the current stroke at the current radius. * * Step 1: walk every recorded stroke center and expand * `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices * newly included here are still pristine because the original stroke * never touched them. * Step 2: restore every snapshotted slice from its pristine backup. * Step 3: replay every stroke center at the new radius. * * Step 3 produces the same final state as if the user had used the new * radius from the start. Undo still captures the full stroke as one group * at pointerup. */ private replayStrokeWithCurrentRadius; /** * Handle pointer-down in sphereBrush mode (3D Slicer-style). * * Captures pristine snapshots at the mousedown location but does NOT * write any voxels yet — the user may wheel-adjust the radius before * dragging. The first sphere is written either by the first pointermove * or by pointerup (single-click fallback). */ onSphereBrushClick(e: MouseEvent): void; /** * Handle pointer-move in sphereBrush mode — drag to continuously paint. * * Mirrors sphereEraser drag: writes a new sphere at each move position, * extends the cumulative before-snapshot to cover any newly-touched Z * slices, and repaints the preview. Never notifies the backend; pointerup * does that once for the whole stroke. */ onSphereBrushMove(e: MouseEvent): void; /** * Handle pointer-up in sphereBrush mode — finalize stroke + push undo group * + single batched backend notify for all Z slices touched during the drag. */ onSphereBrushPointerUp(): void; /** * Handle pointer-down in sphereEraser mode. * Initializes drag tracking for cumulative undo. */ onSphereEraserClick(e: MouseEvent): void; /** * Handle pointer-move in sphereEraser mode — drag to continuously erase. */ onSphereEraserMove(e: MouseEvent): void; /** * Handle pointer-up in sphereEraser mode — finalize erase + push undo group. * Works for both click-release and drag-release. */ onSphereEraserPointerUp(): void; /** * Clear per-stroke state after pointerup or cancel. Kept as a helper so * brush/eraser pointerup and cancelActivePlacement all reset the same set * of fields — if a new drag-state field is added, only one place to update. */ private finalizeStrokeState; /** * Expand cumulative "before" snapshots to cover z range [minZ, maxZ]. * Only captures slices not yet in the map (preserves original state). */ private expandDragBeforeSnapshots; /** Get minimum Z index from drag snapshots */ private dragMinZ; /** Get maximum Z index from drag snapshots */ private dragMaxZ; /** * After writing/erasing the sphere in the volume, re-render the current * slice's layer canvas and composite all layers to master. * * @param changedDeltas - If provided, fire onMaskChanged for ALL changed * slices (not just the current view slice) so the backend receives the * full 3D sphere data for NII/GLTF export. * @param notifyBackend - When false, only refresh the local canvas and skip * the onMaskChanged callback. Used during drag to avoid per-frame HTTP; * pointerup then calls with `true` to send a single batched update. */ private refreshDisplay; /** Whether a sphere placement is currently in progress */ get isActive(): boolean; /** * Defensively cancel any in-progress sphere placement and clear the preview. * * Called when crosshair mode is entered so any lingering sphere preview * (e.g. red dashed eraser outline) is wiped immediately. In normal flow * `leftButtonDown === true` blocks crosshair toggle, so this shouldn't * fire mid-stroke — but if it ever does, we drop the drag snapshot * rather than committing a partial undo entry. */ cancelActivePlacement(): void; }