Switch mask preview from chunked to canvas rendering

Replaced chunked rendering approach with direct canvas drawing for mask preview, then applying to main canvas. Added "Mask Opacity" slider.
This commit is contained in:
Dariusz L
2025-08-08 17:13:44 +02:00
parent dd2a81b6f2
commit de83a884c2
8 changed files with 653 additions and 114 deletions

View File

@@ -68,6 +68,10 @@ export class CanvasInteractions {
this.canvas.viewport.zoom = newZoom; this.canvas.viewport.zoom = newZoom;
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }
renderAndSave(shouldSave = false) { renderAndSave(shouldSave = false) {
@@ -161,7 +165,7 @@ export class CanvasInteractions {
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(coords.world, coords.view); this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
this.canvas.render(); // Don't render here - mask tool will handle its own drawing
return; return;
} }
if (this.canvas.shapeTool.isActive) { if (this.canvas.shapeTool.isActive) {
@@ -234,10 +238,7 @@ export class CanvasInteractions {
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'drawingMask': case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view); this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
// Only render if actually drawing, not just moving cursor // Don't render during mask drawing - it's handled by mask tool internally
if (this.canvas.maskTool.isDrawing) {
this.canvas.render();
}
break; break;
case 'panning': case 'panning':
this.panViewport(e); this.panViewport(e);
@@ -274,6 +275,7 @@ export class CanvasInteractions {
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view); this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete
this.canvas.render(); this.canvas.render();
return; return;
} }
@@ -712,6 +714,10 @@ export class CanvasInteractions {
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = { x: e.clientX, y: e.clientY }; this.interaction.panStart = { x: e.clientX, y: e.clientY };
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.render(); this.canvas.render();
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }

View File

@@ -7,8 +7,9 @@ export class CanvasRenderer {
this.lastRenderTime = 0; this.lastRenderTime = 0;
this.renderInterval = 1000 / 60; this.renderInterval = 1000 / 60;
this.isDirty = false; this.isDirty = false;
// Initialize overlay canvas // Initialize overlay canvases
this.initOverlay(); this.initOverlay();
this.initStrokeOverlay();
} }
/** /**
* Helper function to draw text with background at world coordinates * Helper function to draw text with background at world coordinates
@@ -104,10 +105,12 @@ export class CanvasRenderer {
if (maskImage && this.canvas.maskTool.isOverlayVisible) { if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save(); ctx.save();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
// In draw mask mode, use the previewOpacity value from the slider
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5; ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
} }
else { else {
// When not in draw mask mode, show mask at full opacity
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
} }
@@ -160,9 +163,11 @@ export class CanvasRenderer {
this.canvas.canvas.height = this.canvas.offscreenCanvas.height; this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
} }
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Ensure overlay canvas is in DOM and properly sized // Ensure overlay canvases are in DOM and properly sized
this.addOverlayToDOM(); this.addOverlayToDOM();
this.updateOverlaySize(); this.updateOverlaySize();
this.addStrokeOverlayToDOM();
this.updateStrokeOverlaySize();
// Update Batch Preview UI positions // Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => { this.canvas.batchPreviewManagers.forEach((manager) => {
@@ -630,6 +635,121 @@ export class CanvasRenderer {
clearOverlay() { clearOverlay() {
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height); this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
} }
/**
* Initialize a dedicated overlay for real-time mask stroke preview
*/
initStrokeOverlay() {
// Create canvas if not created yet
if (!this.strokeOverlayCanvas) {
this.strokeOverlayCanvas = document.createElement('canvas');
const ctx = this.strokeOverlayCanvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for stroke overlay canvas');
}
this.strokeOverlayCtx = ctx;
}
// Size match main canvas
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
// Add to DOM
this.addStrokeOverlayToDOM();
log.debug('Stroke overlay canvas initialized');
}
/**
* Add stroke overlay canvas to DOM if needed
*/
addStrokeOverlayToDOM() {
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
log.debug('Stroke overlay canvas added to DOM');
}
}
/**
* Ensure stroke overlay size matches main canvas
*/
updateStrokeOverlaySize() {
const w = Math.max(1, this.canvas.canvas.clientWidth);
const h = Math.max(1, this.canvas.canvas.clientHeight);
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
this.strokeOverlayCanvas.width = w;
this.strokeOverlayCanvas.height = h;
log.debug(`Stroke overlay resized to ${w}x${h}`);
}
}
/**
* Clear the stroke overlay
*/
clearMaskStrokeOverlay() {
if (!this.strokeOverlayCtx)
return;
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
}
/**
* Draw a preview stroke segment onto the stroke overlay in screen space
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
*/
drawMaskStrokeSegment(startWorld, endWorld) {
// Ensure overlay is present and sized
this.updateStrokeOverlaySize();
const zoom = this.canvas.viewport.zoom;
const toScreen = (p) => ({
x: (p.x - this.canvas.viewport.x) * zoom,
y: (p.y - this.canvas.viewport.y) * zoom
});
const startScreen = toScreen(startWorld);
const endScreen = toScreen(endWorld);
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
const hardness = this.canvas.maskTool.brushHardness;
const strength = this.canvas.maskTool.brushStrength;
// If strength is 0, don't draw anything
if (strength <= 0) {
return;
}
this.strokeOverlayCtx.save();
// Draw line segment exactly as MaskTool does
this.strokeOverlayCtx.beginPath();
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
// Match the gradient setup from MaskTool's drawLineOnChunk
if (hardness === 1) {
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
}
else {
const innerRadius = brushRadius * hardness;
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.strokeOverlayCtx.strokeStyle = gradient;
}
// Match line properties from MaskTool
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
this.strokeOverlayCtx.lineCap = 'round';
this.strokeOverlayCtx.lineJoin = 'round';
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
this.strokeOverlayCtx.stroke();
this.strokeOverlayCtx.restore();
}
/**
* Redraws the entire stroke overlay from world coordinates
* Used when viewport changes during drawing to maintain visual consistency
*/
redrawMaskStrokeOverlay(strokePoints) {
if (strokePoints.length < 2)
return;
// Clear the overlay first
this.clearMaskStrokeOverlay();
// Redraw all segments with current viewport
for (let i = 1; i < strokePoints.length; i++) {
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
}
}
/** /**
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness * Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
* @param worldPoint World coordinates of cursor * @param worldPoint World coordinates of cursor
@@ -652,46 +772,49 @@ export class CanvasRenderer {
const brushHardness = this.canvas.maskTool.brushHardness; const brushHardness = this.canvas.maskTool.brushHardness;
// Save context state // Save context state
this.canvas.overlayCtx.save(); this.canvas.overlayCtx.save();
// 1. Draw inner fill to visualize STRENGTH (opacity) // If strength is 0, just draw outline
// Higher strength = more opaque fill if (brushStrength > 0) {
this.canvas.overlayCtx.beginPath(); // Draw inner fill to visualize brush effect - matches actual brush rendering
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${brushStrength * 0.2})`; // Max 20% opacity for visibility // Preview alpha - subtle to not obscure content
this.canvas.overlayCtx.fill(); const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
// 2. Draw gradient edge to visualize HARDNESS if (brushHardness === 1) {
// Hard brush = sharp edge, Soft brush = gradient edge // Hard brush - uniform fill within radius
if (brushHardness < 1) { gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
// Create radial gradient for soft brushes gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
const innerRadius = brushRadius * brushHardness; }
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, innerRadius, screenX, screenY, brushRadius); else {
// Inner part is solid // Soft brush - gradient fade matching actual brush
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + brushStrength * 0.3})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
// Outer part fades based on hardness if (brushHardness > 0) {
gradient.addColorStop(1, `rgba(255, 255, 255, ${0.05})`); gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
}
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
}
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = gradient; this.canvas.overlayCtx.fillStyle = gradient;
this.canvas.overlayCtx.fill(); this.canvas.overlayCtx.fill();
} }
// 3. Draw outer circle (SIZE indicator) // Draw outer circle (SIZE indicator)
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
// Make the stroke opacity also reflect strength slightly // Stroke opacity based on strength (dimmer when strength is 0)
const strokeOpacity = 0.4 + brushStrength * 0.4; // Range from 0.4 to 0.8 const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`; this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.lineWidth = 1.5; this.canvas.overlayCtx.lineWidth = 1.5;
// Use solid line for hard brushes, dashed for soft brushes // Visual feedback for hardness
if (brushHardness > 0.8) { if (brushHardness > 0.8) {
// Hard brush - solid line // Hard brush - solid line
this.canvas.overlayCtx.setLineDash([]); this.canvas.overlayCtx.setLineDash([]);
} }
else { else {
// Soft brush - dashed line, dash length based on hardness // Soft brush - dashed line
const dashLength = 2 + (1 - brushHardness) * 4; // Longer dashes for softer brushes const dashLength = 2 + (1 - brushHardness) * 4;
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]); this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
} }
this.canvas.overlayCtx.stroke(); this.canvas.overlayCtx.stroke();
// 4. Optional: Draw center dot for very precise brushes // Center dot for small brushes
if (brushRadius < 5) { if (brushRadius < 5) {
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);

View File

@@ -554,6 +554,25 @@ async function createCanvasWidget(node, widget, app) {
setTimeout(() => canvas.render(), 0); setTimeout(() => canvas.render(), 0);
} }
}), }),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }),
$el("input", {
id: "preview-opacity-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e) => {
const value = e.target.value;
canvas.maskTool.setPreviewOpacity(parseFloat(value));
const valueEl = document.getElementById('preview-opacity-value');
if (valueEl)
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", { id: "preview-opacity-value" }, ["50%"])
]),
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
$el("label", { for: "brush-size-slider", textContent: "Size:" }), $el("label", { for: "brush-size-slider", textContent: "Size:" }),
$el("input", { $el("input", {

View File

@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
const log = createModuleLogger('Mask_tool'); const log = createModuleLogger('Mask_tool');
export class MaskTool { export class MaskTool {
constructor(canvasInstance, callbacks = {}) { constructor(canvasInstance, callbacks = {}) {
// Track strokes during drawing for efficient overlay updates
this.currentStrokePoints = [];
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
this.canvasInstance = canvasInstance; this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
// Initialize stroke tracking for overlay drawing
this.currentStrokePoints = [];
// Initialize chunked mask system // Initialize chunked mask system
this.maskChunks = new Map(); this.maskChunks = new Map();
this.chunkSize = 512; this.chunkSize = 512;
@@ -30,6 +34,7 @@ export class MaskTool {
this.brushSize = 20; this.brushSize = 20;
this._brushStrength = 0.5; this._brushStrength = 0.5;
this._brushHardness = 0.5; this._brushHardness = 0.5;
this._previewOpacity = 0.5; // Default 50% opacity for preview
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true }); const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
@@ -86,9 +91,21 @@ export class MaskTool {
get brushHardness() { get brushHardness() {
return this._brushHardness; return this._brushHardness;
} }
get previewOpacity() {
return this._previewOpacity;
}
setBrushHardness(hardness) { setBrushHardness(hardness) {
this._brushHardness = Math.max(0, Math.min(1, hardness)); this._brushHardness = Math.max(0, Math.min(1, hardness));
} }
setPreviewOpacity(opacity) {
this._previewOpacity = Math.max(0, Math.min(1, opacity));
// Update the stroke overlay canvas opacity when preview opacity changes
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
}
// Trigger canvas render to update mask display opacity
this.canvasInstance.render();
}
initMaskCanvas() { initMaskCanvas() {
// Initialize chunked system // Initialize chunked system
this.chunkSize = 512; this.chunkSize = 512;
@@ -685,9 +702,10 @@ export class MaskTool {
return; return;
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance // Initialize stroke tracking for live preview
this.updateActiveChunksForDrawing(worldCoords); this.currentStrokePoints = [worldCoords];
this.draw(worldCoords); // Clear any previous stroke overlay
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.clearPreview(); this.clearPreview();
} }
handleMouseMove(worldCoords, viewCoords) { handleMouseMove(worldCoords, viewCoords) {
@@ -696,16 +714,69 @@ export class MaskTool {
} }
if (!this.isActive || !this.isDrawing) if (!this.isActive || !this.isDrawing)
return; return;
// Dynamically update active chunks as user moves while drawing // Add point to stroke tracking
this.updateActiveChunksForDrawing(worldCoords); this.currentStrokePoints.push(worldCoords);
this.draw(worldCoords); // Draw interpolated segments for smooth strokes without gaps
if (this.lastPosition) {
// Calculate distance between last and current position
const dx = worldCoords.x - this.lastPosition.x;
const dy = worldCoords.y - this.lastPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// If distance is small, just draw a single segment
if (distance < this.brushSize / 4) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
}
else {
// Interpolate points for smooth drawing without gaps
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
// Draw all interpolated segments
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
}
}
}
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
/**
* Interpolates points between two positions to create smooth strokes without gaps
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
*/
interpolatePoints(start, end, distance) {
const points = [];
// Calculate number of interpolated points based on brush size
// More points = smoother line
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
const numSteps = Math.ceil(distance / stepSize);
// Always include start point
points.push(start);
// Interpolate intermediate points
for (let i = 1; i < numSteps; i++) {
const t = i / numSteps;
points.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
// Always include end point
points.push(end);
return points;
}
/**
* Called when viewport changes during drawing to update stroke overlay
* This ensures the stroke preview scales correctly with zoom changes
*/
handleViewportChange() {
if (this.isDrawing && this.currentStrokePoints.length > 1) {
// Redraw the entire stroke overlay with new viewport settings
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
}
}
handleMouseLeave() { handleMouseLeave() {
this.previewVisible = false; this.previewVisible = false;
this.clearPreview(); this.clearPreview();
// Clear overlay canvas when mouse leaves // Clear overlay canvases when mouse leaves
this.canvasInstance.canvasRenderer.clearOverlay(); this.canvasInstance.canvasRenderer.clearOverlay();
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
} }
handleMouseEnter() { handleMouseEnter() {
this.previewVisible = true; this.previewVisible = true;
@@ -715,10 +786,15 @@ export class MaskTool {
return; return;
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
// Commit the stroke from overlay to actual mask chunks
this.commitStrokeToChunks();
// Clear stroke overlay and reset state
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.currentStrokePoints = [];
this.lastPosition = null; this.lastPosition = null;
this.currentDrawingChunk = null; this.currentDrawingChunk = null;
// After drawing is complete, update active canvas to show all chunks // After drawing is complete, update active canvas to show all chunks
this.updateActiveMaskCanvas(true); // forceShowAll = true this.updateActiveMaskCanvas(true); // Force full update
this.completeMaskOperation(); this.completeMaskOperation();
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
@@ -733,6 +809,38 @@ export class MaskTool {
// This prevents unnecessary recomposition during drawing // This prevents unnecessary recomposition during drawing
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
} }
/**
* Commits the current stroke from overlay to actual mask chunks
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
*/
commitStrokeToChunks() {
if (this.currentStrokePoints.length < 2) {
return; // Need at least 2 points for a stroke
}
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
// Replay the entire stroke path with interpolation for smooth, accurate lines
for (let i = 1; i < this.currentStrokePoints.length; i++) {
const startPoint = this.currentStrokePoints[i - 1];
const endPoint = this.currentStrokePoints[i];
// Calculate distance between points
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.brushSize / 4) {
// Small distance - draw single segment
this.drawOnChunks(startPoint, endPoint);
}
else {
// Large distance - interpolate for smooth line without gaps
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
// Draw all interpolated segments
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
}
}
}
log.debug("Stroke committed to chunks successfully with interpolation");
}
/** /**
* Draws a line between two world coordinates on the appropriate chunks * Draws a line between two world coordinates on the appropriate chunks
*/ */
@@ -814,28 +922,17 @@ export class MaskTool {
return true; // For now, always draw - more precise intersection can be added later return true; // For now, always draw - more precise intersection can be added later
} }
/** /**
* Updates active canvas when drawing affects chunks with throttling to prevent lag * Updates active canvas when drawing affects chunks
* During drawing, only updates the affected active chunks for performance * Since we now use overlay during drawing, this is only called after drawing is complete
*/ */
updateActiveCanvasIfNeeded(startWorld, endWorld) { updateActiveCanvasIfNeeded(startWorld, endWorld) {
// Calculate which chunks were affected by this drawing operation // This method is now simplified - we only update after drawing is complete
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; // The overlay handles all live preview, so we don't need complex chunk activation
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; if (!this.isDrawing) {
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// During drawing, only update affected chunks that are active for performance
if (this.isDrawing) {
// Use throttled partial update for active chunks only
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
}
else {
// Not drawing - do full update to show all chunks // Not drawing - do full update to show all chunks
this.updateActiveMaskCanvas(true); this.updateActiveMaskCanvas(true);
} }
// During drawing, we don't update chunks at all - overlay handles preview
} }
/** /**
* Schedules a throttled update of the active mask canvas to prevent excessive redraws * Schedules a throttled update of the active mask canvas to prevent excessive redraws

View File

@@ -131,6 +131,11 @@ export class CanvasInteractions {
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }
@@ -243,7 +248,7 @@ export class CanvasInteractions {
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(coords.world, coords.view); this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
this.canvas.render(); // Don't render here - mask tool will handle its own drawing
return; return;
} }
@@ -327,10 +332,7 @@ export class CanvasInteractions {
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'drawingMask': case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view); this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
// Only render if actually drawing, not just moving cursor // Don't render during mask drawing - it's handled by mask tool internally
if (this.canvas.maskTool.isDrawing) {
this.canvas.render();
}
break; break;
case 'panning': case 'panning':
this.panViewport(e); this.panViewport(e);
@@ -370,6 +372,7 @@ export class CanvasInteractions {
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view); this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete
this.canvas.render(); this.canvas.render();
return; return;
} }
@@ -840,6 +843,12 @@ export class CanvasInteractions {
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = {x: e.clientX, y: e.clientY};
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.render(); this.canvas.render();
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }

View File

@@ -8,6 +8,9 @@ export class CanvasRenderer {
lastRenderTime: any; lastRenderTime: any;
renderAnimationFrame: any; renderAnimationFrame: any;
renderInterval: any; renderInterval: any;
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
strokeOverlayCanvas!: HTMLCanvasElement;
strokeOverlayCtx!: CanvasRenderingContext2D;
constructor(canvas: any) { constructor(canvas: any) {
this.canvas = canvas; this.canvas = canvas;
this.renderAnimationFrame = null; this.renderAnimationFrame = null;
@@ -15,8 +18,9 @@ export class CanvasRenderer {
this.renderInterval = 1000 / 60; this.renderInterval = 1000 / 60;
this.isDirty = false; this.isDirty = false;
// Initialize overlay canvas // Initialize overlay canvases
this.initOverlay(); this.initOverlay();
this.initStrokeOverlay();
} }
/** /**
@@ -144,9 +148,11 @@ export class CanvasRenderer {
ctx.save(); ctx.save();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
// In draw mask mode, use the previewOpacity value from the slider
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5; ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
} else { } else {
// When not in draw mask mode, show mask at full opacity
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
} }
@@ -208,9 +214,11 @@ export class CanvasRenderer {
} }
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Ensure overlay canvas is in DOM and properly sized // Ensure overlay canvases are in DOM and properly sized
this.addOverlayToDOM(); this.addOverlayToDOM();
this.updateOverlaySize(); this.updateOverlaySize();
this.addStrokeOverlayToDOM();
this.updateStrokeOverlaySize();
// Update Batch Preview UI positions // Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
@@ -769,6 +777,141 @@ export class CanvasRenderer {
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height); this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
} }
/**
* Initialize a dedicated overlay for real-time mask stroke preview
*/
initStrokeOverlay(): void {
// Create canvas if not created yet
if (!this.strokeOverlayCanvas) {
this.strokeOverlayCanvas = document.createElement('canvas');
const ctx = this.strokeOverlayCanvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for stroke overlay canvas');
}
this.strokeOverlayCtx = ctx;
}
// Size match main canvas
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
// Add to DOM
this.addStrokeOverlayToDOM();
log.debug('Stroke overlay canvas initialized');
}
/**
* Add stroke overlay canvas to DOM if needed
*/
addStrokeOverlayToDOM(): void {
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
log.debug('Stroke overlay canvas added to DOM');
}
}
/**
* Ensure stroke overlay size matches main canvas
*/
updateStrokeOverlaySize(): void {
const w = Math.max(1, this.canvas.canvas.clientWidth);
const h = Math.max(1, this.canvas.canvas.clientHeight);
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
this.strokeOverlayCanvas.width = w;
this.strokeOverlayCanvas.height = h;
log.debug(`Stroke overlay resized to ${w}x${h}`);
}
}
/**
* Clear the stroke overlay
*/
clearMaskStrokeOverlay(): void {
if (!this.strokeOverlayCtx) return;
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
}
/**
* Draw a preview stroke segment onto the stroke overlay in screen space
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
*/
drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void {
// Ensure overlay is present and sized
this.updateStrokeOverlaySize();
const zoom = this.canvas.viewport.zoom;
const toScreen = (p: { x: number; y: number }) => ({
x: (p.x - this.canvas.viewport.x) * zoom,
y: (p.y - this.canvas.viewport.y) * zoom
});
const startScreen = toScreen(startWorld);
const endScreen = toScreen(endWorld);
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
const hardness = this.canvas.maskTool.brushHardness;
const strength = this.canvas.maskTool.brushStrength;
// If strength is 0, don't draw anything
if (strength <= 0) {
return;
}
this.strokeOverlayCtx.save();
// Draw line segment exactly as MaskTool does
this.strokeOverlayCtx.beginPath();
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
// Match the gradient setup from MaskTool's drawLineOnChunk
if (hardness === 1) {
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
} else {
const innerRadius = brushRadius * hardness;
const gradient = this.strokeOverlayCtx.createRadialGradient(
endScreen.x, endScreen.y, innerRadius,
endScreen.x, endScreen.y, brushRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.strokeOverlayCtx.strokeStyle = gradient;
}
// Match line properties from MaskTool
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
this.strokeOverlayCtx.lineCap = 'round';
this.strokeOverlayCtx.lineJoin = 'round';
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
this.strokeOverlayCtx.stroke();
this.strokeOverlayCtx.restore();
}
/**
* Redraws the entire stroke overlay from world coordinates
* Used when viewport changes during drawing to maintain visual consistency
*/
redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void {
if (strokePoints.length < 2) return;
// Clear the overlay first
this.clearMaskStrokeOverlay();
// Redraw all segments with current viewport
for (let i = 1; i < strokePoints.length; i++) {
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
}
}
/** /**
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness * Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
* @param worldPoint World coordinates of cursor * @param worldPoint World coordinates of cursor
@@ -797,27 +940,29 @@ export class CanvasRenderer {
// Save context state // Save context state
this.canvas.overlayCtx.save(); this.canvas.overlayCtx.save();
// 1. Draw inner fill to visualize STRENGTH (opacity) // If strength is 0, just draw outline
// Higher strength = more opaque fill if (brushStrength > 0) {
this.canvas.overlayCtx.beginPath(); // Draw inner fill to visualize brush effect - matches actual brush rendering
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${brushStrength * 0.2})`; // Max 20% opacity for visibility
this.canvas.overlayCtx.fill();
// 2. Draw gradient edge to visualize HARDNESS
// Hard brush = sharp edge, Soft brush = gradient edge
if (brushHardness < 1) {
// Create radial gradient for soft brushes
const innerRadius = brushRadius * brushHardness;
const gradient = this.canvas.overlayCtx.createRadialGradient( const gradient = this.canvas.overlayCtx.createRadialGradient(
screenX, screenY, innerRadius, screenX, screenY, 0,
screenX, screenY, brushRadius screenX, screenY, brushRadius
); );
// Inner part is solid // Preview alpha - subtle to not obscure content
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + brushStrength * 0.3})`); const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
// Outer part fades based on hardness
gradient.addColorStop(1, `rgba(255, 255, 255, ${0.05})`); if (brushHardness === 1) {
// Hard brush - uniform fill within radius
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
} else {
// Soft brush - gradient fade matching actual brush
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
if (brushHardness > 0) {
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
}
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
}
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
@@ -825,28 +970,28 @@ export class CanvasRenderer {
this.canvas.overlayCtx.fill(); this.canvas.overlayCtx.fill();
} }
// 3. Draw outer circle (SIZE indicator) // Draw outer circle (SIZE indicator)
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
// Make the stroke opacity also reflect strength slightly // Stroke opacity based on strength (dimmer when strength is 0)
const strokeOpacity = 0.4 + brushStrength * 0.4; // Range from 0.4 to 0.8 const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`; this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.lineWidth = 1.5; this.canvas.overlayCtx.lineWidth = 1.5;
// Use solid line for hard brushes, dashed for soft brushes // Visual feedback for hardness
if (brushHardness > 0.8) { if (brushHardness > 0.8) {
// Hard brush - solid line // Hard brush - solid line
this.canvas.overlayCtx.setLineDash([]); this.canvas.overlayCtx.setLineDash([]);
} else { } else {
// Soft brush - dashed line, dash length based on hardness // Soft brush - dashed line
const dashLength = 2 + (1 - brushHardness) * 4; // Longer dashes for softer brushes const dashLength = 2 + (1 - brushHardness) * 4;
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]); this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
} }
this.canvas.overlayCtx.stroke(); this.canvas.overlayCtx.stroke();
// 4. Optional: Draw center dot for very precise brushes // Center dot for small brushes
if (brushRadius < 5) { if (brushRadius < 5) {
this.canvas.overlayCtx.beginPath(); this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI); this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);

View File

@@ -640,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", {
setTimeout(() => canvas.render(), 0); setTimeout(() => canvas.render(), 0);
} }
}), }),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
$el("input", {
id: "preview-opacity-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setPreviewOpacity(parseFloat(value));
const valueEl = document.getElementById('preview-opacity-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.slider-value", {id: "preview-opacity-value"}, ["50%"])
]),
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-size-slider", textContent: "Size:"}), $el("label", {for: "brush-size-slider", textContent: "Size:"}),
$el("input", { $el("input", {

View File

@@ -24,6 +24,7 @@ export class MaskTool {
private _brushHardness: number; private _brushHardness: number;
public brushSize: number; public brushSize: number;
private _brushStrength: number; private _brushStrength: number;
private _previewOpacity: number;
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }; private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
public isActive: boolean; public isActive: boolean;
public isDrawing: boolean; public isDrawing: boolean;
@@ -31,6 +32,9 @@ export class MaskTool {
private lastPosition: Point | null; private lastPosition: Point | null;
private mainCanvas: HTMLCanvasElement; private mainCanvas: HTMLCanvasElement;
// Track strokes during drawing for efficient overlay updates
private currentStrokePoints: Point[] = [];
// Chunked mask system // Chunked mask system
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates) private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
private chunkSize: number; private chunkSize: number;
@@ -72,6 +76,9 @@ export class MaskTool {
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
// Initialize stroke tracking for overlay drawing
this.currentStrokePoints = [];
// Initialize chunked mask system // Initialize chunked mask system
this.maskChunks = new Map(); this.maskChunks = new Map();
this.chunkSize = 512; this.chunkSize = 512;
@@ -98,6 +105,7 @@ export class MaskTool {
this.brushSize = 20; this.brushSize = 20;
this._brushStrength = 0.5; this._brushStrength = 0.5;
this._brushHardness = 0.5; this._brushHardness = 0.5;
this._previewOpacity = 0.5; // Default 50% opacity for preview
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
@@ -165,10 +173,24 @@ export class MaskTool {
return this._brushHardness; return this._brushHardness;
} }
get previewOpacity(): number {
return this._previewOpacity;
}
setBrushHardness(hardness: number): void { setBrushHardness(hardness: number): void {
this._brushHardness = Math.max(0, Math.min(1, hardness)); this._brushHardness = Math.max(0, Math.min(1, hardness));
} }
setPreviewOpacity(opacity: number): void {
this._previewOpacity = Math.max(0, Math.min(1, opacity));
// Update the stroke overlay canvas opacity when preview opacity changes
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
}
// Trigger canvas render to update mask display opacity
this.canvasInstance.render();
}
initMaskCanvas(): void { initMaskCanvas(): void {
// Initialize chunked system // Initialize chunked system
this.chunkSize = 512; this.chunkSize = 512;
@@ -884,10 +906,12 @@ export class MaskTool {
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
// Activate chunks around the drawing position for performance // Initialize stroke tracking for live preview
this.updateActiveChunksForDrawing(worldCoords); this.currentStrokePoints = [worldCoords];
// Clear any previous stroke overlay
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.draw(worldCoords);
this.clearPreview(); this.clearPreview();
} }
@@ -897,18 +921,83 @@ export class MaskTool {
} }
if (!this.isActive || !this.isDrawing) return; if (!this.isActive || !this.isDrawing) return;
// Dynamically update active chunks as user moves while drawing // Add point to stroke tracking
this.updateActiveChunksForDrawing(worldCoords); this.currentStrokePoints.push(worldCoords);
// Draw interpolated segments for smooth strokes without gaps
if (this.lastPosition) {
// Calculate distance between last and current position
const dx = worldCoords.x - this.lastPosition.x;
const dy = worldCoords.y - this.lastPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// If distance is small, just draw a single segment
if (distance < this.brushSize / 4) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
} else {
// Interpolate points for smooth drawing without gaps
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
// Draw all interpolated segments
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(
interpolatedPoints[i],
interpolatedPoints[i + 1]
);
}
}
}
this.draw(worldCoords);
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
/**
* Interpolates points between two positions to create smooth strokes without gaps
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
*/
private interpolatePoints(start: Point, end: Point, distance: number): Point[] {
const points: Point[] = [];
// Calculate number of interpolated points based on brush size
// More points = smoother line
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
const numSteps = Math.ceil(distance / stepSize);
// Always include start point
points.push(start);
// Interpolate intermediate points
for (let i = 1; i < numSteps; i++) {
const t = i / numSteps;
points.push({
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t
});
}
// Always include end point
points.push(end);
return points;
}
/**
* Called when viewport changes during drawing to update stroke overlay
* This ensures the stroke preview scales correctly with zoom changes
*/
handleViewportChange(): void {
if (this.isDrawing && this.currentStrokePoints.length > 1) {
// Redraw the entire stroke overlay with new viewport settings
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
}
}
handleMouseLeave(): void { handleMouseLeave(): void {
this.previewVisible = false; this.previewVisible = false;
this.clearPreview(); this.clearPreview();
// Clear overlay canvas when mouse leaves // Clear overlay canvases when mouse leaves
this.canvasInstance.canvasRenderer.clearOverlay(); this.canvasInstance.canvasRenderer.clearOverlay();
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
} }
handleMouseEnter(): void { handleMouseEnter(): void {
@@ -919,11 +1008,18 @@ export class MaskTool {
if (!this.isActive) return; if (!this.isActive) return;
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
// Commit the stroke from overlay to actual mask chunks
this.commitStrokeToChunks();
// Clear stroke overlay and reset state
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
this.currentStrokePoints = [];
this.lastPosition = null; this.lastPosition = null;
this.currentDrawingChunk = null; this.currentDrawingChunk = null;
// After drawing is complete, update active canvas to show all chunks // After drawing is complete, update active canvas to show all chunks
this.updateActiveMaskCanvas(true); // forceShowAll = true this.updateActiveMaskCanvas(true); // Force full update
this.completeMaskOperation(); this.completeMaskOperation();
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
@@ -943,6 +1039,44 @@ export class MaskTool {
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords); this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
} }
/**
* Commits the current stroke from overlay to actual mask chunks
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
*/
private commitStrokeToChunks(): void {
if (this.currentStrokePoints.length < 2) {
return; // Need at least 2 points for a stroke
}
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
// Replay the entire stroke path with interpolation for smooth, accurate lines
for (let i = 1; i < this.currentStrokePoints.length; i++) {
const startPoint = this.currentStrokePoints[i - 1];
const endPoint = this.currentStrokePoints[i];
// Calculate distance between points
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.brushSize / 4) {
// Small distance - draw single segment
this.drawOnChunks(startPoint, endPoint);
} else {
// Large distance - interpolate for smooth line without gaps
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
// Draw all interpolated segments
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
}
}
}
log.debug("Stroke committed to chunks successfully with interpolation");
}
/** /**
* Draws a line between two world coordinates on the appropriate chunks * Draws a line between two world coordinates on the appropriate chunks
*/ */
@@ -1040,29 +1174,17 @@ export class MaskTool {
} }
/** /**
* Updates active canvas when drawing affects chunks with throttling to prevent lag * Updates active canvas when drawing affects chunks
* During drawing, only updates the affected active chunks for performance * Since we now use overlay during drawing, this is only called after drawing is complete
*/ */
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void { private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
// Calculate which chunks were affected by this drawing operation // This method is now simplified - we only update after drawing is complete
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize; // The overlay handles all live preview, so we don't need complex chunk activation
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize; if (!this.isDrawing) {
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
// During drawing, only update affected chunks that are active for performance
if (this.isDrawing) {
// Use throttled partial update for active chunks only
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
} else {
// Not drawing - do full update to show all chunks // Not drawing - do full update to show all chunks
this.updateActiveMaskCanvas(true); this.updateActiveMaskCanvas(true);
} }
// During drawing, we don't update chunks at all - overlay handles preview
} }
/** /**