436 lines
17 KiB
JavaScript
436 lines
17 KiB
JavaScript
/* ============================================================
|
|
canvasPhoto.js — the photographic raster renderer.
|
|
A multi-pass compositor that turns the pure scene model into
|
|
something that reads as a worn bubble-chamber plate:
|
|
paper + gas-glow → tonal mottle → ink (soft blooming
|
|
bubbles that merge into lines) → halation → shock disk →
|
|
plate damage → vignette → film grain.
|
|
Geometry is identical to the vector renderer; only the look
|
|
differs. Same code path serves the live preview and the
|
|
high-resolution print render (effect radii scale with size).
|
|
============================================================ */
|
|
import { makeRng } from '../rng.js';
|
|
import { sampleBubbles, trackInkWeight } from '../scene/bubbles.js';
|
|
import { mottleCanvas, grainCanvas } from './noise.js';
|
|
|
|
const MARGIN = 0.02;
|
|
|
|
/* ---- palettes ---- */
|
|
function palette(inv) {
|
|
return inv
|
|
? { paperFlat: '#d3ccb8', glowIn: '#e4ddc9', glowOut: '#b3aa92',
|
|
ink: [28, 24, 19], inkBlend: 'multiply',
|
|
lift: 'screen', vign: [56, 46, 32] }
|
|
: { paperFlat: '#0e0d0b', glowIn: '#211e18', glowOut: '#070605',
|
|
ink: [233, 228, 214], inkBlend: 'screen',
|
|
lift: 'multiply', vign: [0, 0, 0] };
|
|
}
|
|
|
|
/* cached noise canvases (rebuilt only when seed/size change) */
|
|
const noiseCache = { key: '', mottleA: null, mottleB: null, grain: null, sprite: null, spriteKey: '' };
|
|
|
|
function softDotSprite(inkRGB) {
|
|
const key = inkRGB.join(',');
|
|
if (noiseCache.sprite && noiseCache.spriteKey === key) return noiseCache.sprite;
|
|
const S = 64;
|
|
const c = document.createElement('canvas');
|
|
c.width = c.height = S;
|
|
const g = c.getContext('2d');
|
|
const grad = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
|
const [r, gr, b] = inkRGB;
|
|
grad.addColorStop(0.0, `rgba(${r},${gr},${b},1)`);
|
|
grad.addColorStop(0.30, `rgba(${r},${gr},${b},0.92)`);
|
|
grad.addColorStop(0.62, `rgba(${r},${gr},${b},0.40)`);
|
|
grad.addColorStop(1.0, `rgba(${r},${gr},${b},0)`);
|
|
g.fillStyle = grad;
|
|
g.fillRect(0, 0, S, S);
|
|
noiseCache.sprite = c; noiseCache.spriteKey = key;
|
|
return c;
|
|
}
|
|
|
|
export function renderCanvasPhoto(ctx, w, h, scene, params, opts = {}) {
|
|
const pre = opts.preview !== false;
|
|
const inv = params.invert;
|
|
const P = palette(inv);
|
|
const scale = (w / 2) * (1 - MARGIN);
|
|
const cx = w / 2, cy = h / 2;
|
|
const tx = (x) => cx + x * scale;
|
|
const ty = (y) => cy + y * scale;
|
|
const u = w / 1000; // unit scale relative to a 1000px render
|
|
const blur = (px) => `blur(${(px * u).toFixed(2)}px)`;
|
|
|
|
// ---- noise cache key ----
|
|
const nKey = `${params.seed}|${w}`;
|
|
if (noiseCache.key !== nKey) {
|
|
noiseCache.key = nKey;
|
|
noiseCache.mottleA = mottleCanvas(params.seed, Math.min(512, w), 4, 4); // broad blotches
|
|
noiseCache.mottleB = mottleCanvas(params.seed + '#b', Math.min(512, w), 14, 4); // medium
|
|
noiseCache.grain = grainCanvas(params.seed, Math.max(256, Math.round(w / 2.2)), 1.15);
|
|
}
|
|
|
|
/* ---------- Pass 1: paper + gas glow ---------- */
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = P.paperFlat;
|
|
ctx.fillRect(0, 0, w, h);
|
|
const glow = ctx.createRadialGradient(w * 0.5, h * 0.42, 0, w * 0.5, h * 0.5, w * 0.72);
|
|
glow.addColorStop(0, P.glowIn);
|
|
glow.addColorStop(1, P.glowOut);
|
|
ctx.fillStyle = glow;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
/* ---------- Pass 2: tonal mottle (uneven development) ---------- */
|
|
if (params.mottle > 0) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
|
|
ctx.globalAlpha = params.mottle * 0.5;
|
|
ctx.drawImage(noiseCache.mottleA, 0, 0, w, h);
|
|
ctx.globalAlpha = params.mottle * 0.28;
|
|
ctx.drawImage(noiseCache.mottleB, 0, 0, w, h);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ---------- Pass 2.5: chamber optics / structural geometry ---------- */
|
|
if (scene.instrument) drawInstrument(ctx, scene.instrument, tx, ty, scale, u, P, inv);
|
|
|
|
/* ---------- Pass 3: ink layer (offscreen, transparent) ---------- */
|
|
const ink = document.createElement('canvas');
|
|
ink.width = w; ink.height = h;
|
|
const ic = ink.getContext('2d');
|
|
const [ir, ig, ib] = P.ink;
|
|
const sprite = softDotSprite(P.ink);
|
|
|
|
// NOTE: never set ic.filter here — a filter set before the bubble loop is
|
|
// re-applied to every single drawImage stamp (tens of thousands), which is
|
|
// catastrophically slow and scales with size. Softness is the sprite's job,
|
|
// plus one whole-layer blur at composite time (Pass 5).
|
|
|
|
// 3a. continuity under-stroke beneath each track
|
|
ic.lineCap = 'round'; ic.lineJoin = 'round';
|
|
for (const track of scene.tracks) {
|
|
if (track.pts.length < 2) continue;
|
|
const iw = trackInkWeight(track);
|
|
const wgt = track.weight;
|
|
const lw = Math.min(2.6, (0.25 + Math.sqrt(iw) * 0.12)) * u * params.size * wgt;
|
|
if (lw < 0.2 * u) continue;
|
|
ic.strokeStyle = `rgba(${ir},${ig},${ib},${0.14 * wgt})`;
|
|
ic.lineWidth = lw;
|
|
ic.beginPath();
|
|
ic.moveTo(tx(track.pts[0].x), ty(track.pts[0].y));
|
|
for (let i = 1; i < track.pts.length; i++) ic.lineTo(tx(track.pts[i].x), ty(track.pts[i].y));
|
|
ic.stroke();
|
|
}
|
|
|
|
// 3b. soft blooming bubbles (stamped sprite; overlaps merge into lines)
|
|
const bubbleRng = makeRng(params.seed, 'bubbles');
|
|
for (const track of scene.tracks) {
|
|
const bubs = sampleBubbles(track, params, bubbleRng);
|
|
ic.globalAlpha = Math.min(1, 0.45 + track.weight * 0.5);
|
|
for (const b of bubs) {
|
|
const rr = Math.max(b.r * scale, 0.45);
|
|
const d = rr * 2.4; // sprite footprint a touch larger than core
|
|
ic.drawImage(sprite, tx(b.x) - d / 2, ty(b.y) - d / 2, d, d);
|
|
}
|
|
}
|
|
ic.globalAlpha = 1;
|
|
|
|
// 3c. shock disk drawn into the ink layer so bloom catches it
|
|
if (scene.shock) drawShock(ic, scene.shock, tx, ty, scale, u, P, params, sprite);
|
|
|
|
/* ---------- Pass 4: halation / bloom ---------- */
|
|
// composite a blurred copy of the ink under the sharp ink for soft spread
|
|
if (params.bloom > 0) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = P.inkBlend;
|
|
ctx.globalAlpha = Math.min(0.9, params.bloom * 0.7);
|
|
ctx.filter = blur(pre ? 2.4 : 3.0);
|
|
ctx.drawImage(ink, 0, 0);
|
|
ctx.filter = 'none';
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ---------- Pass 5: composite sharp ink onto paper (one soft-focus blur) ---------- */
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = P.inkBlend;
|
|
ctx.globalAlpha = 1;
|
|
ctx.filter = blur(pre ? 0.5 : 0.7); // single whole-layer blur, not per-stamp
|
|
ctx.drawImage(ink, 0, 0);
|
|
ctx.filter = 'none';
|
|
ctx.restore();
|
|
|
|
/* ---------- Pass 6: plate damage ---------- */
|
|
drawDamage(ctx, scene.artifacts, tx, ty, scale, u, P, inv);
|
|
|
|
/* ---------- Pass 7: fiducials, boundary & archival header ---------- */
|
|
drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv);
|
|
if (params.showHeader) drawHeader(ctx, w, h, scene, params, u, P);
|
|
|
|
/* ---------- Pass 8: vignette ---------- */
|
|
if (params.vign > 0) {
|
|
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.28, w / 2, h / 2, w * 0.72);
|
|
const [vr, vgc, vb] = P.vign;
|
|
vg.addColorStop(0, `rgba(${vr},${vgc},${vb},0)`);
|
|
vg.addColorStop(1, `rgba(${vr},${vgc},${vb},${params.vign * (inv ? 0.5 : 0.85)})`);
|
|
ctx.fillStyle = vg;
|
|
ctx.fillRect(0, 0, w, h);
|
|
}
|
|
|
|
/* ---------- Pass 9: film grain ---------- */
|
|
if (params.grain > 0) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'overlay';
|
|
ctx.globalAlpha = Math.min(0.85, params.grain * 0.7);
|
|
// tile the grain canvas across at ~1:1.6 so clumps are supra-pixel
|
|
const gsz = noiseCache.grain.width;
|
|
const tile = gsz * 1.6;
|
|
for (let gy = 0; gy < h; gy += tile) {
|
|
for (let gx = 0; gx < w; gx += tile) {
|
|
ctx.drawImage(noiseCache.grain, gx, gy, tile, tile);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
/* ---- shock disk ---- */
|
|
function drawShock(c, shock, tx, ty, scale, u, P, params, sprite) {
|
|
const [r, g, b] = P.ink;
|
|
const px = tx(shock.x), py = ty(shock.y), R = shock.r * scale;
|
|
const useBubbles = params.diskBubbles !== false;
|
|
|
|
// when softening, draw the whole disk to its own offscreen and composite it
|
|
// back through a single Gaussian blur — softens just this layer's edges.
|
|
const soften = (params.diskSoften || 0) * u;
|
|
let T = c, tmp = null;
|
|
if (soften > 0) {
|
|
tmp = document.createElement('canvas');
|
|
tmp.width = c.canvas.width; tmp.height = c.canvas.height;
|
|
T = tmp.getContext('2d');
|
|
}
|
|
|
|
// disk body: dark annulus that keeps a lighter, detailed centre.
|
|
// softer when the line work is bubbles (the bubbles carry the density).
|
|
const bodyK = useBubbles ? 0.6 : 1.0;
|
|
const core = T.createRadialGradient(px, py, 0, px, py, R);
|
|
core.addColorStop(0.0, `rgba(${r},${g},${b},${0.06 * shock.intensity * bodyK})`);
|
|
core.addColorStop(0.35, `rgba(${r},${g},${b},${0.18 * shock.intensity * bodyK})`);
|
|
core.addColorStop(0.72, `rgba(${r},${g},${b},${0.38 * shock.intensity * bodyK})`);
|
|
core.addColorStop(0.94, `rgba(${r},${g},${b},${0.34 * shock.intensity * bodyK})`);
|
|
core.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
|
T.fillStyle = core;
|
|
T.beginPath(); T.arc(px, py, R, 0, Math.PI * 2); T.fill();
|
|
|
|
if (useBubbles && shock.bubbleStrokes) {
|
|
// describe striations / rings / rim / core with the particle method
|
|
const dRng = makeRng(params.seed, 'diskbubbles');
|
|
for (const stroke of shock.bubbleStrokes) {
|
|
const bubs = sampleBubbles(stroke, params, dRng);
|
|
T.globalAlpha = Math.min(1, 0.45 + stroke.weight * 0.5);
|
|
for (const bb of bubs) {
|
|
const rr = Math.max(bb.r * scale, 0.45);
|
|
const d = rr * 2.4;
|
|
T.drawImage(sprite, tx(bb.x) - d / 2, ty(bb.y) - d / 2, d, d);
|
|
}
|
|
}
|
|
T.globalAlpha = 1;
|
|
} else {
|
|
// clean vector strokes
|
|
T.lineCap = 'round';
|
|
for (const s of shock.striations) {
|
|
const ix = px + Math.cos(s.a) * s.inner * scale, iy = py + Math.sin(s.a) * s.inner * scale;
|
|
const ox = px + Math.cos(s.a) * s.outer * scale, oy = py + Math.sin(s.a) * s.outer * scale;
|
|
const mx = (ix + ox) / 2 + Math.cos(s.a + Math.PI / 2) * s.wobble * scale;
|
|
const my = (iy + oy) / 2 + Math.sin(s.a + Math.PI / 2) * s.wobble * scale;
|
|
T.strokeStyle = `rgba(${r},${g},${b},${s.opacity})`;
|
|
T.lineWidth = s.width * u;
|
|
T.beginPath(); T.moveTo(ix, iy); T.quadraticCurveTo(mx, my, ox, oy); T.stroke();
|
|
}
|
|
for (const ring of shock.rings) {
|
|
T.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
|
|
T.lineWidth = ring.width * u;
|
|
T.beginPath(); T.arc(px, py, ring.rr * scale, 0, Math.PI * 2); T.stroke();
|
|
}
|
|
for (const seg of (shock.rimSegs || [])) {
|
|
T.strokeStyle = `rgba(${r},${g},${b},${seg.opacity})`;
|
|
T.lineWidth = seg.width * u;
|
|
T.beginPath(); T.arc(px, py, shock.r * scale, seg.a0, seg.a1); T.stroke();
|
|
}
|
|
for (const k of shock.core) {
|
|
T.strokeStyle = `rgba(${r},${g},${b},${k.opacity})`;
|
|
T.lineWidth = k.width * u;
|
|
T.beginPath(); T.moveTo(tx(k.x1), ty(k.y1)); T.lineTo(tx(k.x2), ty(k.y2)); T.stroke();
|
|
}
|
|
}
|
|
|
|
// staining blotches: dark = grime, light = lifted/washed clean spots
|
|
if (shock.stains) {
|
|
for (const st of shock.stains) {
|
|
const sr = st.r * scale;
|
|
const grad = T.createRadialGradient(tx(st.x), ty(st.y), 0, tx(st.x), ty(st.y), sr);
|
|
if (st.dark) {
|
|
T.globalCompositeOperation = 'source-over';
|
|
grad.addColorStop(0, `rgba(${r},${g},${b},${st.opacity})`);
|
|
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
|
T.fillStyle = grad;
|
|
} else {
|
|
T.globalCompositeOperation = 'destination-out';
|
|
grad.addColorStop(0, `rgba(0,0,0,${st.opacity * 1.4})`);
|
|
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
T.fillStyle = grad;
|
|
}
|
|
T.beginPath(); T.arc(tx(st.x), ty(st.y), sr, 0, Math.PI * 2); T.fill();
|
|
}
|
|
T.globalCompositeOperation = 'source-over';
|
|
}
|
|
|
|
// keep a bright, detailed centre by clearing a soft hole
|
|
if (shock.bright) {
|
|
T.save();
|
|
T.globalCompositeOperation = 'destination-out';
|
|
const hole = T.createRadialGradient(px, py, 0, px, py, shock.bright * scale);
|
|
hole.addColorStop(0, 'rgba(0,0,0,0.85)');
|
|
hole.addColorStop(1, 'rgba(0,0,0,0)');
|
|
T.fillStyle = hole;
|
|
T.beginPath(); T.arc(px, py, shock.bright * scale, 0, Math.PI * 2); T.fill();
|
|
T.restore();
|
|
}
|
|
|
|
// composite the (optionally blurred) disk back onto the ink layer
|
|
if (tmp) {
|
|
c.save();
|
|
c.filter = `blur(${soften.toFixed(2)}px)`;
|
|
c.drawImage(tmp, 0, 0);
|
|
c.filter = 'none';
|
|
c.restore();
|
|
}
|
|
}
|
|
|
|
/* ---- plate damage ---- */
|
|
function drawDamage(ctx, A, tx, ty, scale, u, P, inv) {
|
|
if (!A) return;
|
|
const [r, g, b] = P.ink;
|
|
ctx.save();
|
|
|
|
// water rings (faint, under)
|
|
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
|
|
for (const ring of A.rings) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${ring.opacity})`;
|
|
ctx.lineWidth = ring.width * u;
|
|
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), ring.r * scale, 0, Math.PI * 2); ctx.stroke();
|
|
}
|
|
|
|
// fingerprints
|
|
for (const fp of A.fingerprints) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${fp.opacity})`;
|
|
ctx.lineWidth = 0.7 * u;
|
|
for (const lp of fp.loops) {
|
|
ctx.save();
|
|
ctx.translate(tx(fp.x), ty(fp.y));
|
|
ctx.rotate(lp.rot);
|
|
ctx.scale(1, lp.squash);
|
|
ctx.beginPath(); ctx.arc(0, 0, lp.rr * scale, 0, Math.PI * 2); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// scratches: dark (multiply) and bright (lift)
|
|
for (const s of A.scratches) {
|
|
ctx.globalCompositeOperation = s.bright ? P.lift : (inv ? 'multiply' : 'screen');
|
|
const col = s.bright ? (inv ? '245,242,232' : '0,0,0') : `${r},${g},${b}`;
|
|
ctx.strokeStyle = `rgba(${col},${s.opacity})`;
|
|
ctx.lineWidth = s.width * u;
|
|
ctx.beginPath(); ctx.moveTo(tx(s.x1), ty(s.y1)); ctx.lineTo(tx(s.x2), ty(s.y2)); ctx.stroke();
|
|
}
|
|
|
|
// hairs
|
|
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
|
|
for (const hair of A.hairs) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${hair.opacity})`;
|
|
ctx.lineWidth = hair.width * u;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx(hair.pts[0].x), ty(hair.pts[0].y));
|
|
for (let i = 1; i < hair.pts.length; i++) ctx.lineTo(tx(hair.pts[i].x), ty(hair.pts[i].y));
|
|
ctx.stroke();
|
|
}
|
|
|
|
// dust specks (over)
|
|
for (const sp of A.specks) {
|
|
ctx.fillStyle = `rgba(${r},${g},${b},${sp.opacity})`;
|
|
ctx.beginPath(); ctx.arc(tx(sp.x), ty(sp.y), Math.max(sp.r * scale, 0.4), 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ---- chamber optics / structural geometry ---- */
|
|
function drawInstrument(ctx, inst, tx, ty, scale, u, P, inv) {
|
|
const [r, g, b] = P.ink;
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = inv ? 'multiply' : 'screen';
|
|
ctx.lineCap = 'round';
|
|
ctx.filter = `blur(${(0.4 * u).toFixed(2)}px)`;
|
|
for (const l of inst.lines) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${l.opacity})`;
|
|
ctx.lineWidth = l.width * u;
|
|
ctx.beginPath(); ctx.moveTo(tx(l.x1), ty(l.y1)); ctx.lineTo(tx(l.x2), ty(l.y2)); ctx.stroke();
|
|
}
|
|
for (const a of inst.arcs) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},${a.opacity})`;
|
|
ctx.lineWidth = a.width * u;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx(a.pts[0].x), ty(a.pts[0].y));
|
|
for (let i = 1; i < a.pts.length; i++) ctx.lineTo(tx(a.pts[i].x), ty(a.pts[i].y));
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ---- archival header (rendered text, baked into the plate) ---- */
|
|
function drawHeader(ctx, w, h, scene, params, u, P) {
|
|
const [r, g, b] = P.ink;
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = params.invert ? 'multiply' : 'screen';
|
|
ctx.fillStyle = `rgba(${r},${g},${b},0.62)`;
|
|
const pad = 26 * u;
|
|
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(scene.lab.toUpperCase(), pad, pad);
|
|
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
|
|
ctx.fillStyle = `rgba(${r},${g},${b},0.5)`;
|
|
ctx.fillText(`SEED ${params.seed}`, pad, pad + 16 * u);
|
|
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.font = `${10 * u}px 'JetBrains Mono', ui-monospace, monospace`;
|
|
ctx.fillStyle = `rgba(${r},${g},${b},0.58)`;
|
|
ctx.fillText(`PLATE ${scene.plate}`, w - pad, h - pad - 13 * u);
|
|
ctx.fillText(`EXPOSED ${scene.exposure}`, w - pad, h - pad);
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ---- fiducials & chamber boundary ---- */
|
|
function drawFiducialsBoundary(ctx, w, h, params, scale, cx, cy, tx, ty, u, P, inv) {
|
|
const [r, g, b] = P.ink;
|
|
if (params.showBoundary) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},0.4)`;
|
|
ctx.lineWidth = 1.4 * u;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy + h * 0.35, w * 0.45, Math.PI * 0.15, Math.PI - Math.PI * 0.15, false);
|
|
ctx.stroke();
|
|
}
|
|
if (params.showFiducials) {
|
|
ctx.strokeStyle = `rgba(${r},${g},${b},0.55)`;
|
|
ctx.lineWidth = 1 * u;
|
|
const fids = [[-0.85, -0.85], [0.85, -0.85], [-0.85, 0.85], [0.85, 0.85], [0, -0.85], [-0.85, 0], [0.85, 0]];
|
|
const s = 6 * u;
|
|
for (const [fx, fy] of fids) {
|
|
const px = tx(fx), py = ty(fy);
|
|
ctx.beginPath();
|
|
ctx.moveTo(px - s, py); ctx.lineTo(px + s, py);
|
|
ctx.moveTo(px, py - s); ctx.lineTo(px, py + s);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|