Files
2026-01-18 00:13:02 +09:00

204 lines
6.3 KiB
JavaScript

/**
* Hit Testing Module
*
* Provides efficient hit-testing for stroke detection.
* Used by the eraser tool to determine which strokes to remove.
*
* ## Hit-Testing Strategy: Whole Stroke Removal
*
* For v1, we use whole-stroke removal (simpler, more performant):
* - When eraser touches any part of a stroke, the entire stroke is removed
* - This avoids complex stroke splitting and segment management
* - Performance: O(n * m) where n = strokes, m = points per stroke
*
* ## Optimization Techniques
*
* 1. Bounding Box Pre-filter: Skip strokes whose bounding box doesn't
* intersect with the eraser area
* 2. Early Exit: Stop checking a stroke once a hit is found
* 3. Distance Squared: Avoid sqrt() calls by comparing squared distances
*
* ## Future Improvements (v2)
*
* - Spatial indexing (quadtree) for large numbers of strokes
* - Segment-level erasing (split strokes at intersection points)
* - Stroke thickness consideration in hit detection
*/
/**
* Calculates the squared distance from a point to a line segment.
* Using squared distance avoids expensive sqrt() calls.
*
* @param {number} px - Point X
* @param {number} py - Point Y
* @param {number} x1 - Segment start X
* @param {number} y1 - Segment start Y
* @param {number} x2 - Segment end X
* @param {number} y2 - Segment end Y
* @returns {number} Squared distance from point to segment
*/
export function pointToSegmentDistanceSquared(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
// Segment is a point
const dpx = px - x1;
const dpy = py - y1;
return dpx * dpx + dpy * dpy;
}
// Project point onto line, clamped to segment
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
// Find closest point on segment
const closestX = x1 + t * dx;
const closestY = y1 + t * dy;
// Return squared distance
const distX = px - closestX;
const distY = py - closestY;
return distX * distX + distY * distY;
}
/**
* Calculates the bounding box of a stroke.
*
* @param {Object} stroke - Stroke object with points array
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
*/
export function getStrokeBoundingBox(stroke) {
if (!stroke.points || stroke.points.length === 0) {
return null;
}
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
for (const point of stroke.points) {
if (point.x < minX) minX = point.x;
if (point.y < minY) minY = point.y;
if (point.x > maxX) maxX = point.x;
if (point.y > maxY) maxY = point.y;
}
return { minX, minY, maxX, maxY };
}
/**
* Checks if a circle (eraser) intersects with a bounding box.
* Used as a quick pre-filter before detailed hit testing.
*
* @param {number} cx - Circle center X
* @param {number} cy - Circle center Y
* @param {number} radius - Circle radius
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} box
* @returns {boolean}
*/
export function circleIntersectsBox(cx, cy, radius, box) {
// Find the closest point on the box to the circle center
const closestX = Math.max(box.minX, Math.min(cx, box.maxX));
const closestY = Math.max(box.minY, Math.min(cy, box.maxY));
// Check if that point is within the circle
const dx = cx - closestX;
const dy = cy - closestY;
return (dx * dx + dy * dy) <= (radius * radius);
}
/**
* Tests if an eraser circle hits a stroke.
* Uses bounding box pre-filter and segment distance checking.
*
* @param {number} eraserX - Eraser center X (canvas coordinates)
* @param {number} eraserY - Eraser center Y (canvas coordinates)
* @param {number} eraserRadius - Eraser radius (canvas coordinates)
* @param {Object} stroke - Stroke object with points array
* @returns {boolean} True if eraser hits the stroke
*/
export function eraserHitsStroke(eraserX, eraserY, eraserRadius, stroke) {
if (!stroke.points || stroke.points.length < 2) {
return false;
}
// Get bounding box (with padding for stroke thickness)
const strokeRadius = (stroke.size || 4) / 2;
const box = getStrokeBoundingBox(stroke);
if (!box) return false;
// Expand box by stroke thickness
box.minX -= strokeRadius;
box.minY -= strokeRadius;
box.maxX += strokeRadius;
box.maxY += strokeRadius;
// Quick rejection: bounding box test
if (!circleIntersectsBox(eraserX, eraserY, eraserRadius, box)) {
return false;
}
// Detailed test: check distance to each segment
const radiusSquared = eraserRadius * eraserRadius;
const points = stroke.points;
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const distSquared = pointToSegmentDistanceSquared(
eraserX, eraserY,
p1.x, p1.y,
p2.x, p2.y
);
// Account for stroke thickness in hit detection
const effectiveRadiusSquared = (eraserRadius + strokeRadius) * (eraserRadius + strokeRadius);
if (distSquared <= effectiveRadiusSquared) {
return true; // Hit found, early exit
}
}
return false;
}
/**
* Finds all strokes that the eraser hits.
*
* @param {number} eraserX - Eraser center X (canvas coordinates)
* @param {number} eraserY - Eraser center Y (canvas coordinates)
* @param {number} eraserRadius - Eraser radius (canvas coordinates)
* @param {Array} strokes - Array of stroke objects
* @returns {Array} Array of stroke IDs that were hit
*/
export function findHitStrokes(eraserX, eraserY, eraserRadius, strokes) {
const hitIds = [];
for (const stroke of strokes) {
if (eraserHitsStroke(eraserX, eraserY, eraserRadius, stroke)) {
hitIds.push(stroke.id);
}
}
return hitIds;
}
/**
* Removes strokes by their IDs from an array.
* Returns a new array without the removed strokes.
*
* @param {Array} strokes - Original strokes array
* @param {Array} idsToRemove - Array of stroke IDs to remove
* @returns {Array} New array with strokes removed
*/
export function removeStrokesById(strokes, idsToRemove) {
if (idsToRemove.length === 0) {
return strokes;
}
const idSet = new Set(idsToRemove);
return strokes.filter(stroke => !idSet.has(stroke.id));
}