|
| 1 | +import Victor from "victor" |
| 2 | +import Effect from "./Effect" |
| 3 | +import Graph, { getEulerianTrail } from "@/common/Graph" |
| 4 | +import { |
| 5 | + centroid, |
| 6 | + findBounds, |
| 7 | + pointInPolygon, |
| 8 | + subsample, |
| 9 | +} from "@/common/geometry" |
| 10 | + |
| 11 | +const FILL_BBOX_COVERAGE_THRESHOLD = 0.7 // If fill covers >70% of bbox, it's likely a false positive |
| 12 | +const FILL_STROKE_RATIO_THRESHOLD = 1.5 // Fill must be 1.5x larger than stroke to be chosen |
| 13 | +const POINT_EPSILON = 0.0001 // Tolerance for comparing point coordinates |
| 14 | +const VISUAL_SCALE_DIVISOR = 3 // Divisor to make pixel sizes visually appropriate |
| 15 | + |
| 16 | +// Helper to create consistent cell keys for Set storage |
| 17 | +const cellKey = (cx, cy) => `${cx},${cy}` |
| 18 | + |
| 19 | +// Convert grid coordinates to world coordinates |
| 20 | +const gridToWorld = (cx, cy, centroid, pixelSize) => |
| 21 | + new Victor(centroid.x + cx * pixelSize, centroid.y + cy * pixelSize) |
| 22 | + |
| 23 | +// Convert world coordinate to grid cell coordinate |
| 24 | +const worldToGrid = (val, centroid, pixelSize) => { |
| 25 | + const relative = (val - centroid) / pixelSize |
| 26 | + // Round to 9 decimal places to avoid floating-point edge cases |
| 27 | + // (e.g., 2.9999999999 should floor to 3, not 2) |
| 28 | + const rounded = Math.round(relative * 1e9) / 1e9 |
| 29 | + |
| 30 | + return Math.floor(rounded) |
| 31 | +} |
| 32 | + |
| 33 | +const options = { |
| 34 | + pixelatePixelSize: { |
| 35 | + title: "Pixel size", |
| 36 | + type: "slider", |
| 37 | + min: 1, |
| 38 | + max: 30, |
| 39 | + step: 1, |
| 40 | + }, |
| 41 | +} |
| 42 | + |
| 43 | +export default class Pixelate extends Effect { |
| 44 | + constructor() { |
| 45 | + super("pixelate") |
| 46 | + this.label = "Pixelate" |
| 47 | + } |
| 48 | + |
| 49 | + getInitialState() { |
| 50 | + return { |
| 51 | + ...super.getInitialState(), |
| 52 | + pixelatePixelSize: 8, |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + getVertices(effect, layer, vertices) { |
| 57 | + if (vertices.length < 2) { |
| 58 | + return vertices |
| 59 | + } |
| 60 | + |
| 61 | + // Scale pixel size with layer size so pixelation stays consistent when resizing |
| 62 | + // Use sqrt for gentler scaling - doubling layer size increases pixel size by ~1.4x |
| 63 | + const referenceSize = 100 |
| 64 | + const layerSize = Math.max( |
| 65 | + layer.width || referenceSize, |
| 66 | + layer.height || referenceSize, |
| 67 | + ) |
| 68 | + const scaleFactor = Math.sqrt(layerSize / referenceSize) |
| 69 | + const pixelSize = |
| 70 | + (effect.pixelatePixelSize * scaleFactor) / VISUAL_SCALE_DIVISOR |
| 71 | + const shapeCentroid = centroid(vertices) |
| 72 | + const bounds = findBounds(vertices) |
| 73 | + const subsampleLength = Math.max(1.0, pixelSize / 2) |
| 74 | + |
| 75 | + vertices = subsample(vertices, subsampleLength) |
| 76 | + |
| 77 | + const fillCells = this.findInsideCells( |
| 78 | + vertices, |
| 79 | + pixelSize, |
| 80 | + shapeCentroid, |
| 81 | + bounds, |
| 82 | + ) |
| 83 | + const strokeCells = this.findStrokeCells(vertices, pixelSize, shapeCentroid) |
| 84 | + const cells = this.chooseCells(fillCells, strokeCells, bounds, pixelSize) |
| 85 | + |
| 86 | + if (cells.size === 0) { |
| 87 | + return vertices |
| 88 | + } |
| 89 | + |
| 90 | + return this.traceCellOutline(cells, pixelSize, shapeCentroid) |
| 91 | + } |
| 92 | + |
| 93 | + // Find all grid cells whose centers are inside the shape |
| 94 | + findInsideCells(vertices, pixelSize, shapeCentroid, bounds) { |
| 95 | + const cells = new Set() |
| 96 | + const [minX, minY] = [bounds[0].x, bounds[0].y] |
| 97 | + const [maxX, maxY] = [bounds[1].x, bounds[1].y] |
| 98 | + |
| 99 | + // Convert to grid cell range (centered on centroid) |
| 100 | + const cellMinX = Math.floor((minX - shapeCentroid.x) / pixelSize) - 1 |
| 101 | + const cellMinY = Math.floor((minY - shapeCentroid.y) / pixelSize) - 1 |
| 102 | + const cellMaxX = Math.ceil((maxX - shapeCentroid.x) / pixelSize) + 1 |
| 103 | + const cellMaxY = Math.ceil((maxY - shapeCentroid.y) / pixelSize) + 1 |
| 104 | + |
| 105 | + // Test each cell's center |
| 106 | + for (let cy = cellMinY; cy <= cellMaxY; cy++) { |
| 107 | + for (let cx = cellMinX; cx <= cellMaxX; cx++) { |
| 108 | + const cellCenter = gridToWorld( |
| 109 | + cx + 0.5, |
| 110 | + cy + 0.5, |
| 111 | + shapeCentroid, |
| 112 | + pixelSize, |
| 113 | + ) |
| 114 | + |
| 115 | + if (pointInPolygon(cellCenter.x, cellCenter.y, vertices)) { |
| 116 | + cells.add(cellKey(cx, cy)) |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + return cells |
| 122 | + } |
| 123 | + |
| 124 | + // Find all grid cells that the path passes through (stroke-based) |
| 125 | + findStrokeCells(vertices, pixelSize, centroid) { |
| 126 | + const cells = new Set() |
| 127 | + |
| 128 | + for (let i = 0; i < vertices.length - 1; i++) { |
| 129 | + const v0 = vertices[i] |
| 130 | + const v1 = vertices[i + 1] |
| 131 | + |
| 132 | + this.rasterizeSegment(v0.x, v0.y, v1.x, v1.y, pixelSize, cells, centroid) |
| 133 | + } |
| 134 | + |
| 135 | + return cells |
| 136 | + } |
| 137 | + |
| 138 | + // Rasterize a line segment using Bresenham's "supercover" variant. |
| 139 | + // Unlike standard Bresenham which visits only one cell per step, supercover |
| 140 | + // visits ALL cells the line passes through, including diagonal neighbors. |
| 141 | + rasterizeSegment(x0, y0, x1, y1, pixelSize, cells, shapeCentroid) { |
| 142 | + const gx0 = worldToGrid(x0, shapeCentroid.x, pixelSize) |
| 143 | + const gy0 = worldToGrid(y0, shapeCentroid.y, pixelSize) |
| 144 | + const gx1 = worldToGrid(x1, shapeCentroid.x, pixelSize) |
| 145 | + const gy1 = worldToGrid(y1, shapeCentroid.y, pixelSize) |
| 146 | + const dx = Math.abs(gx1 - gx0) |
| 147 | + const dy = Math.abs(gy1 - gy0) |
| 148 | + let x = gx0 |
| 149 | + let y = gy0 |
| 150 | + let n = 1 + dx + dy |
| 151 | + const xInc = gx1 > gx0 ? 1 : gx1 < gx0 ? -1 : 0 |
| 152 | + const yInc = gy1 > gy0 ? 1 : gy1 < gy0 ? -1 : 0 |
| 153 | + let error = dx - dy |
| 154 | + const dx2 = dx * 2 |
| 155 | + const dy2 = dy * 2 |
| 156 | + |
| 157 | + while (n > 0) { |
| 158 | + cells.add(cellKey(x, y)) |
| 159 | + |
| 160 | + if (error > 0) { |
| 161 | + x += xInc |
| 162 | + error -= dy2 |
| 163 | + } else if (error < 0) { |
| 164 | + y += yInc |
| 165 | + error += dx2 |
| 166 | + } else { |
| 167 | + // Exactly on corner - add both adjacent cells for full coverage |
| 168 | + x += xInc |
| 169 | + error -= dy2 |
| 170 | + cells.add(cellKey(x, y)) |
| 171 | + y += yInc |
| 172 | + error += dx2 |
| 173 | + n-- |
| 174 | + } |
| 175 | + n-- |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + // Choose between fill and stroke cells based on shape characteristics |
| 180 | + chooseCells(fillCells, strokeCells, bounds, pixelSize) { |
| 181 | + // Compute bounding box area in cells to detect "fill everything" false positives |
| 182 | + const bboxCellsX = Math.ceil((bounds[1].x - bounds[0].x) / pixelSize) + 1 |
| 183 | + const bboxCellsY = Math.ceil((bounds[1].y - bounds[0].y) / pixelSize) + 1 |
| 184 | + const bboxArea = bboxCellsX * bboxCellsY |
| 185 | + |
| 186 | + // Use fill if it found substantially more cells than stroke (solid shape) |
| 187 | + // BUT not if fill covers almost the entire bounding box (line-based shapes |
| 188 | + // like maze/wiper where the path encloses everything but detail is in the path) |
| 189 | + const fillCoversAlmostAll = |
| 190 | + fillCells.size > bboxArea * FILL_BBOX_COVERAGE_THRESHOLD |
| 191 | + const fillIsSignificantlyLarger = |
| 192 | + fillCells.size > strokeCells.size * FILL_STROKE_RATIO_THRESHOLD |
| 193 | + |
| 194 | + if (fillIsSignificantlyLarger && !fillCoversAlmostAll) { |
| 195 | + return fillCells |
| 196 | + } |
| 197 | + |
| 198 | + return strokeCells |
| 199 | + } |
| 200 | + |
| 201 | + traceCellOutline(cells, pixelSize, shapeCentroid) { |
| 202 | + const graph = new Graph() |
| 203 | + |
| 204 | + // Each edge definition specifies: |
| 205 | + // - neighbor: offset to check if adjacent cell exists (if not, draw this edge) |
| 206 | + // - from/to: corner offsets within the cell for the edge endpoints |
| 207 | + const edgeDefs = [ |
| 208 | + { neighbor: [0, -1], from: [0, 0], to: [1, 0] }, // Bottom edge |
| 209 | + { neighbor: [1, 0], from: [1, 0], to: [1, 1] }, // Right edge |
| 210 | + { neighbor: [0, 1], from: [1, 1], to: [0, 1] }, // Top edge |
| 211 | + { neighbor: [-1, 0], from: [0, 1], to: [0, 0] }, // Left edge |
| 212 | + ] |
| 213 | + |
| 214 | + for (const key of cells) { |
| 215 | + const [cx, cy] = key.split(",").map(Number) |
| 216 | + |
| 217 | + for (const { neighbor, from, to } of edgeDefs) { |
| 218 | + if (!cells.has(cellKey(cx + neighbor[0], cy + neighbor[1]))) { |
| 219 | + const n1 = gridToWorld( |
| 220 | + cx + from[0], |
| 221 | + cy + from[1], |
| 222 | + shapeCentroid, |
| 223 | + pixelSize, |
| 224 | + ) |
| 225 | + const n2 = gridToWorld( |
| 226 | + cx + to[0], |
| 227 | + cy + to[1], |
| 228 | + shapeCentroid, |
| 229 | + pixelSize, |
| 230 | + ) |
| 231 | + |
| 232 | + graph.addNode(n1) |
| 233 | + graph.addNode(n2) |
| 234 | + graph.addEdge(n1, n2) |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + if (graph.nodeKeys.size === 0) { |
| 240 | + return [] |
| 241 | + } |
| 242 | + |
| 243 | + graph.connectComponents() |
| 244 | + |
| 245 | + const trail = getEulerianTrail(graph) |
| 246 | + const result = [] |
| 247 | + |
| 248 | + // Convert trail to vertices |
| 249 | + for (const nodeKey of trail) { |
| 250 | + const node = graph.nodeMap[nodeKey] |
| 251 | + |
| 252 | + if (node) { |
| 253 | + // Skip duplicate consecutive points |
| 254 | + if (result.length > 0) { |
| 255 | + const last = result[result.length - 1] |
| 256 | + |
| 257 | + if ( |
| 258 | + Math.abs(node.x - last.x) < POINT_EPSILON && |
| 259 | + Math.abs(node.y - last.y) < POINT_EPSILON |
| 260 | + ) { |
| 261 | + continue |
| 262 | + } |
| 263 | + } |
| 264 | + result.push(new Victor(node.x, node.y)) |
| 265 | + } |
| 266 | + } |
| 267 | + |
| 268 | + return result |
| 269 | + } |
| 270 | + |
| 271 | + getOptions() { |
| 272 | + return options |
| 273 | + } |
| 274 | +} |
0 commit comments