0x01876c26…01casent to0xbd11994a…7699·#25,476,497·view on Etherscan
Resonance return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
}
function getDominantSource(x, y) {
const fx = Math.max(0, Math.min(gridW - 1, Math.round(x * (gridW - 1))));
const fy = Math.max(0, Math.min(gridH - 1, Math.round(y * (gridH - 1))));
const v = domGrid[fy * gridW + fx];
return Math.round((v / 255) * (MAX_SOURCES - 1));
}
function draw() {
if (!composition || !needsRender) return;
needsRender = false;
const t0 = performance.now();
renderArtwork();
handleAutoSaveAfterRender();
const t1 = performance.now();
console.log(`render ${(t1 - t0).toFixed(0)} ms`);
}
function handleAutoSaveAfterRender() {
if (!ifSave && !ifBulk) return;
saveCanvas('Resonance_' + seed, 'png');
if (ifBulk) {
setTimeout(() => {
seed = Math.floor(Math.random() * 999999);
location.hash = String(seed);
generate();
}, 350);
}
}
function renderArtwork() {
const P = composition.palette;
noStroke();
background(P.paper);
if (paperImg) image(paperImg, 0, 0, canvasSize, canvasSize);
setSeed(seed * 7 + 1);
drawSubstrateGrain(P);
setSeed(seed * 11 + 3);
drawSubstrateGrid(P);
setSeed(seed * 13 + 5);
drawGapCircles(P);
setSeed(seed * 17 + 7);
drawContours(P);
}
function drawSubstrateGrain(P) {
const G = 512;
const img = createImage(G, G);
img.loadPixels();
for (let i = 0; i < G * G; i++) {
const v = rnd() * rnd();
if (v > 0.65) {
const a = (v - 0.65) * 3.0;
img.pixels[i * 4 + 0] = 0;
img.pixels[i * 4 + 1] = 0;
img.pixels[i * 4 + 2] = 0;
img.pixels[i * 4 + 3] = Math.min(75, Math.floor(a * 110));
} else {
img.pixels[i * 4 + 3] = 0;
}
}
img.updatePixels();
push();
drawingContext.imageSmoothingEnabled = false;
drawingContext.globalCompositeOperation = 'multiply';
image(img, 0, 0, canvasSize, canvasSize);
drawingContext.globalCompositeOperation = 'source-over';
drawingContext.imageSmoothingEnabled = true;
pop();
}
function drawSubstrateGrid(P) {
const inkCol = color(P.ink);
const r = red(inkCol), g = green(inkCol), b = blue(inkCol);
const minorCells = 200;
const minorSpacing = canvasSize / minorCells;
const tremor = 0.35 * M;
push();
drawingContext.lineCap = 'butt';
drawingContext.strokeStyle = `rgba(${r},${g},${b},0.14)`;
drawingContext.lineWidth = 0.34 * M;
for (let n = 0; n <= minorCells; n++) {
const x = n * minorSpacing;
const ox = (Math.sin(n * 13.7 + seed * 0.011) + Math.sin(n * 41.3 + seed * 0.007) * 0.5) * tremor;
drawingContext.beginPath();
drawingContext.moveTo(x + ox, 0);
drawingContext.lineTo(x + ox, canvasSize);
drawingContext.stroke();
}
for (let n = 0; n <= minorCells; n++) {
const y = n * minorSpacing;
const oy = (Math.cos(n * 17.1 + seed * 0.013) + Math.cos(n * 39.7 + seed * 0.009) * 0.5) * tremor;
drawingContext.beginPath();
drawingContext.moveTo(0, y + oy);
drawingContext.lineTo(canvasSize, y + oy);
drawingContext.stroke();
}
pop();
}
function drawWash(P) {
const washImg = createImage(gridW, gridH);
washImg.loadPixels();
const groundCol = color(P.ground);
const accentCol = color(P.accent);
const sec1Col = color(P.sec1);
const gR = red(groundCol), gG = green(groundCol), gB = blue(groundCol);
const aR = red(accentCol), aG = green(accentCol), aB = blue(accentCol);
const sR = red(sec1Col), sG = green(sec1Col), sB = blue(sec1Col);
for (let j = 0; j < gridH; j++) {
for (let i = 0; i < gridW; i++) {
const idx = (j * gridW + i) * 4;
const lf = lowGrid[j * gridW + i];
const t = Math.max(0, lf);
const tneg = Math.max(0, -lf) * 0.45;
const alpha = Math.min(1, t * 0.55 + tneg * 0.45);
const mix = tneg / (t + tneg + 0.0001);
const sec1Mix = (1 - mix) * 0.25;
let r = gR * (1 - mix - sec1Mix) + aR * mix + sR * sec1Mix;
let g = gG * (1 - mix - sec1Mix) + aG * mix + sG * sec1Mix;
let b = gB * (1 - mix - sec1Mix) + aB * mix + sB * sec1Mix;
washImg.pixels[idx + 0] = r;
washImg.pixels[idx + 1] = g;
washImg.pixels[idx + 2] = b;
washImg.pixels[idx + 3] = Math.floor(alpha * 110);
}
}
washImg.updatePixels();
push();
drawingContext.imageSmoothingEnabled = true;
image(washImg, 0, 0, canvasSize, canvasSize);
pop();
}
function drawRegionFills(P) {
const palette = composition.palette;
const colorOf = (key) => key ? color(palette[key]) : null;
for (let s = 0; s < composition.numSources; s++) {
const src = composition.sources[s];
if (src.fill === 'none') continue;
const fillCol = colorOf(src.fillColor) || color(palette.ink);
if (src.fill === 'stipple') drawStipple(s, fillCol);
else if (src.fill === 'crosshatch') drawCrossHatch(s, fillCol);
else if (src.fill === 'rings') drawInnerRings(s, src, fillCol);
else if (src.fill === 'solid') drawSolidFill(s, fillCol);
else if (src.fill === 'ribbon') drawRibbon(s, src, fillCol);
else if (src.fill === 'dots-grid') drawDotsGrid(s, fillCol);
}
}
function drawStipple(sourceIndex, fillCol) {
push();
fill(red(fillCol), green(fillCol), blue(fillCol), 200);
noStroke();
const step = canvasSize / 110;
for (let y = 0; y < canvasSize; y += step) {
for (let x = 0; x < canvasSize; x += step) {
const u = (x + (rnd() - 0.5) * step) / canvasSize;
const v = (y + (rnd() - 0.5) * step) / canvasSize;
if (u < 0 || u > 1 || v < 0 || v > 1) continue;
if (getDominantSource(u, v) !== sourceIndex) continue;
const f = sampleField(fieldGrid, u, v);
const lf = sampleField(lowGrid, u, v);
if (lf < 0.15) continue;
if (Math.abs(f) < 0.15) continue;
const density = Math.abs(f) * 0.7 + lf * 0.4;
if (rnd() > density * 1.6) continue;
const r = step * 0.18 + Math.abs(f) * step * 0.15;
circle(u * canvasSize, v * canvasSize, r);
}
}
pop();
}
function drawCrossHatch(sourceIndex, fillCol) {
const baseAngle = composition.hatchAngle + (rnd() - 0.5) * 0.4;
push();
drawingContext.lineCap = 'round';
for (let pass = 0; pass < 2; pass++) {
const a = baseAngle + (pass === 0 ? -0.5 : 0.5);
const cosA = Math.cos(a), sinA = Math.sin(a);
const spacing = canvasSize / (110 + pass * 10);
stroke(red(fillCol), green(fillCol), blue(fillCol), 130);
strokeWeight(0.6);
for (let s = -canvasSize; s < canvasSize * 2; s += spacing) {
const samples = 220;
let inSeg = false, segStart = null;
for (let k = 0; k <= samples; k++) {
const u = k / samples;
const cx = canvasSize / 2 + (-sinA) * s + cosA * (u - 0.5) * canvasSize * 1.6;
const cy = canvasSize / 2 + (cosA) * s + sinA * (u - 0.5) * canvasSize * 1.6;
const ux = cx / canvasSize, uy = cy / canvasSize;
let inRegion = false;
if (ux >= 0 && ux <= 1 && uy >= 0 && uy <= 1
&& getDominantSource(ux, uy) === sourceIndex) {
const lf = sampleField(lowGrid, ux, uy);
if (lf > 0.12) inRegion = true;
}
if (inRegion) {
if (!inSeg) { segStart = { x: cx, y: cy }; inSeg = true; }
} else {
if (inSeg) { line(segStart.x, segStart.y, cx, cy); inSeg = false; }
}
}
}
}
pop();
}
function drawInnerRings(sourceIndex, src, fillCol) {
push();
noFill();
stroke(red(fillCol), green(fillCol), blue(fillCol), 200);
drawingContext.lineCap = 'round';
const cx = src.x * canvasSize, cy = src.y * canvasSize;
for (let k = 1; k <= 18; k++) {
const r = k * canvasSize * 0.012;
strokeWeight(0.6 + (k % 3 === 0 ? 0.4 : 0));
const samples = 140;
let prev = null;
for (let s = 0; s <= samples; s++) {
const t = s / samples;
const a = t * Math.PI * 2;
const px = cx + Math.cos(a) * r;
const py = cy + Math.sin(a) * r;
const ux = px / canvasSize, uy = py / canvasSize;
if (ux < 0 || ux > 1 || uy < 0 || uy > 1) { prev = null; continue; }
const inRegion = getDominantSource(ux, uy) === sourceIndex;
if (inRegion && prev) line(prev.x, prev.y, px, py);
prev = inRegion ? { x: px, y: py } : null;
}
}
pop();
}
function drawSolidFill(sourceIndex, fillCol) {
const mw = 256, mh = 256;
const img = createImage(mw, mh);
img.loadPixels();
const fr = red(fillCol), fg = green(fillCol), fb = blue(fillCol);
for (let j = 0; j < mh; j++) {
for (let i = 0; i < mw; i++) {
const u = i / (mw - 1), v = j / (mh - 1);
const idx = (j * mw + i) * 4;
const dom = getDominantSource(u, v);
if (dom !== sourceIndex) { img.pixels[idx + 3] = 0; continue; }
const lf = sampleField(lowGrid, u, v);
if (lf < 0.08) { img.pixels[idx + 3] = 0; continue; }
const a = Math.min(1, Math.max(0, lf * 1.4 - 0.05));
const dither = rnd() < a ? 1 : a * 0.4;
img.pixels[idx + 0] = fr;
img.pixels[idx + 1] = fg;
img.pixels[idx + 2] = fb;
img.pixels[idx + 3] = Math.floor(dither * 200);
}
}
img.updatePixels();
push();
drawingContext.imageSmoothingEnabled = true;
drawingContext.globalCompositeOperation = 'multiply';
image(img, 0, 0, canvasSize, canvasSize);
drawingContext.globalCompositeOperation = 'source-over';
pop();
}
function drawRibbon(sourceIndex, src, fillCol) {
push();
drawingContext.lineCap = 'butt';
const cx = src.x * canvasSize, cy = src.y * canvasSize;
for (let k = 0; k < 8; k++) {
const baseR = k * canvasSize * 0.025 + canvasSize * 0.01;
const w = canvasSize * 0.012;
const a = 90 + (k % 2) * 70;
stroke(red(fillCol), green(fillCol), blue(fillCol), a);
strokeWeight(w);
noFill();
const samples = 200;
let prev = null;
for (let s = 0; s <= samples; s++) {
const t = s / samples;
const ang = t * Math.PI * 2;
const rr = baseR * (1 + 0.06 * Math.sin(ang * 5 + k * 0.7));
const px = cx + Math.cos(ang) * rr;
const py = cy + Math.sin(ang) * rr;
const ux = px / canvasSize, uy = py / canvasSize;
if (ux < 0 || ux > 1 || uy < 0 || uy > 1) { prev = null; continue; }
const inRegion = getDominantSource(ux, uy) === sourceIndex;
if (inRegion && prev) line(prev.x, prev.y, px, py);
prev = inRegion ? { x: px, y: py } : null;
}
}
pop();
}
function drawDotsGrid(sourceIndex, fillCol) {
push();
noStroke();
fill(red(fillCol), green(fillCol), blue(fillCol), 200);
const step = canvasSize / 60;
for (let y = step / 2; y < canvasSize; y += step) {
for (let x = step / 2; x < canvasSize; x += step) {
const u = x / canvasSize, v = y / canvasSize;
if (getDominantSource(u, v) !== sourceIndex) continue;
const lf = sampleField(lowGrid, u, v);
if (lf < 0.12) continue;
const f = sampleField(fieldGrid, u, v);
const r = step * 0.16 + Math.abs(f) * step * 0.12;
circle(x, y, r);
}
}
pop();
}
function drawHatching(P) {
const inkCol = color(P.ink);
const angle = composition.hatchAngle;
const spacing = canvasSize / 95;
const cosA = Math.cos(angle), sinA = Math.sin(angle);
push();
stroke(red(inkCol), green(inkCol), blue(inkCol), 50);
strokeWeight(0.5);
drawingContext.lineCap = 'round';
const hatchThreshold = -0.35;
const len = canvasSize * 1.6;
for (let s = -canvasSize; s < canvasSize * 2; s += spacing) {
const samples = 280;
let inSeg = false, segStart = null;
for (let k = 0; k <= samples; k++) {
const u = k / samples;
const cx = canvasSize / 2 + (-sinA) * s + cosA * (u - 0.5) * len;
const cy = canvasSize / 2 + (cosA) * s + sinA * (u - 0.5) * len;
if (cx < 0 || cx >= canvasSize || cy < 0 || cy >= canvasSize) {
if (inSeg) { line(segStart.x, segStart.y, cx, cy); inSeg = false; }
continue;
}
const ux = cx / canvasSize, uy = cy / canvasSize;
const v = sampleField(fieldGrid, ux, uy);
if (v < hatchThreshold) {
if (!inSeg) { segStart = { x: cx, y: cy }; inSeg = true; }
} else {
if (inSeg) { line(segStart.x, segStart.y, cx, cy); inSeg = false; }
}
}
}
pop();
}
function drawGapCircles(P) {
const inkCol = color(P.ink);
const accentCol = color(P.accent);
const inkR = red(inkCol), inkG = green(inkCol), inkB = blue(inkCol);
const accR = red(accentCol), accG = green(accentCol), accB = blue(accentCol);
const gw = gridW, gh = gridH;
const gradMag = new Float32Array(gw * gh);
for (let j = 1; j < gh - 1; j++) {
for (let i = 1; i < gw - 1; i++) {
const gx = fieldGrid[j * gw + (i + 1)] - fieldGrid[j * gw + (i - 1)];
const gy = fieldGrid[(j + 1) * gw + i] - fieldGrid[(j - 1) * gw + i];
gradMag[j * gw + i] = Math.hypot(gx, gy);
}
}
const cd = composition.circleDensity != null ? composition.circleDensity : 0.7;
const lerp = (a, b, t) => a + (b - a) * t;
const gapThreshold = lerp(0.04, 0.10, cd);
const cellsPerSide = lerp(70, 130, cd);
const cellSizeNorm = 1 / cellsPerSide;
const candidateCount = Math.max(1, Math.ceil(cellsPerSide - 0.5));
const placed = [];
const HASH_CELL_NORM = 14 / DEFAULT_SIZE;
const hashCols = Math.ceil(1 / HASH_CELL_NORM) + 1;
const hashGrid = new Map();
const hashKey = (i, j) => i * hashCols + j;
const hashAdd = (p) => {
const i = Math.floor(p.u / HASH_CELL_NORM);
const j = Math.floor(p.v / HASH_CELL_NORM);
const k = hashKey(i, j);
if (!hashGrid.has(k)) hashGrid.set(k, []);
hashGrid.get(k).push(p);
};
const placementCoef = lerp(0.7, 1.05, cd);
const radiusBaseNorm = lerp(1.2, 1.8, cd) / DEFAULT_SIZE;
const radiusRangeNorm = lerp(3.0, 5.0, cd) / DEFAULT_SIZE;
const spacingBufferNorm = lerp(1.5, -2.5, cd) / DEFAULT_SIZE;
const edgeClampNorm = 4 / DEFAULT_SIZE;
for (let cy = 0; cy < candidateCount; cy++) {
for (let cx = 0; cx < candidateCount; cx++) {
const cu = (cx + 0.5) * cellSizeNorm;
const cv = (cy + 0.5) * cellSizeNorm;
const jU = cu + (rnd() - 0.5) * cellSizeNorm * 0.7;
const jV = cv + (rnd() - 0.5) * cellSizeNorm * 0.7;
if (jU < edgeClampNorm || jV < edgeClampNorm || jU > 1 - edgeClampNorm || jV > 1 - edgeClampNorm) continue;
const jx = jU * canvasSize;
const jy = jV * canvasSize;
const gi = Math.max(1, Math.min(gw - 2, Math.round(jU * (gw - 1))));
const gj = Math.max(1, Math.min(gh - 2, Math.round(jV * (gh - 1))));
const grad = gradMag[gj * gw + gi];
if (grad > gapThreshold) continue;
const placementProb = placementCoef * (1 - grad / gapThreshold);
if (rnd() > placementProb) continue;
const rRadiusNorm = radiusBaseNorm + radiusRangeNorm * (1 - grad / gapThreshold) * (0.5 + 0.5 * rnd());
const rRadius = rRadiusNorm * canvasSize;
const hi = Math.floor(jU / HASH_CELL_NORM);
const hj = Math.floor(jV / HASH_CELL_NORM);
let tooClose = false;
outer: for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
const bucket = hashGrid.get(hashKey(hi + di, hj + dj));
if (!bucket) continue;
for (const p of bucket) {
const du = p.u - jU, dv = p.v - jV;
const minD = p.rNorm + rRadiusNorm + spacingBufferNorm;
if (du * du + dv * dv < minD * minD) { tooClose = true; break outer; }
}
}
}
if (tooClose) continue;
const newCircle = { u: jU, v: jV, rNorm: rRadiusNorm };
placed.push(newCircle);
hashAdd(newCircle);
}
}
push();
drawingContext.lineCap = 'round';
drawingContext.lineJoin = 'round';
for (const c of placed) {
drawScribbledCircle(c.u * canvasSize, c.v * canvasSize, c.rNorm * canvasSize, inkR, inkG, inkB, accR, accG, accB);
}
pop();
}
function drawScribbledCircle(cx, cy, radius, inkR, inkG, inkB, accR, accG, accB) {
const numPasses = radius < 2.5 * M
? (1 + (rnd() < 0.4 ? 1 : 0))
: (2 + Math.floor(rnd() * 3));
const mixColors = rnd() < 0.4;
const dominantIsAccent = rnd() < 0.18;
for (let pass = 0; pass < numPasses; pass++) {
const startA = rnd() * Math.PI * 2;
const arcSpan = (0.7 + 1.6 * rnd()) * Math.PI * 2;
const direction = rnd() < 0.5 ? 1 : -1;
const segs = Math.max(8, Math.floor((radius / M) * 2.5 * (arcSpan / (Math.PI * 2))));
const noisePhase = rnd() * Math.PI * 2;
const noiseFreq = 2 + rnd() * 4;
const noiseAmp = radius * (0.12 + 0.20 * rnd());
let r0, g0, b0;
if (mixColors) {
if (rnd() < 0.35) { r0 = accR; g0 = accG; b0 = accB; }
else { r0 = inkR; g0 = inkG; b0 = inkB; }
} else {
if (dominantIsAccent) { r0 = accR; g0 = accG; b0 = accB; }
else { r0 = inkR; g0 = inkG; b0 = inkB; }
}
r0 += (rnd() - 0.5) * 18;
g0 += (rnd() - 0.5) * 18;
b0 += (rnd() - 0.5) * 18;
r0 = Math.max(0, Math.min(255, r0));
g0 = Math.max(0, Math.min(255, g0));
b0 = Math.max(0, Math.min(255, b0));
const alpha = 0.30 + 0.30 * rnd();
const w = (0.5 + 0.6 * rnd()) * M;
drawingContext.strokeStyle = `rgba(${r0 | 0},${g0 | 0},${b0 | 0},${alpha})`;
drawingContext.lineWidth = w;
drawingContext.beginPath();
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const a = startA + direction * arcSpan * t;
const wob =
Math.sin(a * noiseFreq + noisePhase) * noiseAmp
+ Math.sin(a * (noiseFreq * 1.7) + noisePhase * 1.3) * (noiseAmp * 0.5)
+ (rnd() - 0.5) * radius * 0.10;
const r = radius + wob;
const px = cx + Math.cos(a) * r;
const py = cy + Math.sin(a) * r;
if (i === 0) drawingContext.moveTo(px, py);
else drawingContext.lineTo(px, py);
}
drawingContext.stroke();
}
}
function drawContours(P) {
const inkCol = color(P.ink);
const primaryCol = color(P.primary);
const accentCol = color(P.accent);
const featureLevels = new Set();
const numLevels = composition.contourLevels.length;
featureLevels.add(Math.floor(numLevels * 0.25));
featureLevels.add(Math.floor(numLevels * 0.75));
if (rnd() < 0.5) featureLevels.add(Math.floor(numLevels * 0.5));
for (let lvl = 0; lvl < numLevels; lvl++) {
const t = composition.contourLevels[lvl];
const threshold = (t - 0.5) * 1.7;
const polylines = marchingSquares(fieldGrid, gridW, gridH, threshold);
let strokeCol, weight;
const isFeature = featureLevels.has(lvl);
const isCenter = Math.abs(t - 0.5) < 0.06;
if (isCenter) {
strokeCol = accentCol;
weight = composition.baseWeight * 2.8 * M;
} else if (isFeature) {
strokeCol = primaryCol;
weight = composition.baseWeight * 2.0 * M;
} else {
strokeCol = inkCol;
weight = composition.baseWeight * (0.7 + 0.6 * Math.abs(t - 0.5) * 2) * M;
}
const levelWeightVariation = isCenter
? 0.92 + 0.36 * rnd()
: (isFeature ? 0.80 + 0.55 * rnd() : 0.65 + 0.75 * rnd());
for (const poly of polylines) {
if (poly.length < 4) continue;
const polyWeightVariation = 0.82 + 0.36 * rnd();
drawPencilContour(poly, strokeCol, weight * levelWeightVariation * polyWeightVariation);
}
}
}
function drawPencilContour(polyline, col, baseWeight) {
const pts = polyline.map(p => {
const gx = Math.min(gridW - 1, Math.max(0, Math.round(p.x)));
const gy = Math.min(gridH - 1, Math.max(0, Math.round(p.y)));
return {
x: (p.x / (gridW - 1)) * canvasSize,
y: (p.y / (gridH - 1)) * canvasSize,
gx, gy
};
});
let energySum = 0;
for (const p of pts) energySum += Math.abs(lowGrid[p.gy * gridW + p.gx]);
const energyAvg = energySum / pts.length;
const energyFactor = Math.min(1, 0.18 + energyAvg * 1.6);
const tremor = Math.max(0.3 * M, 1.6 * M - baseWeight * 0.4);
const tpts = pts.map((p, i) => {
const dx = (Math.sin(i * 0.31 + seed * 0.013) + Math.sin(i * 0.117 + seed * 0.027) * 0.6) * tremor;
const dy = (Math.cos(i * 0.27 + seed * 0.019) + Math.cos(i * 0.131 + seed * 0.033) * 0.6) * tremor;
return { x: p.x + dx, y: p.y + dy };
});
const cumulative = [0];
for (let i = 1; i < tpts.length; i++) {
const dx = tpts[i].x - tpts[i - 1].x;
const dy = tpts[i].y - tpts[i - 1].y;
cumulative.push(cumulative[i - 1] + Math.hypot(dx, dy));
}
const totalLen = cumulative[cumulative.length - 1];
if (totalLen < 4 * M) return;
const r = red(col), g = green(col), b = blue(col);
if ((baseWeight / M) > composition.baseWeight * 1.4) {
push();
noFill();
stroke(r, g, b, Math.floor(32 * energyFactor));
strokeWeight(baseWeight * 3.0);
drawingContext.lineCap = 'round';
drawingContext.lineJoin = 'round';
drawSmoothCurve(tpts);
pop();
}
const variants = makeColorVariants(r, g, b);
const unscaledWeight = baseWeight / M;
const density = 0.38 + 0.82 * Math.min(1, unscaledWeight / (composition.baseWeight * 2.8));
const numStrokes = Math.max(2, Math.floor((totalLen / M) * density));
let lastIdx = 1;
const sampleAt = (s) => {
while (lastIdx > 1 && cumulative[lastIdx - 1] > s) lastIdx--;
while (lastIdx < cumulative.length - 1 && cumulative[lastIdx] < s) lastIdx++;
const i = lastIdx;
const segStart = cumulative[i - 1];
const segLen = cumulative[i] - segStart;
const t = segLen > 0 ? (s - segStart) / segLen : 0;
const x = tpts[i - 1].x * (1 - t) + tpts[i].x * t;
const y = tpts[i - 1].y * (1 - t) + tpts[i].y * t;
let tx = tpts[i].x - tpts[i - 1].x;
let ty = tpts[i].y - tpts[i - 1].y;
const tlen = Math.hypot(tx, ty);
if (tlen > 0) { tx /= tlen; ty /= tlen; }
else { tx = 1; ty = 0; }
return { x, y, tx, ty };
};
push();
drawingContext.lineCap = 'round';