UNPKG

@fluritheme/fluri

Version:

Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.

161 lines • 7.46 kB
import { clamp, localToWorld, surfaceNormal, worldToLocal } from './math.js'; import { snowYAt } from './profiles.js'; function insideClip(point, clip) { if (point.x < clip.x || point.x > clip.x + clip.width || point.y < clip.y || point.y > clip.y + clip.height) return false; const radius = Math.max(0, Math.min(clip.radius, clip.width / 2, clip.height / 2)); const x = point.x - clamp(point.x, clip.x + radius, clip.x + clip.width - radius); const y = point.y - clamp(point.y, clip.y + radius, clip.y + clip.height - radius); return x * x + y * y <= radius * radius + 1e-6; } /** Swept intersections prevent high-speed clumps crossing a one-pixel shelf. */ export function sweptSurfaceHit(surface, start, end, radius = 0) { if (!surface.visible || surface.occluded || surface.width <= 0) return undefined; const a = worldToLocal(surface, start); const b = worldToLocal(surface, end); const dx = b.x - a.x; const dy = b.y - a.y; if (Math.max(a.y, b.y) + radius < -surface.maxDepth || Math.min(a.y, b.y) - radius > surface.radius) return undefined; if (Math.max(a.x, b.x) < 0 || Math.min(a.x, b.x) > surface.width) return undefined; // Each linear profile section is an exact collision segment in local space. const startX = Math.max(0, Math.min(a.x, b.x)); const endX = Math.min(surface.width, Math.max(a.x, b.x)); // Only sample the crossed interval. Sampling to both far shelf ends made each flake // traverse an entire snow profile even when it moved a fraction of one CSS pixel. const narrowLeft = Math.max(0, startX - 0.5), narrowRight = Math.min(surface.width, endX + 0.5); const boundaries = [narrowLeft]; if (surface.binSize > 0) { const first = Math.max(0, Math.ceil(narrowLeft / surface.binSize - 0.5)); const last = Math.min(surface.profile.length - 1, Math.floor(narrowRight / surface.binSize - 0.5)); for (let index = first; index <= last; index++) { const x = (index + 0.5) * surface.binSize; if (x > narrowLeft && x < narrowRight) boundaries.push(x); } } boundaries.push(narrowRight); const sampled = []; const subdivide = (left, right, leftDepth, rightDepth, level) => { const span = right - left; let error = 0; for (const fraction of [0.25, 0.5, 0.75]) error = Math.max(error, Math.abs(snowYAt(surface, left + span * fraction) - (leftDepth + (rightDepth - leftDepth) * fraction))); if (error > 0.16 && level < 5) { const middle = (left + right) / 2; const middleDepth = snowYAt(surface, middle); subdivide(left, middle, leftDepth, middleDepth, level + 1); subdivide(middle, right, middleDepth, rightDepth, level + 1); } else sampled.push(right); }; for (let index = 0; index + 1 < boundaries.length; index++) { const left = boundaries[index] ?? 0; const right = boundaries[index + 1] ?? surface.width; if (right < startX || left > endX || right - left <= 1e-8) continue; if (sampled[sampled.length - 1] !== left) sampled.push(left); subdivide(left, right, snowYAt(surface, left), snowYAt(surface, right), 0); } let best; for (let index = 0; index + 1 < sampled.length; index++) { const left = sampled[index] ?? 0; const right = sampled[index + 1] ?? surface.width; if (right < startX || left > endX || right - left <= 1e-8) continue; const leftY = snowYAt(surface, left) - radius; const rightY = snowYAt(surface, right) - radius; const slope = (rightY - leftY) / (right - left); const denominator = dy - slope * dx; if (denominator <= 1e-8) continue; const t = (leftY + slope * (a.x - left) - a.y) / denominator; if (t < -1e-7 || t > 1 || (best && t >= best.t)) continue; const x = a.x + dx * t; if (x < left - 1e-7 || x > right + 1e-7) continue; if (surface.exposed?.[clamp(Math.floor(x / surface.binSize), 0, surface.profile.length - 1)] === 0) continue; const point = localToWorld(surface, x, snowYAt(surface, x)); if (surface.options.clip !== false && surface.clipRegions.some((clip) => !insideClip(point, clip))) continue; const baseNormal = surfaceNormal(surface); const tx = Math.cos(surface.angle); const ty = Math.sin(surface.angle); const scale = 1 / Math.hypot(1, slope); best = { surface, t: Math.max(0, t), x, point, normal: { x: (baseNormal.x + slope * tx) * scale, y: (baseNormal.y + slope * ty) * scale }, }; } return best; } /** Grid cells contain shelf bounds, never individual flake state. Rebuild on dirty geometry. */ export class SurfaceIndex { cellSize; cells = new Map(); oversized = []; surfaces = []; constructor(cellSize = 128) { this.cellSize = cellSize; if (!(cellSize >= 8) || !Number.isFinite(cellSize)) throw new RangeError('SurfaceIndex cell size must be finite and at least 8.'); } rebuild(surfaces) { this.cells.clear(); this.oversized.length = 0; this.surfaces = surfaces.filter((surface) => surface.visible && surface.width > 0); for (const surface of this.surfaces) { const end = localToWorld(surface, surface.width); const depth = Math.max(surface.maxDepth, 0); const x1 = Math.floor((Math.min(surface.x, end.x) - depth) / this.cellSize); const x2 = Math.floor((Math.max(surface.x, end.x) + depth) / this.cellSize); const y1 = Math.floor((Math.min(surface.y, end.y) - depth) / this.cellSize); const y2 = Math.floor((Math.max(surface.y, end.y) + depth) / this.cellSize); // Malformed external surfaces must not allocate an unbounded spatial grid. if ((x2 - x1 + 1) * (y2 - y1 + 1) > 4096) { this.oversized.push(surface); continue; } for (let x = x1; x <= x2; x++) for (let y = y1; y <= y2; y++) { const key = `${x},${y}`; const cell = this.cells.get(key); if (cell) cell.push(surface); else this.cells.set(key, [surface]); } } } query(start, end, radius = 0) { const x1 = Math.floor((Math.min(start.x, end.x) - radius) / this.cellSize); const x2 = Math.floor((Math.max(start.x, end.x) + radius) / this.cellSize); const y1 = Math.floor((Math.min(start.y, end.y) - radius) / this.cellSize); const y2 = Math.floor((Math.max(start.y, end.y) + radius) / this.cellSize); if ((x2 - x1 + 1) * (y2 - y1 + 1) > 4096) return [...this.surfaces]; const found = new Set(this.oversized); for (let x = x1; x <= x2; x++) for (let y = y1; y <= y2; y++) { for (const surface of this.cells.get(`${x},${y}`) ?? []) found.add(surface); } return [...found]; } } //# sourceMappingURL=collisions.js.map