Skip to content

Commit bceef99

Browse files
authored
Merge pull request #341 from jeffeb3/feature/pixelate
Pixelate effect
2 parents f7177d6 + 2f5a270 commit bceef99

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

src/features/effects/Pixelate.js

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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+
}

src/features/effects/effectFactory.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Fisheye from "./Fisheye"
55
import Loop from "./Loop"
66
import Mask from "./Mask"
77
import Noise from "./noise/Noise"
8+
import Pixelate from "./Pixelate"
89
import ProgramCode from "./ProgramCode"
910
import Track from "./Track"
1011
import Transformer from "./Transformer"
@@ -22,6 +23,7 @@ export const effectFactory = {
2223
track: Track,
2324
warp: Warp,
2425
voronoi: Voronoi,
26+
pixelate: Pixelate,
2527
}
2628

2729
export const getEffect = (type, ...args) => {

src/i18n/locales/zh.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@
190190
"Patterns": "图案",
191191
"Period": "周期",
192192
"perimeter": "边缘",
193+
"Pixel size": "像素大小",
194+
"Pixelate": "像素化",
193195
"Placement": "放置方式",
194196
"Point": "",
195197
"Points": "顶点数",

0 commit comments

Comments
 (0)