class Stippling {
constructor(densityGrid, resolutionGrid, minRadius, maxRadius) {
this.densityGrid = densityGrid;
this.resolutionGrid = resolutionGrid;
this.width = resolutionGrid.width;
this.height = resolutionGrid.height;
this.densityGridExtent = d3.extent(densityGrid.values);
this.resolutionGridExtent = d3.extent(resolutionGrid.values);
this.radiusExtent = [minRadius, maxRadius];
this.resolutionDensityToRadius = d3
.scaleLinear()
.domain(this.resolutionGridExtent)
.range([minRadius, maxRadius]);
this.threshold = 0.4;
this.delta_threshold = 0.01;
this.stipples = this.random_stipples(this.width*this.height / 625 | 0);
}
random_stipples(num) {
const stipples = []
const xran = d3.randomUniform(0, this.width);
const yran = d3.randomUniform(0, this.height);
for (var i = 0; i < num; ++i) {
stipples[i] = [
xran(),
yran(),
this.radiusExtent[0],
0, // radiusDensity
0, // resolutionDensity
0, // moment10
0, // moment01d
0, // moment11
0, // moment20
0, // moment02
];
}
return stipples;
}
// performs one iteration of the LBG stippling algorithm
iterate() {
const delaunay = d3.Delaunay.from(this.stipples),
voronoi = delaunay.voronoi([0, 0, this.width, this.height]);
// initialize densities
for(let i = 0; i < this.stipples.length; i++) {
const st = this.stipples[i];
st[3] = 0; // radiusDensity
st[4] = 0; // resolutionDensity aka moment00
st[5] = 0; // moment10
st[6] = 0; // moment01
st[7] = 0; // moment11
st[8] = 0; // moment20
st[9] = 0; // moment02
}
// compute the density and the weighted centroid of each cell
var found = 0;
for(let y = 0, {stipples, width} = this, dValues = this.densityGrid.values, rValues = this.resolutionGrid.values; y < this.height; y++) {
const line = y * width;
for(let x = 0; x < width; x++) {
found = delaunay.find(x, y, found);
const st = stipples[found];
const densityVal = dValues[x + line];
const resolutionVal = rValues[x + line];
const xval = x*resolutionVal;
const yval = y*resolutionVal;
st[3] += densityVal; // radiusDensity
st[4] += resolutionVal; // resolutionDensity aka moment00
st[5] += xval; // moment10
st[6] += yval; // moment01
st[7] += x * yval; // moment11
st[8] += x * xval; // moment20
st[9] += y * yval; // moment02
}
}
const {
resolutionDensityToRadius,
} = this;
const resolutionGridMax = this.resolutionGridExtent[1];
let deleted = 0;
let splitted = 0
const newStipples = [];
const {abs, atan2, cos, min, sin, sqrt, PI } = Math;
for (let i = 0; i < this.stipples.length; i++) {
const polygon = voronoi.cellPolygon(i);
if (!polygon) continue;
const st = this.stipples[i];
const density = st[4]; // resolutionDensity aka moment00
// determine point size based on average intensity
const area = abs(d3.polygonArea(polygon));
const avgDensity = area ? density / area : 0;
const resolutionRadius = resolutionDensityToRadius(avgDensity);
const resolutionArea = PI * resolutionRadius * resolutionRadius * resolutionGridMax;
const splitThreshold = (1.0 + this.threshold / 2.0) * resolutionArea;
const deleteThreshold = (1.0 - this.threshold / 2.0) * resolutionArea;
const renderRadius = sqrt(st[3]) / 16; // radiusDensity
if (density < deleteThreshold) {
deleted++;
} else if (density > splitThreshold) {
// Split
splitted++;
const centroid = d3.polygonCentroid(polygon);
const cx = centroid[0];
const cy = centroid[1];
const dist = sqrt(area / PI) / 2.0;
const x = st[8] / density - cx * cx; // moment20
const y = 2 * (st[7] / density - cx * cy); // moment11
const z = st[9] / density - cy * cy; // moment02
var orientation = atan2(y, x - z) / 2.0;
var deltaX = dist * cos(orientation);
var deltaY = dist * sin(orientation);
st[0] = cx + deltaX;
st[1] = cy + deltaY;
st[2] = renderRadius;
// re-use arrays to reduce GC pressure
centroid[0] -= deltaX;
centroid[1] -= deltaY;
centroid.push(
renderRadius,
0, // radiusDensity
0, // resolutionDensity
0, // moment10
0, // moment01
0, // moment11
0, // moment20
0, // moment02
);
newStipples.push(st, centroid);
} else {
// Relax
st[0] = st[5] / density; // moment10
st[1] = st[6] / density; //moment01
st[2] = renderRadius;
newStipples.push(st);
}
}
// we increase the threshold with each iteration for faster convergence
this.threshold = min(1, this.threshold + this.delta_threshold);
this.stipples = newStipples;
// is done?
return splitted + deleted === 0;
}
// Performs plain Voronoi relaxation (can be used to "clean up" LBG stippling that doesn't converge)
relax() {
const delaunay = d3.Delaunay.from(this.stipples),
voronoi = delaunay.voronoi([0, 0, this.width, this.height]);
// initialize densities
for(let i = 0; i < this.stipples.length; i++) {
const st = this.stipples[i];
st[3] = 0; // radiusDensity
st[4] = 0; // resolutionDensity aka moment00
st[5] = 0; // moment10
st[6] = 0; // moment01
}
// compute the density and the weighted centroid of each cell
var found = 0;
for(let y = 0, {stipples, width} = this, dValues = this.densityGrid.values, rValues = this.resolutionGrid.values; y < this.height; y++) {
const line = y * width;
for(let x = 0; x < width; x++) {
found = delaunay.find(x, y, found);
const st = stipples[found];
const densityVal = dValues[x + line];
const resolutionVal = rValues[x + line];
const xval = x*resolutionVal;
const yval = y*resolutionVal;
st[3] += densityVal; // radiusDensity
st[4] += resolutionVal; // resolutionDensity aka moment00
st[5] += xval; // moment10
st[6] += yval; // moment01
}
}
const {abs, sqrt} = Math;
for (let i = 0; i < this.stipples.length; i++) {
const polygon = voronoi.cellPolygon(i);
if (!polygon) continue;
const st = this.stipples[i];
// determine point size based on average intensity
const renderRadius = sqrt(st[3]) / 16; // radiusDensity
// Relax
const density = st[4]
st[0] = st[5] / density; // moment10
st[1] = st[6] / density; // moment01
st[3] = renderRadius;
}
}
}