mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-26 14:48:52 -03:00
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:
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
147
js/MaskTool.js
147
js/MaskTool.js
@@ -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
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
174
src/MaskTool.ts
174
src/MaskTool.ts
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user